let every client watching a chat receive it, not the newest one

Reported from two devices at once: typing on the iPad, reading the reply on the Mac. Sending
from the Mac produced nothing there. Both halves are one field.

A session held `ws`, a single socket, and `attachWs` assigned it. So the newest attach silently
took the turn away from whoever was already watching — and with a tab now holding up to three
panes, plus a phone and a laptop on the same conversation, several sockets per session stopped
being exotic and became the ordinary case. Now a Set, and every message goes to all of them.

`detachWs(sessionId)` was worse, because it named no socket: it nulled the field on ANY close.
A stale client going away therefore killed delivery for the client that had attached after it,
which is the "nothing happens on the Mac" half. It takes the socket now and removes only that
one, and the idle GC is armed only once nothing is left watching — otherwise a close would
collect a session another pane is still reading.

endTurnIfAgentIsGone takes the whole set for the same reason: a cut-off notice explains a
spinner that will otherwise never stop, and telling one of three clients leaves two spinning.

Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both
failing identically on master before this change.

Nobody has clicked it; the two devices that reported it are the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:12:35 +01:00
co-authored by Claude Opus 5
parent cb7ab55cca
commit 7726c9fc71
3 changed files with 36 additions and 14 deletions
+14 -4
View File
@@ -24,7 +24,7 @@ class SessionManager {
cwd,
model,
piProcess: null,
ws: null,
sockets: new Set(),
lastActivity: Date.now(),
idleTimer: null,
streamBuffer: '',
@@ -145,7 +145,7 @@ class SessionManager {
attachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.ws = ws;
session.sockets.add(ws);
session.lastActivity = Date.now();
if (session.idleTimer) {
@@ -155,14 +155,24 @@ class SessionManager {
}
}
detachWs(sessionId: string): void {
/**
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
* session's only socket field, so a stale client's close event silenced whichever client had attached
* after it. A close is only the end of the conversation when nothing else is still watching.
*/
detachWs(sessionId: string, ws: any): void {
const session = this.sessions.get(sessionId);
if (session) {
session.ws = null;
session.sockets.delete(ws);
session.lastActivity = Date.now();
}
}
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
hasSockets(sessionId: string): boolean {
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
}
setIdleTimeout(sessionId: string, timeoutMs: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;