@edspencer/paddock 0.65.0 → 0.66.1

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 (100) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/server/dist/app.d.ts +8 -0
  4. package/packages/server/dist/app.js +4 -1
  5. package/packages/server/dist/cli/args.d.ts +4 -4
  6. package/packages/server/dist/cli/args.js +5 -5
  7. package/packages/server/dist/cli/paddock.js +1 -1
  8. package/packages/server/dist/config.d.ts +7 -0
  9. package/packages/server/dist/config.js +9 -1
  10. package/packages/server/dist/herdctl.d.ts +89 -0
  11. package/packages/server/dist/herdctl.js +169 -2
  12. package/packages/server/dist/instance-config.js +12 -1
  13. package/packages/server/dist/projects.d.ts +11 -0
  14. package/packages/server/dist/projects.js +40 -2
  15. package/packages/server/dist/queued-message.d.ts +57 -0
  16. package/packages/server/dist/queued-message.js +135 -1
  17. package/packages/server/dist/routes/chats.js +77 -6
  18. package/packages/server/dist/routes/projects.js +22 -2
  19. package/packages/server/dist/schema-version.d.ts +111 -0
  20. package/packages/server/dist/schema-version.js +142 -0
  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/subagents.d.ts +65 -0
  24. package/packages/server/dist/subagents.js +159 -19
  25. package/packages/server/dist/tooldetails.d.ts +10 -4
  26. package/packages/server/dist/tooldetails.js +23 -6
  27. package/packages/server/dist/trusted-proxy.js +1 -1
  28. package/packages/server/dist/turn-interlock.d.ts +101 -0
  29. package/packages/server/dist/turn-interlock.js +84 -0
  30. package/packages/server/dist/ws-protocol.d.ts +76 -5
  31. package/packages/server/dist/ws-protocol.js +2 -0
  32. package/packages/server/dist/ws.d.ts +0 -52
  33. package/packages/server/dist/ws.js +472 -119
  34. package/packages/server/package.json +1 -1
  35. package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +2 -0
  36. package/packages/web/dist/assets/{MarkdownRenderer-Ri1LwAUN.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-Dze5H8Xy.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
  38. package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
  39. package/packages/web/dist/assets/{ProjectsGrid-BidsM7ah.js → ProjectsGrid-BaUE8zaF.js} +1 -1
  40. package/packages/web/dist/assets/{arc-eQ3ryE9c.js → arc-DqMSW0As.js} +1 -1
  41. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
  42. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DBX5pma_.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
  43. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CnWrtTpa.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
  44. package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
  45. package/packages/web/dist/assets/{chunk-2J33WTMH-2qmLvtsr.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-4BX2VUAB-Dn-LUDMV.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-55IACEB6-DB0augUY.js → chunk-55IACEB6-HO60VE45.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-727SXJPM-BPQQM0i8.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DOhfwA-x.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-FMBD7UC4-CatHHdFD.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-ND2GUHAM-B645aAlG.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-QZHKN3VN-D1fAaPSZ.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
  53. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
  54. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
  55. package/packages/web/dist/assets/{core-B4t7D8Om.js → core-DwSEw_oE.js} +1 -1
  56. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BLdKDAwa.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
  57. package/packages/web/dist/assets/{dagre-BM42HDAG-CbTbTVKv.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-2AECGRRQ-CorSS29_.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-5GNKFQAL-PLKivts0.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-KO2AKTUF-CUSIBacZ.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-LMA3HP47-CZ69mAZs.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-OG6HWLK6-Cmf042Rh.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
  63. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-DyIKGj6e.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
  64. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B79pLdLD.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
  65. package/packages/web/dist/assets/{format-C5nhFL8q.js → format-u-YtEZFd.js} +1 -1
  66. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BFTR_oNA.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DdGUlJxS.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
  68. package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
  69. package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-B80FyUOy.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-_wRlphn9.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-qu4yvjW8.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-D2DEUp2-.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
  74. package/packages/web/dist/assets/{linear-D-CLRUEW.js → linear-Dolq_eLR.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-DdihBJl4.js → mermaid.core-D1T_GQW7.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BEh_TDBf.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BbrmLVaZ.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DfVuygAv.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
  79. package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
  80. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BHp2QpZt.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
  81. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D2G6lRMY.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
  82. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Dj-jzR-c.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
  83. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C5D6bT13.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
  84. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
  85. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DE4gGjhP.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
  86. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C9QNrl92.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
  87. package/packages/web/dist/assets/{wardley-L42UT6IY-U2fSGy-d.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
  88. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-B9RhObdH.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
  89. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BnlPrtQ9.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
  90. package/packages/web/dist/index.html +2 -2
  91. package/packages/web/dist/sw.js +1 -1
  92. package/packages/web/dist/assets/InstanceConfigPage-DcprTH2W.js +0 -2
  93. package/packages/web/dist/assets/ProjectView-PrA8purE.js +0 -13
  94. package/packages/web/dist/assets/channel-B4sED4OP.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +0 -2
  97. package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
  98. package/packages/web/dist/assets/index-DVSsta3B.js +0 -71
  99. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  100. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.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 { 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,30 +467,78 @@ 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 (!queued?.text)
480
+ return;
481
+ send({
482
+ type: "chat:queued_state",
483
+ payload: {
484
+ projectSlug: slug,
485
+ sessionId,
486
+ text: queued.text,
487
+ ...(queued.id ? { qid: queued.id } : {}),
488
+ },
489
+ });
490
+ })
491
+ .catch(() => undefined);
492
+ }
390
493
  };
391
494
  // 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.
495
+ // message as the next turn, exactly once. Called (a) whenever a turn ends —
496
+ // through the hub, so EVERY turn path drains (#627) — and (b) when a queue is
497
+ // set while the session is idle, i.e. a queue that arrived (e.g. via the
498
+ // reconnect outbox) after the turn it was meant to follow already ended.
499
+ // `take` makes the read+clear atomic so two concurrent drains can never both
500
+ // send it; the flushed-parts ledger skips what was already drained (a stale
501
+ // client re-assert on reload) so it isn't sent twice.
398
502
  const drainQueue = async (slug, sessionId) => {
399
503
  if (!deps.queuedMessage)
400
504
  return;
505
+ // Another turn is already live on this session (a drain racing a fresh send,
506
+ // or a turn ending while its successor has started). Leave the queue for
507
+ // that turn's own end — sending now would interleave two turns.
508
+ if (hub.isRunning(sessionId))
509
+ return;
401
510
  const agent = keeperAgentName(slug);
402
511
  const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
403
512
  if (!queued?.text)
404
513
  return;
405
514
  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)));
