@edspencer/paddock 0.66.0 → 0.66.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +8 -0
  3. package/packages/server/dist/app.js +4 -1
  4. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  5. package/packages/server/dist/herdctl-jobs.js +94 -0
  6. package/packages/server/dist/herdctl.d.ts +46 -0
  7. package/packages/server/dist/herdctl.js +58 -0
  8. package/packages/server/dist/instance-config.d.ts +56 -5
  9. package/packages/server/dist/instance-config.js +206 -24
  10. package/packages/server/dist/project-paths.d.ts +46 -1
  11. package/packages/server/dist/project-paths.js +66 -2
  12. package/packages/server/dist/project-types.d.ts +23 -0
  13. package/packages/server/dist/project-types.js +35 -0
  14. package/packages/server/dist/projects.d.ts +5 -2
  15. package/packages/server/dist/projects.js +71 -17
  16. package/packages/server/dist/queued-message.d.ts +87 -0
  17. package/packages/server/dist/queued-message.js +214 -1
  18. package/packages/server/dist/routes/chats.js +27 -8
  19. package/packages/server/dist/routes/meta.js +32 -5
  20. package/packages/server/dist/routes/projects.js +71 -7
  21. package/packages/server/dist/session-hub.d.ts +69 -1
  22. package/packages/server/dist/session-hub.js +73 -1
  23. package/packages/server/dist/turn-interlock.js +8 -0
  24. package/packages/server/dist/ws-protocol.d.ts +128 -5
  25. package/packages/server/dist/ws-protocol.js +30 -13
  26. package/packages/server/dist/ws.d.ts +0 -52
  27. package/packages/server/dist/ws.js +545 -127
  28. package/packages/server/package.json +1 -1
  29. package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
  30. package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
  31. package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
  32. package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
  33. package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
  34. package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
  35. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
  36. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
  37. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
  38. package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
  39. package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
  47. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
  48. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
  49. package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
  50. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
  51. package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
  57. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
  58. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
  59. package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
  60. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
  61. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
  62. package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
  63. package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
  64. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
  65. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
  66. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
  67. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
  68. package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
  69. package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
  70. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
  71. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
  72. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
  73. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
  84. package/packages/web/dist/index.html +2 -2
  85. package/packages/web/dist/sw.js +1 -1
  86. package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
  87. package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
  88. package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
  91. package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
  92. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  93. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  94. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
@@ -1,9 +1,63 @@
1
+ /**
2
+ * WebSocket chat transport.
3
+ *
4
+ * Protocol (the contract the frontend agent matches):
5
+ *
6
+ * client -> server:
7
+ * { type: "chat:send", payload: {
8
+ * projectSlug: string, // workspace key: a project slug, or "" for the root
9
+ * sessionId: string | null, // resume an existing chat, or null = new
10
+ * message: string,
11
+ * preloadContext?: boolean, // new chat: prepend the project OVERVIEW.md
12
+ * model?: string, // per-chat model override (a known model id)
13
+ * } }
14
+ * { type: "chat:cancel", payload: { jobId } } // optional: stop a running turn
15
+ * { type: "ping" }
16
+ *
17
+ * server -> client (all carry projectSlug + sessionId + jobId for routing):
18
+ * { type: "chat:response", payload: { projectSlug, sessionId, jobId, chunk } }
19
+ * { type: "chat:tool_call", payload: { projectSlug, sessionId, jobId,
20
+ * toolName, inputSummary, output,
21
+ * isError, durationMs } }
22
+ * { type: "chat:message_boundary", payload: { projectSlug, sessionId, jobId } }
23
+ * { type: "chat:complete", payload: { projectSlug, sessionId, jobId, success, error?,
24
+ * model?, usage? } }
25
+ * // model: the model the turn ran on (lastModel ?? effectiveModel).
26
+ * // usage: { inputTokens, outputTokens, cacheReadTokens,
27
+ * // cacheCreationTokens, contextTokens, contextLimit } — the
28
+ * // LAST per-turn usage observed; omitted (with model) if none.
29
+ * // contextTokens = input + cacheRead + cacheCreation;
30
+ * // contextLimit = getContextLimit(model). Stale-by-one-turn
31
+ * // by design (it reflects the just-completed turn's input).
32
+ * { type: "chat:injected", payload: { projectSlug, sessionId, jobId,
33
+ * sender, content, timestamp } }
34
+ * // A machine-injected user turn (issue #290 Part 2): another chat
35
+ * // send_message'd / a schedule fired into this session. Emitted so a
36
+ * // client already viewing the recipient chat renders the injected user
37
+ * // bubble LIVE (with its sender attribution) instead of only seeing the
38
+ * // assistant reply and needing a refresh. `sender` is the MessageSender.
39
+ * { type: "chat:error", payload: { projectSlug, error } }
40
+ * { type: "pong" }
41
+ *
42
+ * Streaming is wired for real via HerdctlService.chat()'s onMessage callback
43
+ * (the public trigger API supports it). The SDKMessage -> chat-event translation
44
+ * (assistant text deltas, message boundaries, and paired tool_use -> tool_result
45
+ * calls enriched with input summaries + wall-clock durations) is done by
46
+ * @herdctl/chat's `createSDKMessageHandler` — the shared, transport-agnostic
47
+ * translator every herdctl chat surface uses — so paddock no longer reimplements
48
+ * it (and as of @herdctl/chat@0.4.1 it pairs CLI tool results correctly, so the
49
+ * prior `normalizeForTranslator` shim is gone). We compose it with a tiny wrapper
50
+ * that also captures, from each raw SDK message, the session id and the per-turn
51
+ * usage + model (the translator only exposes text/boundary/tool events).
52
+ */
53
+ import { randomUUID } from "node:crypto";
1
54
  import { createSDKMessageHandler } from "@herdctl/chat";
