Swap in `ws` as the attached client. A controller claim makes ownership * explicit: a second controller is rejected unless it deliberately takes * over. This is the shared rule for browser, Electron IPC, future IM bridges, * and any debug client that can write to the PTY.
(
ws: WebSocket,
cols: number,
rows: number,
since: number | undefined,
claim?: SessionControllerClaim,
)
| 303 | * over. This is the shared rule for browser, Electron IPC, future IM bridges, |
| 304 | * and any debug client that can write to the PTY. */ |
| 305 | attach( |
| 306 | ws: WebSocket, |
| 307 | cols: number, |
| 308 | rows: number, |
| 309 | since: number | undefined, |
| 310 | claim?: SessionControllerClaim, |
| 311 | ): SessionAttachResult { |
| 312 | if (this.disposed) { |
| 313 | try { |
| 314 | ws.close(1011, 'session disposed'); |
| 315 | } catch { |
| 316 | // ignore |
| 317 | } |
| 318 | return { ok: false, reason: 'locked', owner: this.controller ?? { id: 'disposed', kind: 'session' } }; |
| 319 | } |
| 320 | |
| 321 | const owner = normalizeClaim(claim); |
| 322 | if ( |
| 323 | owner && |
| 324 | this.ws !== null && |
| 325 | this.controller !== null && |
| 326 | this.controller.id !== owner.id && |
| 327 | !claim?.takeover |
| 328 | ) { |
| 329 | try { |
| 330 | ws.close(4409, 'session locked by another controller'); |
| 331 | } catch { |
| 332 | // ignore |
| 333 | } |
| 334 | return { ok: false, reason: 'locked', owner: this.controller }; |
| 335 | } |
| 336 | |
| 337 | // Kick previous client. |
| 338 | if (this.ws !== null && this.ws !== ws) { |
| 339 | const prev = this.ws; |
| 340 | this.unwireWs(prev); |
| 341 | this.ws = null; |
| 342 | try { |
| 343 | prev.close(4001, 'kicked by new attach'); |
| 344 | } catch { |
| 345 | // ignore |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | this.ws = ws; |
| 350 | this.controller = owner; |
| 351 | // A previous client may have dropped mid-backpressure, leaving the PTY |
| 352 | // paused at the OS level. Clearing only the flag (not resuming the term) |
| 353 | // would strand it paused forever; resumePty() un-sticks both. |
| 354 | this.resumePty(); |
| 355 | this.resize(cols, rows); |
| 356 | |
| 357 | // Compute replay window. Cold attach (since=undefined) replays the full |
| 358 | // buffer — without that, a fresh browser tab on a workspace where the |
| 359 | // agent is already idle would just see a black void instead of the prompt |
| 360 | // and recent output. Hot attach (since=N) only fills in what was missed. |
| 361 | const requested = since ?? 0; |
| 362 | const slice = this.buffer.since(requested); |
no test coverage detected