515
+ // Parked by a Stop we couldn't hand back (see returnQueueToComposer): the
516
+ // user asked for control, so it waits for them, not for the next turn end.
517
+ // Put it back — `take` already removed it.
518
+ if (parked.get(markerKey) === slotVersion(queued)) {
519
+ await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
520
+ return;
521
+ }
522
+ const ledger = flushedLedger(markerKey);
523
+ // Have we already sent this? (#628/#736.)
524
+ //
525
+ // Identity is the (id, text) TUPLE, matched by EQUALITY, against every
526
+ // identity the slot has been known by — so a client re-asserting its stored
527
+ // copy on reload is recognised whether it holds the id it sent or the slot
528
+ // version the server broadcast back.
529
+ //
530
+ // It used to be a (ts, text) tuple with an ORDERING rule on top: "older than
531
+ // the last flush ⇒ stale by construction, timestamps being monotonic". They
532
+ // are monotonic within one client, but the ts came from whichever BROWSER
533
+ // queued the message — so a single client with a fast clock left the marker
534
+ // five minutes in the future, and every later queued message on that chat,
535
+ // from any client, was taken, classified already-flushed, deleted and never
536
+ // sent, until wall-clock time caught up (#736). Equality assumes nothing
537
+ // about clocks. An APPEND keeps its identity with LONGER text (#245), so it
538
+ // still reads as a genuinely new message and is sent — #628, unchanged.
539
+ const parts = partsOf(queued);
540
+ const stale = parts.some((p) => ledger.get(p.id)?.has(p.text));
541
+ const text = stale ? "" : queued.text;
414
542
  // Tell every attached client (origin + reconnected sockets) to clear its copy