2
55
  import { extractSubagentLaunches, subagentLaunchFields, } from "./subagents.js";
3
56
  import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
4
57
  import { consumeResumedTurn } from "./resume-drain.js";
5
58
  import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
6
59
  import { SessionHub } from "./session-hub.js";
60
+ import { hasQueuedContent, partsOf, } from "./queued-message.js";
7
61
  import { resolveAttachmentsConfig } from "./attachments-config.js";
8
62
  import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
9
63
  import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
@@ -40,6 +94,48 @@ const SERVER_PING_INTERVAL_MS = 30_000;
40
94
  // ripgrep/grep treat this file as binary and skip it during directory traversal
41
95
  // (silently, exit 1, indistinguishable from "no matches"). See issue #570.
42
96
  const KEY_SEP = "\u0000";
97
+ /**
98
+ * Cap on a single queued message. The sidecar is rewritten in full on every queue
99
+ * mutation and the text is persisted verbatim, so an unbounded queue is a cheap
100
+ * way to bloat the data dir (a 2 MB `chat:set_queue` was accepted as-is). Well
101
+ * above any hand-typed follow-up, including a pasted stack trace.
102
+ */
103
+ const MAX_QUEUED_CHARS = 100_000;
104
+ /**
105
+ * The opaque identity of the client queue a `chat:set_queue` belongs to (#736).
106
+ *
107
+ * Whatever the client supplies is used for EQUALITY ONLY — never compared as a
108
+ * clock — so a skewed clock can't poison anything. A legacy client sends only the
109
+ * enqueue `ts`, which is folded into an id (still a perfectly good per-client
110
+ * identity, just no longer an ordering). A client that sends neither gets a fresh
111
+ * id, so its queue is always treated as new: with nothing stable to match on, the
112
+ * safe failure is to send the message rather than to discard it.
113
+ */
114
+ function queueIdOf(payload) {
115
+ if (typeof payload.qid === "string" && payload.qid.length > 0)
116
+ return payload.qid;
117
+ if (typeof payload.ts === "number")
118
+ return `ts:${payload.ts}`;
119
+ return `anon:${randomUUID()}`;
120
+ }
121
+ /** Write one frame straight to a single socket, skipping a closed one. */
122
+ function writeFrame(socket, msg) {
123
+ if (socket.readyState !== socket.OPEN)
124
+ return;
125
+ try {
126
+ socket.send(JSON.stringify(msg));
127
+ }
128
+ catch {
129
+ /* a socket that throws on send is effectively gone */
130
+ }
131
+ }
132
+ /**
133
+ * The identity of a slot as a whole (not of one contribution). Written by every
134
+ * `upsert`; synthesised for an entry an older server wrote without one.
135
+ */
136
+ function slotVersion(m) {
137
+ return m.id ?? `legacy:${m.createdAtMs}`;
138
+ }
43
139
  export function makeChatHandler(deps) {
44
140
  // ONE hub shared across every socket this handler serves: it tracks each
45
141
  // session's in-flight turn and fans its frames out to whichever socket(s) are
@@ -53,14 +149,61 @@ export function makeChatHandler(deps) {
53
149
  // double-send only survives a server restart, when the persisted store is
54
150
  // already empty anyway.
55
151
  //
56
- // A message's identity is the (ts, text) TUPLE, not the timestamp alone (#628).
57
- // The client deliberately KEEPS one enqueue ts when APPENDING to an existing
58
- // queue (#245 stable identity), so "same ts, different text" is a genuinely new
59
- // message — deduping on the ts alone silently destroyed the appended text.
60
- // Timestamps are monotonic per session (a fresh queue stamps `Date.now()`), so
61
- // anything strictly OLDER than `ts` is stale by construction and only the texts
62
- // flushed AT `ts` need remembering — one bounded set per session.
152
+ // A queued message's identity is the (queue id, text) TUPLE, matched by
153
+ // EQUALITY (#628/#736). The client KEEPS one queue id when APPENDING to an
154
+ // existing queue (#245 stable identity), so "same id, different text" is a
155
+ // genuinely new message — deduping on the id alone would silently destroy the
156
+ // appended text.
157
+ //
158
+ // This used to be a (ts, text) tuple with an ORDERING rule on top: anything
159
+ // older than the last flushed ts was "stale by construction, timestamps being
160
+ // monotonic". They are monotonic within ONE client — but the ts came from
161
+ // whichever browser queued the message, so a single client with a fast clock
162
+ // parked the marker in the future and every subsequent queued message on that
163
+ // chat, from ANY client, was silently destroyed until wall-clock time caught up
164
+ // (#736). Equality assumes nothing about clocks, so the ordering rule is gone;
165
+ // in exchange every flushed id has to be remembered rather than just the newest.
166
+ //
167
+ // Bounded on both axes: MAX_FLUSHED_SESSIONS chats, MAX_FLUSHED_IDS ids each,
168
+ // oldest evicted first (insertion-ordered Maps). Overflow can only cost a rare
169
+ // double-send of a re-asserted message, the same exposure a server restart has.
63
170
  const lastFlushed = new Map();
171
+ const MAX_FLUSHED_SESSIONS = 512;
172
+ const MAX_FLUSHED_IDS = 32;
173
+ const MAX_FLUSHED_TEXTS = 8;
174
+ /** This session's flushed-parts ledger (id -> the texts flushed under it). */
175
+ const flushedLedger = (markerKey) => {
176
+ let m = lastFlushed.get(markerKey);
177
+ if (!m) {
178
+ m = new Map();
179
+ lastFlushed.set(markerKey, m);
180
+ if (lastFlushed.size > MAX_FLUSHED_SESSIONS) {
181
+ lastFlushed.delete(lastFlushed.keys().next().value);
182
+ }
183
+ }
184
+ return m;
185
+ };
186
+ // Slots we could NOT hand back to a composer on Stop, keyed `agent \0 sessionId`
187
+ // → the slot version that was parked. A parked message stays queued and visible,
188
+ // but no turn end auto-sends it: the user pressed Stop, and firing their
189
+ // follow-up later — behind whatever they type next — is the stranding this whole
190
+ // change exists to remove. Any real edit mints a new slot version, so the parked
191
+ // id simply stops matching and the message becomes ordinary again; a bare
192
+ // re-assert keeps the version and stays parked. Same bound as the ledger above.
193
+ const parked = new Map();
194
+ /** Record one flushed part, trimming the oldest entries past the bounds. */
195
+ const rememberFlushed = (ledger, id, text) => {
196
+ let texts = ledger.get(id);
197
+ if (!texts) {
198
+ texts = new Set();
199
+ ledger.set(id, texts);
200
+ if (ledger.size > MAX_FLUSHED_IDS)
201
+ ledger.delete(ledger.keys().next().value);
202
+ }
203
+ texts.add(text);
204
+ if (texts.size > MAX_FLUSHED_TEXTS)
205
+ texts.delete(texts.values().next().value);
206
+ };
64
207
  // Every currently-connected socket, so a turn's start/stop transition can be
65
208
  // broadcast to all clients — powering the per-chat sidebar streaming dots that
66
209
  // must update even for chats whose pane isn't mounted (issue #53).
@@ -253,84 +396,19 @@ export function makeChatHandler(deps) {
253
396
  // consumes the returned surface.
254
397
  const engine = makeTurnEngine({ deps, hub });
255
398
  const { startAgentTurn, wakeInjection, selfMcpCtx, injectRecoveryNudge, recoveryEngine, emitAfterTurn, composePreloadedPrompt, fireTrigger, makeBackgroundTurnSink, } = engine;
256
- const handle = async function handle(socket) {
257
- const send = (m) => {
258
- if (socket.readyState === socket.OPEN)
259
- socket.send(JSON.stringify(m));
260
- };
261
- // Register this socket for active-turn broadcasts, and immediately catch it
262
- // up on which sessions are currently running (so the sidebar dots and a
263
- // returning pane's Stop button reflect reality from the first paint).
264
- clients.add(socket);
265
- for (const info of hub.runningSessions())
266
- send(activeFrame(info));
267
- // Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
268
- // client whose TCP has silently died (idle drop, sleep) fails to pong and is
269
- // terminated on the next tick — freeing server resources and letting the
270
- // client's own reconnect take over. Cleared when the socket closes.
271
- let isAlive = true;
272
- socket.on("pong", () => {
273
- isAlive = true;
274
- });
275
- const heartbeat = setInterval(() => {
276
- if (!isAlive) {
277
- socket.terminate();
278
- return;
279
- }
280
- isAlive = false;
281
- try {
282
- socket.ping();
283
- }
284
- catch {
285
- socket.terminate();
286
- }
287
- }, SERVER_PING_INTERVAL_MS);
288
- socket.on("close", () => {
289
- clearInterval(heartbeat);
290
- // Drop this socket from every session fan-out set so the hub stops trying
291
- // to write to it (a running turn keeps going for other attached sockets).
292
- hub.unsubscribeSocket(socket);
293
- clients.delete(socket);
294
- });
295
- socket.on("message", (raw) => {
296
- let parsed;
297
- try {
298
- parsed = JSON.parse(raw.toString());
299
- }
300
- catch {
301
- send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
302
- return;
303
- }
304
- if (!isClientMessage(parsed)) {
305
- send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
306
- return;
307
- }
308
- if (parsed.type === "ping") {
309
- send({ type: "pong" });
310
- return;
311
- }
312
- if (parsed.type === "chat:cancel") {
313
- void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
314
- return;
315
- }
316
- if (parsed.type === "chat:subscribe") {
317
- onSubscribe(parsed);
318
- return;
319
- }
320
- if (parsed.type === "chat:set_queue") {
321
- void onSetQueue(parsed);
322
- return;
323
- }
324
- if (parsed.type === "chat:command") {
325
- void onChatCommand(parsed);
326
- return;
327
- }
328
- if (parsed.type === "chat:continue") {
329
- void onChatContinue(parsed);
330
- return;
331
- }
332
- void onChatSend(parsed);
333
- });
399
+ /**
400
+ * Build the chat operations for ONE connection: the handlers behind every
401
+ * client frame, plus the queue drain they share.
402
+ *
403
+ * A factory rather than plain closures inside {@link handle} because the drain
404
+ * has to run with NO socket at all (#627). A turn started by a trigger, a
405
+ * scheduler wake or a spawn has no originating connection, and a queued
406
+ * follow-up must still go out when it ends — so `socket` is nullable and the
407
+ * ops are built once, socket-less, for those paths (see `headlessOps` below).
408
+ * A socket-less send is a no-op: everything a client needs to see travels over
409
+ * the hub, which fans out to every socket attached to the session.
410
+ */
411
+ const makeChatOps = (socket, send) => {
334
412
  /**
335
413
  * Manual keeper recovery (issue #301, Layer 2). Re-drive a hung keeper whose
336
414
  * background task was killed at the turn boundary by injecting the recovery
@@ -369,6 +447,8 @@ export function makeChatHandler(deps) {
369
447
  });
370
448
  };
371
449
  const onSubscribe = (msg) => {
450
+ if (!socket)
451
+ return; // the socket-less ops never receive client frames
372
452
  const { sessionId, wantReplay, lastSeq } = msg.payload;
373
453
  const result = hub.attach(sessionId, socket, {
374
454
  wantReplay: wantReplay === true,
@@ -387,63 +467,158 @@ export function makeChatHandler(deps) {
387
467
  const active = hub.activeInfo(sessionId);
388
468
  if (active)
389
469
  send(activeFrame(active));
470
+ // …and what is currently QUEUED on it (#629). The queue is shared chat
471
+ // state, but a pane could only ever learn it from its own localStorage, so
472
+ // a message queued on another device — or one parked by a Stop nobody was
473
+ // left to hand it back to — was invisible to whoever opened the chat next.
474
+ const slug = msg.payload.projectSlug;
475
+ if (deps.queuedMessage && typeof slug === "string") {
476
+ void deps.queuedMessage
477
+ .get(keeperAgentName(slug), sessionId)
478
+ .then((queued) => {
479
+ if (!hasQueuedContent(queued))
480
+ return;
481
+ send({
482
+ type: "chat:queued_state",
483
+ payload: {
484
+ projectSlug: slug,
485
+ sessionId,
486
+ text: queued.text || null,
487
+ ...(queued.id ? { qid: queued.id } : {}),
488
+ ...(queued.attachments?.length ? { attachments: queued.attachments } : {}),
489
+ },
490
+ });
491
+ })
492
+ .catch(() => undefined);
493
+ }
390
494
  };
391
495
  // Server-authoritative queue drain (#245): auto-send a persisted queued
392
- // message as the next turn, exactly once. Called (a) when a turn completes
393
- // successfully and (b) when a queue is set while the session is idle — a queue
394
- // that arrived (e.g. via the reconnect outbox) after the turn it was meant to
395
- // follow already ended. `take` makes the read+clear atomic so the two callers
396
- // can never both send it; the `lastFlushed` marker skips a message already
397
- // drained (a stale client re-assert on reload) so it isn't sent twice.
496
+ // message as the next turn, exactly once. Called (a) whenever a turn ends —
497
+ // through the hub, so EVERY turn path drains (#627) — and (b) when a queue is
498
+ // set while the session is idle, i.e. a queue that arrived (e.g. via the
499
+ // reconnect outbox) after the turn it was meant to follow already ended.
500
+ // `take` makes the read+clear atomic so two concurrent drains can never both
501
+ // send it; the flushed-parts ledger skips what was already drained (a stale
502
+ // client re-assert on reload) so it isn't sent twice.
503
+ /**
504
+ * Put attachments the drain could not carry back onto the (now empty) slot as
505
+ * an attachments-only queued message, and tell every client (#728). Only the
506
+ * slash-command path uses this: a command cannot carry files, and dropping
507
+ * them here would be the silent loss this whole change is about.
508
+ */
509
+ const restageAttachments = async (slug, sessionId, attachments) => {
510
+ if (!deps.queuedMessage)
511
+ return;
512
+ const agent = keeperAgentName(slug);
513
+ await deps.queuedMessage
514
+ .set(agent, sessionId, {
515
+ text: "",
516
+ createdAtMs: Date.now(),
517
+ id: randomUUID(),
518
+ attachments,
519
+ })
520
+ .catch(() => undefined);
521
+ hub.broadcast(sessionId, {
522
+ type: "chat:queued_state",
523
+ payload: { projectSlug: slug, sessionId, text: null, attachments },
524
+ });
525
+ };
398
526
  const drainQueue = async (slug, sessionId) => {
399
527
  if (!deps.queuedMessage)
400
528
  return;
529
+ // Another turn is already live on this session (a drain racing a fresh send,
530
+ // or a turn ending while its successor has started). Leave the queue for
531
+ // that turn's own end — sending now would interleave two turns.
532
+ if (hub.isRunning(sessionId))
533
+ return;
401
534
  const agent = keeperAgentName(slug);
402
535
  const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
403
- if (!queued?.text)
536
+ // A slot with no prose but staged files is a real message (#328/#728), so
537
+ // "is there anything here" is content, not text.
538
+ if (!hasQueuedContent(queued) || !queued)
404
539
  return;
405
540
  const markerKey = `${agent}${KEY_SEP}${sessionId}`;
406
- const marker = lastFlushed.get(markerKey);
407
- // Already drained, on the (ts, text) tuple (#628): either this exact text
408
- // went out at this exact ts, or the whole message predates the last one we
409
- // flushed — which, timestamps being monotonic, can only be a stale re-assert.
410
- // An APPEND reuses the ts with longer text, so it is NOT a duplicate.
411
- const already = marker !== undefined &&
412
- (queued.createdAtMs < marker.ts ||
413
- (queued.createdAtMs === marker.ts && marker.texts.has(queued.text)));
541
+ // Parked by a Stop we couldn't hand back (see returnQueueToComposer): the
542
+ // user asked for control, so it waits for them, not for the next turn end.
543
+ // Put it back — `take` already removed it.
544
+ if (parked.get(markerKey) === slotVersion(queued)) {
545
+ await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
546
+ return;
547
+ }
548
+ const ledger = flushedLedger(markerKey);
549
+ // Have we already sent this? (#628/#736.)
550
+ //
551
+ // Identity is the (id, text) TUPLE, matched by EQUALITY, against every
552
+ // identity the slot has been known by — so a client re-asserting its stored
553
+ // copy on reload is recognised whether it holds the id it sent or the slot
554
+ // version the server broadcast back.
555
+ //
556
+ // It used to be a (ts, text) tuple with an ORDERING rule on top: "older than
557
+ // the last flush ⇒ stale by construction, timestamps being monotonic". They
558
+ // are monotonic within one client, but the ts came from whichever BROWSER
559
+ // queued the message — so a single client with a fast clock left the marker
560
+ // five minutes in the future, and every later queued message on that chat,
561
+ // from any client, was taken, classified already-flushed, deleted and never
562
+ // sent, until wall-clock time caught up (#736). Equality assumes nothing
563
+ // about clocks. An APPEND keeps its identity with LONGER text (#245), so it
564
+ // still reads as a genuinely new message and is sent — #628, unchanged.
565
+ const parts = partsOf(queued);
566
+ const stale = parts.some((p) => ledger.get(p.id)?.has(p.text));
567
+ const text = stale ? "" : queued.text;
568
+ // A leading-slash queued message is a slash command (e.g. "/compact"): the
569
+ // CLI dispatches it, and a command carries no attachments — exactly as the
570
+ // live composer refuses to attach files to one. Keep them on the slot rather
571
+ // than destroying them: they stay visible as a queued message of their own
572
+ // and go out after the command's turn, which is the only outcome here that
573
+ // isn't silent loss (#728). Unreachable from the current client, which never
574
+ // stages files onto a slash-command queue; reachable if one ever appends
575
+ // prose + a file behind an already-queued command.
576
+ const isCommand = !stale && text.startsWith("/");
577
+ const attachments = stale || isCommand ? [] : (queued.attachments ?? []);
578
+ // An attachments-only slot has no text but is still a real message
579
+ // (#328/#728), so what makes this a send is CONTENT, not prose.
580
+ const sending = !stale && (Boolean(text) || attachments.length > 0);
414
581
  // Tell every attached client (origin + reconnected sockets) to clear its copy
415
- // of this message. When we're really sending it, carry the text so the client
416
- // renders the sent bubble in-transcript; on a stale re-assert we only clear.
582
+ // of this message. When we're really sending it, carry the text + attachments
583
+ // so the client renders the sent bubble in-transcript exactly as a live send
584
+ // does; on a stale re-assert we only clear.
417
585
  hub.broadcast(sessionId, {
418
586
  type: "chat:queued_flushed",
419
587
  payload: {
420
588
  projectSlug: slug,
421
589
  sessionId,
422
- ...(already ? {} : { text: queued.text }),
590
+ ...(sending && text ? { text } : {}),
591
+ ...(sending && attachments.length > 0 ? { attachments } : {}),
423
592
  },
424
593
  });
425
- if (already)
594
+ if (!sending)
426
595
  return;
427
- // Record the tuple. A newer ts supersedes the marker outright (everything at
428
- // the old ts is now stale); the same ts adds one more flushed text.
429
- if (marker !== undefined && marker.ts === queued.createdAtMs)
430
- marker.texts.add(queued.text);
431
- else
432
- lastFlushed.set(markerKey, { ts: queued.createdAtMs, texts: new Set([queued.text]) });
596
+ // Every identity this slot carried is now flushed, so a re-assert from any
597
+ // client that held one of them is recognised rather than sent again.
598
+ for (const p of parts)
599
+ rememberFlushed(ledger, p.id, p.text);
433
600
  // Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
434
- // above the reply. Run it detached, like a human send. A leading-slash queued
435
- // message is a slash command (e.g. "/compact"): route it through the command
436
- // path so the CLI dispatches it — matching how the composer sends one live.
437
- if (queued.text.startsWith("/")) {
601
+ // above the reply. Run it detached, like a human send.
602
+ if (isCommand) {
603
+ if ((queued.attachments?.length ?? 0) > 0) {
604
+ void restageAttachments(slug, sessionId, queued.attachments);
605
+ }
438
606
  void onChatCommand({
439
607
  type: "chat:command",
440
- payload: { projectSlug: slug, command: queued.text, sessionId },
608
+ payload: { projectSlug: slug, command: text, sessionId },
441
609
  });
442
610
  }
443
611
  else {
444
612
  void onChatSend({
445
613
  type: "chat:send",
446
- payload: { projectSlug: slug, sessionId, message: queued.text },
614
+ payload: {
615
+ projectSlug: slug,
616
+ sessionId,
617
+ message: text,
618
+ // The files the user staged behind this message (#728). They used to
619
+ // stay in the composer tray and ride whatever was sent NEXT.
620
+ ...(attachments.length > 0 ? { attachments } : {}),
621
+ },
447
622
  });
448
623
  }
449
624
  };
@@ -456,18 +631,59 @@ export function makeChatHandler(deps) {
456
631
  return;
457
632
  const sessionId = msg.payload.sessionId ?? null;
458
633
  const text = msg.payload.text ?? null;
634
+ // The composer's staged files ride the queued message (#728). They merge
635
+ // additively into the slot, so a write that carries none never removes any.
636
+ const attachments = msg.payload.attachments ?? [];
459
637
  // Every chat belongs to a workspace keeper.
460
638
  const agent = keeperAgentName(slug);
461
639
  if (!sessionId) {
462
640
  // New chat: queue isn't stored until the session id exists. The client
463
- // re-asserts it (with the same ts) once the id resolves, so it persists then.
641
+ // re-asserts it (same qid) once the id resolves, so it persists then.
464
642
  return;
465
643
  }
466
- // Store or clear the queued message.
467
- if (text && text.trim().length > 0) {
468
- await deps.queuedMessage
469
- .set(agent, sessionId, { text, createdAtMs: msg.payload.ts ?? Date.now() })
644
+ // Store or clear the queued message. A slot with staged files but no prose is
645
+ // a real message (#328: an attachment-only send is valid), so "is there
646
+ // anything to store" is text OR attachments — it used to be text alone, which
647
+ // is why an attachment-only submit during a live turn was a silent no-op.
648
+ if ((text && text.trim().length > 0) || attachments.length > 0) {
649
+ // A queued message is persisted verbatim and the whole sidecar is rewritten
650
+ // on every mutation, so an unbounded one is a cheap way to bloat the data
651
+ // dir. Refuse it loudly rather than silently truncating the user's text.
652
+ if (text != null && text.length > MAX_QUEUED_CHARS) {
653
+ send({
654
+ type: "chat:error",
655
+ payload: {
656
+ projectSlug: slug,
657
+ error: `Queued message is too long (${text.length} characters; the limit is ${MAX_QUEUED_CHARS}).`,
658
+ },
659
+ });
660
+ return;
661
+ }
662
+ // Merge into the chat's single slot rather than overwriting it (#629). The
663
+ // id the client wrote under says which this is: a client editing the slot
664
+ // it can currently see (replace) or one that queued without knowing what
665
+ // was already there (append). The old unconditional overwrite destroyed the
666
+ // first client's message with no signal to anyone — and then rendered it,
667
+ // in that client's own transcript, as a user bubble they never typed.
668
+ const next = await deps.queuedMessage
669
+ .upsert(agent, sessionId, { id: queueIdOf(msg.payload), text: text ?? "", attachments }, Date.now(), randomUUID)
470
670
  .catch(() => undefined);
671
+ // The queue is shared chat state, so announce it: every attached client
672
+ // renders the same slot, and echoing `qid` back on its next write is how a
673
+ // client proves it is editing the slot as it stands rather than replacing
674
+ // someone else's text with its own.
675
+ if (next !== undefined) {
676
+ hub.broadcast(sessionId, {
677
+ type: "chat:queued_state",
678
+ payload: {
679
+ projectSlug: slug,
680
+ sessionId,
681
+ text: next?.text || null,
682
+ ...(next?.id ? { qid: next.id } : {}),
683
+ ...(next?.attachments?.length ? { attachments: next.attachments } : {}),
684
+ },
685
+ });
686
+ }
471
687
  // If no turn is running, this queue arrived after the turn it was meant to
472
688
  // follow already ended — drain it now rather than wait for a completion
473
689
  // that won't come (the reported stranding bug, #245).
@@ -475,7 +691,14 @@ export function makeChatHandler(deps) {
475
691
  await drainQueue(slug, sessionId);
476
692
  }
477
693
  else {
694
+ // A clear wipes the WHOLE slot, including another client's contribution:
695
+ // with the state broadcast the chip is shared, so clearing it is a
696
+ // deliberate act on something the user can actually see.
478
697
  await deps.queuedMessage.set(agent, sessionId, null).catch(() => undefined);
698
+ hub.broadcast(sessionId, {
699
+ type: "chat:queued_state",
700
+ payload: { projectSlug: slug, sessionId, text: null },
701
+ });
479
702
  }
480
703
  };
481
704
  const onChatSend = async (msg) => {
@@ -838,13 +1061,13 @@ export function makeChatHandler(deps) {
838
1061
  ...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
839
1062
  },
840
1063
  });
1064
+ // Ending the turn drains any queued follow-up (#197/#245) via the hub's
1065
+ // turn-end hook — the ONE place every turn path converges (#627), rather
1066
+ // than this call site alone. It no longer waits for a SUCCESSFUL turn:
1067
+ // holding the queue on a failed/Stopped turn didn't keep it for the user,
1068
+ // it stranded it, to be flushed by the NEXT `chat:send` — landing behind a
1069
+ // message typed later, with a stale chip in between. See drainQueue.
841
1070
  turn.end();
842
- // After a SUCCESSFUL turn, auto-send any queued follow-up (#197/#245). A
843
- // Stop/failed turn holds the queue for the user (no drain). drainQueue owns
844
- // the take + client notify + next-turn kickoff, shared with the idle path.
845
- if (effectiveSuccess && finalSession) {
846
- await drainQueue(slug, finalSession);
847
- }
848
1071
  // Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
849
1072
  // keeper turn. If this turn launched a background task that the runtime kills
850
1073
  // at the turn boundary (herdctl#374) and the keeper doesn't wake on its own,
@@ -1040,6 +1263,201 @@ export function makeChatHandler(deps) {
1040
1263
  turn.end();
1041
1264
  }
1042
1265
  };
1266
+ return { onChatContinue, onSubscribe, drainQueue, onSetQueue, onChatSend, onChatCommand };
1267
+ };
1268
+ // The ops used when a turn ends with no socket behind it — a trigger, a wake, a
1269
+ // spawned turn, a background sub-agent stretch — so the drain still has a way to
1270
+ // dispatch the follow-up. Built once: it holds no per-connection state.
1271
+ const headlessOps = makeChatOps(null, () => { });
1272
+ // Prefer the ending turn's ORIGIN socket when there is one, so a drained turn
1273
+ // keeps the same fan-out (and error routing) the turn it followed had; a turn
1274
+ // with no origin falls back to the headless ops.
1275
+ const opsFor = (origin) => origin
1276
+ ? makeChatOps(origin, (m) => {
1277
+ if (origin.readyState === origin.OPEN)
1278
+ origin.send(JSON.stringify(m));
1279
+ })
1280
+ : headlessOps;
1281
+ /**
1282
+ * Stop hands the queued message BACK to the user instead of sending it.
1283
+ *
1284
+ * Stop means "give me control back", so the one thing it must not do is have
1285
+ * the agent start working again on the follow-up the instant it is stopped.
1286
+ * Holding the message server-side isn't the answer either — that is exactly
1287
+ * what stranded it (#627): it sat there and went out behind whatever the user
1288
+ * typed next. So it goes back to the composer of the client that pressed Stop,
1289
+ * where it is visible, editable, and sent only when they say so. The client
1290
+ * runs its existing "Edit queued message" path on the frame; the composer draft
1291
+ * is persisted to localStorage by the same effect that persists any draft.
1292
+ *
1293
+ * Every OTHER turn-ending path — normal completion, a slash command, a trigger,
1294
+ * a wake, a spawn, a background stretch — still drains. This is the single
1295
+ * explicit exception, not a gap.
1296
+ */
1297
+ const returnQueueToComposer = async (slug, sessionId, to) => {
1298
+ if (!deps.queuedMessage)
1299
+ return;
1300
+ // A successor turn is already live (a Stop racing the next send): leave the
1301
+ // queue to that turn's own end rather than yanking it mid-flight.
1302
+ if (hub.isRunning(sessionId))
1303
+ return;
1304
+ const agent = keeperAgentName(slug);
1305
+ const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
1306
+ if (!hasQueuedContent(queued) || !queued)
1307
+ return;
1308
+ const attachments = queued.attachments ?? [];
1309
+ const live = to && to.readyState === to.OPEN ? to : null;
1310
+ if (!live) {
1311
+ // Nobody to hand it to — the tab closed between the Stop and this turn
1312
+ // ending, or the cancel came from no socket at all. Put it back and PARK
1313
+ // it: still queued, still visible (every attached client is told, and a
1314
+ // pane opening this chat is told on subscribe), but no turn end will
1315
+ // auto-send it. Dropping it here would be exactly the silent loss this
1316
+ // change is about, and sending it would be the surprise Stop must not have.
1317
+ await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
1318
+ const key = `${agent}${KEY_SEP}${sessionId}`;
1319
+ parked.set(key, slotVersion(queued));
1320
+ if (parked.size > MAX_FLUSHED_SESSIONS) {
1321
+ parked.delete(parked.keys().next().value);
1322
+ }
1323
+ hub.broadcast(sessionId, {
1324
+ type: "chat:queued_state",
1325
+ payload: {
1326
+ projectSlug: slug,
1327
+ sessionId,
1328
+ text: queued.text || null,
1329
+ ...(queued.id ? { qid: queued.id } : {}),
1330
+ ...(attachments.length > 0 ? { attachments } : {}),
1331
+ },
1332
+ });
1333
+ return;
1334
+ }
1335
+ // The stopping client gets the text AND the files staged behind it (#728) —
1336
+ // Stop returns the whole message, and a file left on the cleared slot would be
1337
+ // stranded server-side. Everyone else just sees the shared slot clear, WITH a
1338
+ // reason — a chip vanishing from under you with no explanation is the same
1339
+ // silence that made #629 bite.
1340
+ writeFrame(live, {
1341
+ type: "chat:queued_returned",
1342
+ payload: {
1343
+ projectSlug: slug,
1344
+ sessionId,
1345
+ text: queued.text,
1346
+ ...(attachments.length > 0 ? { attachments } : {}),
1347
+ },
1348
+ });
1349
+ hub.broadcast(sessionId, {
1350
+ type: "chat:queued_state",
1351
+ payload: { projectSlug: slug, sessionId, text: null, reason: "returned" },
1352
+ }, { except: live });
1353
+ };
1354
+ // #627: EVERY turn-ending path deals with the queue, because every one of them
1355
+ // ends its turn through the hub. Previously this was a single call after a
1356
+ // successful `chat:send`, so a message queued during a slash command, a trigger,
1357
+ // a wake or a background stretch was stranded until some later `chat:send`
1358
+ // flushed it — out of order, behind a message the user typed afterwards.
1359
+ hub.onTurnEnd = ({ sessionId, projectSlug, origin, cancel }) => {
1360
+ // A destructive op stopped this turn so it can delete/revert/promote the
1361
+ // chat (#731). Its transcript is about to be rewritten or removed, so a
1362
+ // drained follow-up would either race that or resurrect the chat (#730) —
1363
+ // and, by making the session busy again, would make the interlock time out
1364
+ // and refuse the operation. Leave the queue exactly where it is.
1365
+ if (cancel?.reason === "quiesce")
1366
+ return;
1367
+ if (cancel?.reason === "stop") {
1368
+ void returnQueueToComposer(projectSlug, sessionId, cancel.origin);
1369
+ return;
1370
+ }
1371
+ void opsFor(origin).drainQueue(projectSlug, sessionId);
1372
+ };
1373
+ const handle = async function handle(socket) {
1374
+ const send = (m) => {
1375
+ if (socket.readyState === socket.OPEN)
1376
+ socket.send(JSON.stringify(m));
1377
+ };
1378
+ // This connection's operations. The frame dispatcher below closes over them.
1379
+ const { onChatContinue, onSubscribe, onSetQueue, onChatSend, onChatCommand } = makeChatOps(socket, send);
1380
+ // Register this socket for active-turn broadcasts, and immediately catch it
1381
+ // up on which sessions are currently running (so the sidebar dots and a
1382
+ // returning pane's Stop button reflect reality from the first paint).
1383
+ clients.add(socket);
1384
+ for (const info of hub.runningSessions())
1385
+ send(activeFrame(info));
1386
+ // Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
1387
+ // client whose TCP has silently died (idle drop, sleep) fails to pong and is
1388
+ // terminated on the next tick — freeing server resources and letting the
1389
+ // client's own reconnect take over. Cleared when the socket closes.
1390
+ let isAlive = true;
1391
+ socket.on("pong", () => {
1392
+ isAlive = true;
1393
+ });
1394
+ const heartbeat = setInterval(() => {
1395
+ if (!isAlive) {
1396
+ socket.terminate();
1397
+ return;
1398
+ }
1399
+ isAlive = false;
1400
+ try {
1401
+ socket.ping();
1402
+ }
1403
+ catch {
1404
+ socket.terminate();
1405
+ }
1406
+ }, SERVER_PING_INTERVAL_MS);
1407
+ socket.on("close", () => {
1408
+ clearInterval(heartbeat);
1409
+ // Drop this socket from every session fan-out set so the hub stops trying
1410
+ // to write to it (a running turn keeps going for other attached sockets).
1411
+ hub.unsubscribeSocket(socket);
1412
+ clients.delete(socket);
1413
+ });
1414
+ socket.on("message", (raw) => {
1415
+ let parsed;
1416
+ try {
1417
+ parsed = JSON.parse(raw.toString());
1418
+ }
1419
+ catch {
1420
+ send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
1421
+ return;
1422
+ }
1423
+ if (!isClientMessage(parsed)) {
1424
+ send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
1425
+ return;
1426
+ }
1427
+ if (parsed.type === "ping") {
1428
+ send({ type: "pong" });
1429
+ return;
1430
+ }
1431
+ if (parsed.type === "chat:cancel") {
1432
+ // Tell the hub this end is a user Stop, and which socket asked, so the
1433
+ // turn end it causes hands the queued follow-up back to THAT composer
1434
+ // instead of sending it. `chat:cancel` names only a job id, so the hub
1435
+ // resolves the chat it belongs to; a stale Stop for a turn that already
1436
+ // finished resolves to nothing and marks nothing.
1437
+ const target = hub.sessionForJob(parsed.payload.jobId);
1438
+ if (target)
1439
+ hub.noteCancel(target.sessionId, { reason: "stop", origin: socket });
1440
+ void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
1441
+ return;
1442
+ }
1443
+ if (parsed.type === "chat:subscribe") {
1444
+ onSubscribe(parsed);
1445
+ return;
1446
+ }
1447
+ if (parsed.type === "chat:set_queue") {
1448
+ void onSetQueue(parsed);
1449
+ return;
1450
+ }
1451
+ if (parsed.type === "chat:command") {
1452
+ void onChatCommand(parsed);
1453
+ return;
1454
+ }
1455
+ if (parsed.type === "chat:continue") {
1456
+ void onChatContinue(parsed);
1457
+ return;
1458
+ }
1459
+ void onChatSend(parsed);
1460
+ });
1043
1461
  };
1044
1462
  // The socket handler PLUS the manual trigger-fire entrypoint: a "trigger now"
1045
1463
  // action fires a schedule-type trigger on demand through the exact same hub path a