415
543
  // of this message. When we're really sending it, carry the text so the client
416
544
  // renders the sent bubble in-transcript; on a stale re-assert we only clear.
@@ -419,31 +547,29 @@ export function makeChatHandler(deps) {
419
547
  payload: {
420
548
  projectSlug: slug,
421
549
  sessionId,
422
- ...(already ? {} : { text: queued.text }),
550
+ ...(text ? { text } : {}),
423
551
  },
424
552
  });
425
- if (already)
553
+ if (!text)
426
554
  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]) });
555
+ // Every identity this slot carried is now flushed, so a re-assert from any
556
+ // client that held one of them is recognised rather than sent again.
557
+ for (const p of parts)
558
+ rememberFlushed(ledger, p.id, p.text);
433
559
  // Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
434
560
  // above the reply. Run it detached, like a human send. A leading-slash queued
435
561
  // message is a slash command (e.g. "/compact"): route it through the command
436
562
  // path so the CLI dispatches it — matching how the composer sends one live.
437
- if (queued.text.startsWith("/")) {
563
+ if (text.startsWith("/")) {
438
564
  void onChatCommand({
439
565
  type: "chat:command",
440
- payload: { projectSlug: slug, command: queued.text, sessionId },
566
+ payload: { projectSlug: slug, command: text, sessionId },
441
567
  });
442
568
  }
443
569
  else {
444
570
  void onChatSend({
445
571
  type: "chat:send",
446
- payload: { projectSlug: slug, sessionId, message: queued.text },
572
+ payload: { projectSlug: slug, sessionId, message: text },
447
573
  });
448
574
  }
449
575
  };
@@ -460,14 +586,48 @@ export function makeChatHandler(deps) {
460
586
  const agent = keeperAgentName(slug);
461
587
  if (!sessionId) {
462
588
  // 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.
589
+ // re-asserts it (same qid) once the id resolves, so it persists then.
464
590
  return;
465
591
  }
466
592
  // Store or clear the queued message.
467
593
  if (text && text.trim().length > 0) {
468
- await deps.queuedMessage
469
- .set(agent, sessionId, { text, createdAtMs: msg.payload.ts ?? Date.now() })
594
+ // A queued message is persisted verbatim and the whole sidecar is rewritten
595
+ // on every mutation, so an unbounded one is a cheap way to bloat the data
596
+ // dir. Refuse it loudly rather than silently truncating the user's text.
597
+ if (text.length > MAX_QUEUED_CHARS) {
598
+ send({
599
+ type: "chat:error",
600
+ payload: {
601
+ projectSlug: slug,
602
+ error: `Queued message is too long (${text.length} characters; the limit is ${MAX_QUEUED_CHARS}).`,
603
+ },
604
+ });
605
+ return;
606
+ }
607
+ // Merge into the chat's single slot rather than overwriting it (#629). The
608
+ // id the client wrote under says which this is: a client editing the slot
609
+ // it can currently see (replace) or one that queued without knowing what
610
+ // was already there (append). The old unconditional overwrite destroyed the
611
+ // first client's message with no signal to anyone — and then rendered it,
612
+ // in that client's own transcript, as a user bubble they never typed.
613
+ const next = await deps.queuedMessage
614
+ .upsert(agent, sessionId, { id: queueIdOf(msg.payload), text }, Date.now(), randomUUID)
470
615
  .catch(() => undefined);
616
+ // The queue is shared chat state, so announce it: every attached client
617
+ // renders the same slot, and echoing `qid` back on its next write is how a
618
+ // client proves it is editing the slot as it stands rather than replacing
619
+ // someone else's text with its own.
620
+ if (next !== undefined) {
621
+ hub.broadcast(sessionId, {
622
+ type: "chat:queued_state",
623
+ payload: {
624
+ projectSlug: slug,
625
+ sessionId,
626
+ text: next?.text ?? null,
627
+ ...(next?.id ? { qid: next.id } : {}),
628
+ },
629
+ });
630
+ }
471
631
  // If no turn is running, this queue arrived after the turn it was meant to
472
632
  // follow already ended — drain it now rather than wait for a completion
473
633
  // that won't come (the reported stranding bug, #245).
@@ -475,7 +635,14 @@ export function makeChatHandler(deps) {
475
635
  await drainQueue(slug, sessionId);
476
636
  }
477
637
  else {
638
+ // A clear wipes the WHOLE slot, including another client's contribution:
639
+ // with the state broadcast the chip is shared, so clearing it is a
640
+ // deliberate act on something the user can actually see.
478
641
  await deps.queuedMessage.set(agent, sessionId, null).catch(() => undefined);
642
+ hub.broadcast(sessionId, {
643
+ type: "chat:queued_state",
644
+ payload: { projectSlug: slug, sessionId, text: null },
645
+ });
479
646
  }
480
647
  };
481
648
  const onChatSend = async (msg) => {
@@ -838,13 +1005,13 @@ export function makeChatHandler(deps) {
838
1005
  ...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
839
1006
  },
840
1007
  });
1008
+ // Ending the turn drains any queued follow-up (#197/#245) via the hub's
1009
+ // turn-end hook — the ONE place every turn path converges (#627), rather
1010
+ // than this call site alone. It no longer waits for a SUCCESSFUL turn:
1011
+ // holding the queue on a failed/Stopped turn didn't keep it for the user,
1012
+ // it stranded it, to be flushed by the NEXT `chat:send` — landing behind a
1013
+ // message typed later, with a stale chip in between. See drainQueue.
841
1014
  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
1015
  // Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
849
1016
  // keeper turn. If this turn launched a background task that the runtime kills
850
1017
  // at the turn boundary (herdctl#374) and the keeper doesn't wake on its own,
@@ -1040,6 +1207,192 @@ export function makeChatHandler(deps) {
1040
1207
  turn.end();
1041
1208
  }
1042
1209
  };
1210
+ return { onChatContinue, onSubscribe, drainQueue, onSetQueue, onChatSend, onChatCommand };
1211
+ };
1212
+ // The ops used when a turn ends with no socket behind it — a trigger, a wake, a
1213
+ // spawned turn, a background sub-agent stretch — so the drain still has a way to
1214
+ // dispatch the follow-up. Built once: it holds no per-connection state.
1215
+ const headlessOps = makeChatOps(null, () => { });
1216
+ // Prefer the ending turn's ORIGIN socket when there is one, so a drained turn
1217
+ // keeps the same fan-out (and error routing) the turn it followed had; a turn
1218
+ // with no origin falls back to the headless ops.
1219
+ const opsFor = (origin) => origin
1220
+ ? makeChatOps(origin, (m) => {
1221
+ if (origin.readyState === origin.OPEN)
1222
+ origin.send(JSON.stringify(m));
1223
+ })
1224
+ : headlessOps;
1225
+ /**
1226
+ * Stop hands the queued message BACK to the user instead of sending it.
1227
+ *
1228
+ * Stop means "give me control back", so the one thing it must not do is have
1229
+ * the agent start working again on the follow-up the instant it is stopped.
1230
+ * Holding the message server-side isn't the answer either — that is exactly
1231
+ * what stranded it (#627): it sat there and went out behind whatever the user
1232
+ * typed next. So it goes back to the composer of the client that pressed Stop,
1233
+ * where it is visible, editable, and sent only when they say so. The client
1234
+ * runs its existing "Edit queued message" path on the frame; the composer draft
1235
+ * is persisted to localStorage by the same effect that persists any draft.
1236
+ *
1237
+ * Every OTHER turn-ending path — normal completion, a slash command, a trigger,
1238
+ * a wake, a spawn, a background stretch — still drains. This is the single
1239
+ * explicit exception, not a gap.
1240
+ */
1241
+ const returnQueueToComposer = async (slug, sessionId, to) => {
1242
+ if (!deps.queuedMessage)
1243
+ return;
1244
+ // A successor turn is already live (a Stop racing the next send): leave the
1245
+ // queue to that turn's own end rather than yanking it mid-flight.
1246
+ if (hub.isRunning(sessionId))
1247
+ return;
1248
+ const agent = keeperAgentName(slug);
1249
+ const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
1250
+ if (!queued?.text)
1251
+ return;
1252
+ const live = to && to.readyState === to.OPEN ? to : null;
1253
+ if (!live) {
1254
+ // Nobody to hand it to — the tab closed between the Stop and this turn
1255
+ // ending, or the cancel came from no socket at all. Put it back and PARK
1256
+ // it: still queued, still visible (every attached client is told, and a
1257
+ // pane opening this chat is told on subscribe), but no turn end will
1258
+ // auto-send it. Dropping it here would be exactly the silent loss this
1259
+ // change is about, and sending it would be the surprise Stop must not have.
1260
+ await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
1261
+ const key = `${agent}${KEY_SEP}${sessionId}`;
1262
+ parked.set(key, slotVersion(queued));
1263
+ if (parked.size > MAX_FLUSHED_SESSIONS) {
1264
+ parked.delete(parked.keys().next().value);
1265
+ }
1266
+ hub.broadcast(sessionId, {
1267
+ type: "chat:queued_state",
1268
+ payload: {
1269
+ projectSlug: slug,
1270
+ sessionId,
1271
+ text: queued.text,
1272
+ ...(queued.id ? { qid: queued.id } : {}),
1273
+ },
1274
+ });
1275
+ return;
1276
+ }
1277
+ // The stopping client gets the text; everyone else just sees the shared slot
1278
+ // clear, WITH a reason — a chip vanishing from under you with no explanation
1279
+ // is the same silence that made #629 bite.
1280
+ writeFrame(live, {
1281
+ type: "chat:queued_returned",
1282
+ payload: { projectSlug: slug, sessionId, text: queued.text },
1283
+ });
1284
+ hub.broadcast(sessionId, {
1285
+ type: "chat:queued_state",
1286
+ payload: { projectSlug: slug, sessionId, text: null, reason: "returned" },
1287
+ }, { except: live });
1288
+ };
1289
+ // #627: EVERY turn-ending path deals with the queue, because every one of them
1290
+ // ends its turn through the hub. Previously this was a single call after a
1291
+ // successful `chat:send`, so a message queued during a slash command, a trigger,
1292
+ // a wake or a background stretch was stranded until some later `chat:send`
1293
+ // flushed it — out of order, behind a message the user typed afterwards.
1294
+ hub.onTurnEnd = ({ sessionId, projectSlug, origin, cancel }) => {
1295
+ // A destructive op stopped this turn so it can delete/revert/promote the
1296
+ // chat (#731). Its transcript is about to be rewritten or removed, so a
1297
+ // drained follow-up would either race that or resurrect the chat (#730) —
1298
+ // and, by making the session busy again, would make the interlock time out
1299
+ // and refuse the operation. Leave the queue exactly where it is.
1300
+ if (cancel?.reason === "quiesce")
1301
+ return;
1302
+ if (cancel?.reason === "stop") {
1303
+ void returnQueueToComposer(projectSlug, sessionId, cancel.origin);
1304
+ return;
1305
+ }
1306
+ void opsFor(origin).drainQueue(projectSlug, sessionId);
1307
+ };
1308
+ const handle = async function handle(socket) {
1309
+ const send = (m) => {
1310
+ if (socket.readyState === socket.OPEN)
1311
+ socket.send(JSON.stringify(m));
1312
+ };
1313
+ // This connection's operations. The frame dispatcher below closes over them.
1314
+ const { onChatContinue, onSubscribe, onSetQueue, onChatSend, onChatCommand } = makeChatOps(socket, send);
1315
+ // Register this socket for active-turn broadcasts, and immediately catch it
1316
+ // up on which sessions are currently running (so the sidebar dots and a
1317
+ // returning pane's Stop button reflect reality from the first paint).
1318
+ clients.add(socket);
1319
+ for (const info of hub.runningSessions())
1320
+ send(activeFrame(info));
1321
+ // Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
1322
+ // client whose TCP has silently died (idle drop, sleep) fails to pong and is
1323
+ // terminated on the next tick — freeing server resources and letting the
1324
+ // client's own reconnect take over. Cleared when the socket closes.
1325
+ let isAlive = true;
1326
+ socket.on("pong", () => {
1327
+ isAlive = true;
1328
+ });
1329
+ const heartbeat = setInterval(() => {
1330
+ if (!isAlive) {
1331
+ socket.terminate();
1332
+ return;
1333
+ }
1334
+ isAlive = false;
1335
+ try {
1336
+ socket.ping();
1337
+ }
1338
+ catch {
1339
+ socket.terminate();
1340
+ }
1341
+ }, SERVER_PING_INTERVAL_MS);
1342
+ socket.on("close", () => {
1343
+ clearInterval(heartbeat);
1344
+ // Drop this socket from every session fan-out set so the hub stops trying
1345
+ // to write to it (a running turn keeps going for other attached sockets).
1346
+ hub.unsubscribeSocket(socket);
1347
+ clients.delete(socket);
1348
+ });
1349
+ socket.on("message", (raw) => {
1350
+ let parsed;
1351
+ try {
1352
+ parsed = JSON.parse(raw.toString());
1353
+ }
1354
+ catch {
1355
+ send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
1356
+ return;
1357
+ }
1358
+ if (!isClientMessage(parsed)) {
1359
+ send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
1360
+ return;
1361
+ }
1362
+ if (parsed.type === "ping") {
1363
+ send({ type: "pong" });
1364
+ return;
1365
+ }
1366
+ if (parsed.type === "chat:cancel") {
1367
+ // Tell the hub this end is a user Stop, and which socket asked, so the
1368
+ // turn end it causes hands the queued follow-up back to THAT composer
1369
+ // instead of sending it. `chat:cancel` names only a job id, so the hub
1370
+ // resolves the chat it belongs to; a stale Stop for a turn that already
1371
+ // finished resolves to nothing and marks nothing.
1372
+ const target = hub.sessionForJob(parsed.payload.jobId);
1373
+ if (target)
1374
+ hub.noteCancel(target.sessionId, { reason: "stop", origin: socket });
1375
+ void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
1376
+ return;
1377
+ }
1378
+ if (parsed.type === "chat:subscribe") {
1379
+ onSubscribe(parsed);
1380
+ return;
1381
+ }
1382
+ if (parsed.type === "chat:set_queue") {
1383
+ void onSetQueue(parsed);
1384
+ return;
1385
+ }
1386
+ if (parsed.type === "chat:command") {
1387
+ void onChatCommand(parsed);
1388
+ return;
1389
+ }
1390
+ if (parsed.type === "chat:continue") {
1391
+ void onChatContinue(parsed);
1392
+ return;
1393
+ }
1394
+ void onChatSend(parsed);
1395
+ });
1043
1396
  };
1044
1397
  // The socket handler PLUS the manual trigger-fire entrypoint: a "trigger now"
1045
1398
  // action fires a schedule-type trigger on demand through the exact same hub path a