@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
@@ -88,7 +88,7 @@ export const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
88
88
  */
89
89
  export const MTIME_CACHE_MAX = 1024;
90
90
  const taskUsesCache = new Map();
91
- const durationCache = new Map();
91
+ const factsCache = new Map();
92
92
  const usageCache = new Map();
93
93
  /** The file's mtime in epoch ms, or undefined if it can't be stat'd. */
94
94
  export async function statMtimeMs(file) {
@@ -137,7 +137,7 @@ export async function readTaskToolUses(projectDir, sessionId) {
137
137
  */
138
138
  async function readTaskUsesForSession(projectDir, sessionId) {
139
139
  if (!SAFE_SEGMENT.test(sessionId))
140
- return { paired: [], pending: [] };
140
+ return emptyTaskToolUses();
141
141
  return readTaskUsesFromFile(path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`));
142
142
  }
143
143
  /**
@@ -158,10 +158,32 @@ async function readTaskUsesFromFile(file) {
158
158
  mtimeCacheSet(taskUsesCache, file, mtimeMs, value);
159
159
  return value;
160
160
  }
161
+ function emptyTaskToolUses() {
162
+ return { paired: [], pending: [], sidechain: { uuids: new Set(), taskIds: new Set() } };
163
+ }
164
+ /**
165
+ * True when a raw transcript LINE was written on a sub-agent's own lane.
166
+ *
167
+ * Two markers, because the writers disagree: Claude Code stamps `isSidechain:
168
+ * true` (plus an `agentId`) on the line, while the SDK message stream — which
169
+ * `isSidechainMessage` in `ws-turn.ts` reads live, and which the test harness
170
+ * mirrors onto disk — carries `parent_tool_use_id`. Either is conclusive.
171
+ *
172
+ * Exported because EVERY file-order join over a main transcript has to agree with
173
+ * {@link dropSidechainMessages} about which lines exist: a scan that keeps a
174
+ * sidechain line while the parsed message list has dropped it shifts the whole
175
+ * join by one (#727).
176
+ */
177
+ export function isSidechainLine(parsed) {
178
+ if (parsed.isSidechain === true)
179
+ return true;
180
+ return Boolean(parsed.parent_tool_use_id ?? parsed.message?.parent_tool_use_id);
181
+ }
161
182
  async function readTaskUsesFromFileUncached(file) {
162
183
  const byId = new Map();
163
184
  const order = [];
164
185
  const resultIds = new Set();
186
+ const sidechain = { uuids: new Set(), taskIds: new Set() };
165
187
  const stream = createReadStream(file, { encoding: "utf8" });
166
188
  stream.on("error", () => undefined);
167
189
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
@@ -177,12 +199,20 @@ async function readTaskUsesFromFileUncached(file) {
177
199
  catch {
178
200
  continue;
179
201
  }
202
+ // A sub-agent's OWN transcript is sidechain top to bottom, so this only ever
203
+ // marks anything on a MAIN session file — which is the only place the
204
+ // distinction means something (issue #727).
205
+ const onSidechain = isSidechainLine(parsed);
206
+ if (onSidechain && typeof parsed.uuid === "string")
207
+ sidechain.uuids.add(parsed.uuid);
180
208
  const content = parsed.message?.content;
181
209
  if (!Array.isArray(content))
182
210
  continue;
183
211
  for (const block of content) {
184
212
  const b = block;
185
213
  if (b?.type === "tool_use" && b.name && SUBAGENT_TOOL_NAMES.has(b.name) && b.id) {
214
+ if (onSidechain)
215
+ sidechain.taskIds.add(b.id);
186
216
  if (!byId.has(b.id)) {
187
217
  order.push(b.id);
188
218
  byId.set(b.id, {
@@ -200,7 +230,7 @@ async function readTaskUsesFromFileUncached(file) {
200
230
  }
201
231
  }
202
232
  catch {
203
- return { paired: [], pending: [] };
233
+ return emptyTaskToolUses();
204
234
  }
205
235
  finally {
206
236
  rl.close();
@@ -209,8 +239,43 @@ async function readTaskUsesFromFileUncached(file) {
209
239
  return {
210
240
  paired: order.filter((id) => resultIds.has(id)).map((id) => byId.get(id)),
211
241
  pending: order.filter((id) => !resultIds.has(id)).map((id) => byId.get(id)),
242
+ sidechain,
212
243
  };
213
244
  }
245
+ /**
246
+ * Drop a session's sub-agent sidechain steps from its parsed history (issue
247
+ * #727) — the history-path counterpart of the live path's `isSidechainMessage`
248
+ * filter, and the same invariant: a sub-agent's steps render INSIDE its Task
249
+ * card (via the subagents endpoint), never as top-level rows of the parent.
250
+ *
251
+ * Joins on `uuid`, the source line's own id, which `@herdctl/core` preserves on
252
+ * every parsed message (and, for a paired tool message, takes from the
253
+ * originating `tool_use` entry — so a sidechain tool call matches on the same
254
+ * line that made it sidechain). Rides the mtime-cached transcript scan, and
255
+ * short-circuits to the identity for the overwhelmingly common transcript with
256
+ * no sidechain lines at all, so this costs nothing on the normal path.
257
+ *
258
+ * A transcript that is sidechain top to bottom is not a parent with leaked steps
259
+ * — it IS a sub-agent's own session, the shape Claude Code writes as its own
260
+ * `agent-<hex>.jsonl`. herdctl keeps those out of session discovery, but if one
261
+ * is ever reached directly it should render its content, not an empty chat, so
262
+ * a drop that would empty the history is refused.
263
+ */
264
+ export async function dropSidechainMessages(projectDir, sessionId, messages) {
265
+ const { sidechain } = await readTaskUsesForSession(projectDir, sessionId);
266
+ if (sidechain.uuids.size === 0)
267
+ return messages;
268
+ const kept = messages.filter((m) => !(m.uuid && sidechain.uuids.has(m.uuid)));
269
+ return kept.length === 0 ? messages : kept;
270
+ }
271
+ /** The same launches minus any a sub-agent made on its own sidechain lane. */
272
+ function withoutSidechainLaunches(uses) {
273
+ const { taskIds } = uses.sidechain;
274
+ if (taskIds.size === 0)
275
+ return uses;
276
+ const keep = (u) => !taskIds.has(u.toolUseId);
277
+ return { ...uses, paired: uses.paired.filter(keep), pending: uses.pending.filter(keep) };
278
+ }
214
279
  /**
215
280
  * Live sub-agent enrichment for a tool frame (issue #429). When the tool is a
216
281
  * `Task`/`Agent` launch this turn already recovered (via {@link extractSubagentLaunches}),
@@ -306,10 +371,16 @@ export async function readSubagentMessages(projectDir, sessionId, toolUseId) {
306
371
  export async function enrichWithSubagents(projectDir, sessionId, messages) {
307
372
  if (!hasSubagentTool(messages))
308
373
  return messages;
309
- const [taskUses, subagents] = await Promise.all([
374
+ const [rawTaskUses, subagents] = await Promise.all([
310
375
  readTaskUsesForSession(projectDir, sessionId),
311
376
  listSubagents(projectDir, sessionId),
312
377
  ]);
378
+ // The join is by FILE ORDER, so it has to see the same launches the caller's
379
+ // message list does. {@link dropSidechainMessages} has already removed the
380
+ // sidechain rows from that list, so a nested launch a sub-agent made on its own
381
+ // lane must come out of the cursor too — leaving it in would shift every
382
+ // following top-level sub-agent's enrichment onto the wrong card (#727).
383
+ const taskUses = withoutSidechainLaunches(rawTaskUses);
313
384
  const [durations, costs] = await Promise.all([
314
385
  subagentDurations(subagents),
315
386
  subagentCosts(subagents),
@@ -317,42 +388,85 @@ export async function enrichWithSubagents(projectDir, sessionId, messages) {
317
388
  return attachSubagentFields(messages, taskUses, subagents, durations, costs);
318
389
  }
319
390
  /**
320
- * Compute each sub-agent's run time (ms) from the first→last timestamp in its
321
- * own transcript. Keyed by toolUseId. Runs once per session on chat open (only
322
- * over that session's sub-agent files), so the cost is bounded to the open chat.
391
+ * How long a sub-agent transcript with no terminal `end_turn` may stay quiet
392
+ * before we stop calling it live (issue #725).
393
+ *
394
+ * A sub-agent that was interrupted — its keeper killed, the box restarted — never
395
+ * writes an `end_turn`, so without this fallback its card would advertise "still
396
+ * running" forever and its duration would never appear. The window has to be
397
+ * comfortably longer than the longest gap a WORKING sub-agent can leave between
398
+ * transcript lines, which is one slow tool call (a full test suite, a long build),
399
+ * not one slow model response. Ten minutes clears that with room to spare, and the
400
+ * `end_turn` check below means the overwhelming majority of finished sub-agents
401
+ * (392 of the 483 real transcripts this was measured against) never wait for it at
402
+ * all — they are recognised as finished the instant their last line lands.
403
+ */
404
+ const SUBAGENT_STALE_MS = 10 * 60_000;
405
+ /**
406
+ * Compute each FINISHED sub-agent's run time (ms) from the first→last timestamp
407
+ * in its own transcript. Keyed by toolUseId. Runs once per session on chat open
408
+ * (only over that session's sub-agent files), so the cost is bounded to the open
409
+ * chat.
410
+ *
411
+ * A sub-agent that still looks live is deliberately left OUT of the map (issue
412
+ * #725). `subagentDurationMs` is not merely cosmetic — it is the client's
413
+ * finished signal (`useRunningSubagents` drops any card that carries one) — and
414
+ * it is derived from a transcript that is still growing, so publishing it for a
415
+ * running sub-agent both freezes a bogus part-way number onto the card and
416
+ * evicts it from the running-sub-agents bar for the rest of the run. #622 fixed
417
+ * that for the `pending` branch of {@link attachSubagentFields} by omitting the
418
+ * field; the `paired` branch below it kept stamping one, and because the SDK
419
+ * backgrounds sub-agents, the launching `Task` pairs within milliseconds while
420
+ * the sub-agent keeps working — so a live sub-agent reaches the paired branch as
421
+ * a matter of course. Gating at the source fixes both branches by construction.
323
422
  */
324
423
  async function subagentDurations(subagents) {
325
424
  const out = new Map();
425
+ const now = Date.now();
326
426
  await Promise.all([...subagents.values()].map(async (m) => {
327
- const ms = await readSubagentDurationMs(m.transcriptPath);
427
+ const ms = await readFinishedSubagentDurationMs(m.transcriptPath, now);
328
428
  if (ms !== undefined)
329
429
  out.set(m.toolUseId, ms);
330
430
  }));
331
431
  return out;
332
432
  }
333
433
  /**
334
- * The elapsed wall-clock of a sub-agent transcript: the delta between its first
335
- * and last `timestamp`. Scans for timestamps without a full JSON parse; returns
336
- * undefined if the file is missing or has < 2 timestamps.
434
+ * The elapsed wall-clock of a FINISHED sub-agent transcript, or undefined when it
435
+ * is missing, has < 2 timestamps, or still looks live (see {@link SUBAGENT_STALE_MS}).
337
436
  */
338
- async function readSubagentDurationMs(file) {
437
+ async function readFinishedSubagentDurationMs(file, now) {
339
438
  const mtimeMs = await statMtimeMs(file);
439
+ const facts = await readSubagentFacts(file, mtimeMs);
440
+ if (facts.durationMs === undefined)
441
+ return undefined;
442
+ // No mtime (the file vanished between the stat and the read) ⇒ nothing is
443
+ // writing to it, so fall back to the structural check alone.
444
+ const quietMs = mtimeMs === undefined ? Infinity : now - mtimeMs;
445
+ if (!facts.endsWithEndTurn && quietMs < SUBAGENT_STALE_MS)
446
+ return undefined;
447
+ return facts.durationMs;
448
+ }
449
+ async function readSubagentFacts(file, mtimeMs) {
340
450
  if (mtimeMs !== undefined) {
341
- const cached = mtimeCacheGet(durationCache, file, mtimeMs);
451
+ const cached = mtimeCacheGet(factsCache, file, mtimeMs);
342
452
  if (cached.hit)
343
453
  return cached.value;
344
454
  }
345
- const value = await readSubagentDurationMsUncached(file);
346
- // Cache even undefined (missing/malformed) so a repeat open of an unchanged,
347
- // duration-less transcript doesn't re-read the whole file every time.
455
+ const value = await readSubagentFactsUncached(file);
456
+ // Cache even the empty result (missing/malformed) so a repeat open of an
457
+ // unchanged, duration-less transcript doesn't re-read the whole file every time.
348
458
  if (mtimeMs !== undefined)
349
- mtimeCacheSet(durationCache, file, mtimeMs, value);
459
+ mtimeCacheSet(factsCache, file, mtimeMs, value);
350
460
  return value;
351
461
  }
352
- async function readSubagentDurationMsUncached(file) {
462
+ async function readSubagentFactsUncached(file) {
353
463
  const raw = await fs.readFile(file, "utf8").catch(() => null);
354
464
  if (raw === null)
355
- return undefined;
465
+ return { endsWithEndTurn: false };
466
+ return { durationMs: transcriptSpanMs(raw), endsWithEndTurn: endsWithEndTurn(raw) };
467
+ }
468
+ /** First→last `timestamp` delta. Scans for timestamps without a full JSON parse. */
469
+ function transcriptSpanMs(raw) {
356
470
  const matches = [...raw.matchAll(/"timestamp":"([^"]+)"/g)];
357
471
  if (matches.length < 2)
358
472
  return undefined;
@@ -362,6 +476,25 @@ async function readSubagentDurationMsUncached(file) {
362
476
  return undefined;
363
477
  return last - first;
364
478
  }
479
+ /** True when the transcript's last non-empty line is an assistant entry that
480
+ * stopped with `end_turn`. Only that line is JSON-parsed. */
481
+ function endsWithEndTurn(raw) {
482
+ const lines = raw.split("\n");
483
+ for (let i = lines.length - 1; i >= 0; i--) {
484
+ const trimmed = lines[i].trim();
485
+ if (!trimmed)
486
+ continue;
487
+ try {
488
+ const parsed = JSON.parse(trimmed);
489
+ return parsed.type === "assistant" && parsed.message?.stop_reason === "end_turn";
490
+ }
491
+ catch {
492
+ // A torn final line means the writer is mid-append — definitively not settled.
493
+ return false;
494
+ }
495
+ }
496
+ return false;
497
+ }
365
498
  /**
366
499
  * Compute each sub-agent's estimated API-rate cost (USD), keyed by toolUseId, as
367
500
  * the RECURSIVE total of the sub-agent PLUS every sub-agent it (transitively)
@@ -611,6 +744,13 @@ function attachSubagentFields(messages, taskUses, subagents, durations, costs) {
611
744
  description: use.description,
612
745
  prompt: use.prompt,
613
746
  hasSubagent: subagents.has(use.toolUseId),
747
+ // Same hazard as the `pending` branch above, and for the same reason:
748
+ // a PAIRED `Task` does NOT mean the sub-agent is done — the SDK
749
+ // backgrounds it, so the tool_result lands in milliseconds while the
750
+ // sub-agent keeps working. `durations` is therefore gated on liveness at
751
+ // its source ({@link subagentDurations}) and holds an entry only for a
752
+ // sub-agent whose transcript has actually settled, so a still-running one
753
+ // gets `undefined` here and keeps its place in the bar (#725).
614
754
  subagentDurationMs: durations.get(use.toolUseId),
615
755
  subagentCostUsd: costs.get(use.toolUseId),
616
756
  },
@@ -24,10 +24,16 @@ export declare function readToolDetails(projectDir: string, sessionId: string):
24
24
  */
25
25
  export declare function attachToolDetails(projectDir: string, sessionId: string, messages: EnrichedMessage[]): Promise<EnrichedMessage[]>;
26
26
  /**
27
- * The single history-enrichment orchestrator (issue #237). Composes the three
28
- * server-side passes so the routes call one function: sub-agent recovery (#37) →
29
- * generalized per-tool detail (#237, subsuming #232's edit diff) → background /
30
- * task-notification folding (#230) → local-command stdout recovery (#158).
27
+ * The single history-enrichment orchestrator (issue #237). Composes the
28
+ * server-side passes so the routes call one function: sidechain drop (#727) →
29
+ * sub-agent recovery (#37) → generalized per-tool detail (#237, subsuming #232's
30
+ * edit diff) → background / task-notification folding (#230) → local-command
31
+ * stdout recovery (#158).
32
+ *
33
+ * The sidechain drop goes FIRST and is the history-path mirror of the live path's
34
+ * `isSidechainMessage` filter: a sub-agent's steps belong inside its Task card,
35
+ * and every later pass here joins by file order, so they have to be gone before
36
+ * anything counts positions.
31
37
  */
32
38
  export declare function enrichWithToolDetails(projectDir: string, sessionId: string, messages: EnrichedMessage[]): Promise<EnrichedMessage[]>;
33
39
  export {};
@@ -33,7 +33,7 @@ import { createInterface } from "node:readline";
33
33
  import path from "node:path";
34
34
  import { projectChatsDir } from "./transcripts.js";
35
35
  import { fileKind } from "./projects.js";
36
- import { enrichWithSubagents } from "./subagents.js";
36
+ import { dropSidechainMessages, enrichWithSubagents, isSidechainLine } from "./subagents.js";
37
37
  import { enrichWithBackground } from "./background.js";
38
38
  import { enrichWithLocalCommands } from "./localcommand.js";
39
39
  import { SAFE_SEGMENT, statMtimeMs, mtimeCacheGet, mtimeCacheSet, } from "./subagents.js";
@@ -355,6 +355,16 @@ async function readToolDetailsFromFileUncached(file) {
355
355
  catch {
356
356
  continue;
357
357
  }
358
+ // A sub-agent's steps are not rows of THIS transcript — `dropSidechainMessages`
359
+ // has already removed them from the message list this list joins against, so
360
+ // keeping them would shift every following tool's detail by one (#727). Their
361
+ // detail is derived separately, off the sub-agent's own transcript. (A file
362
+ // that is sidechain top to bottom — a sub-agent's OWN transcript — therefore
363
+ // recovers nothing and its messages pass through unenriched, which is a
364
+ // degrade rather than a misjoin; `dropSidechainMessages` likewise refuses to
365
+ // empty such a history.)
366
+ if (isSidechainLine(parsed))
367
+ continue;
358
368
  const content = parsed.message?.content;
359
369
  if (!Array.isArray(content))
360
370
  continue;
@@ -453,13 +463,20 @@ export async function attachToolDetails(projectDir, sessionId, messages) {
453
463
  });
454
464
  }
455
465
  /**
456
- * The single history-enrichment orchestrator (issue #237). Composes the three
457
- * server-side passes so the routes call one function: sub-agent recovery (#37) →
458
- * generalized per-tool detail (#237, subsuming #232's edit diff) → background /
459
- * task-notification folding (#230) → local-command stdout recovery (#158).
466
+ * The single history-enrichment orchestrator (issue #237). Composes the
467
+ * server-side passes so the routes call one function: sidechain drop (#727) →
468
+ * sub-agent recovery (#37) → generalized per-tool detail (#237, subsuming #232's
469
+ * edit diff) → background / task-notification folding (#230) → local-command
470
+ * stdout recovery (#158).
471
+ *
472
+ * The sidechain drop goes FIRST and is the history-path mirror of the live path's
473
+ * `isSidechainMessage` filter: a sub-agent's steps belong inside its Task card,
474
+ * and every later pass here joins by file order, so they have to be gone before
475
+ * anything counts positions.
460
476
  */
461
477
  export async function enrichWithToolDetails(projectDir, sessionId, messages) {
462
- const withSubagents = await enrichWithSubagents(projectDir, sessionId, messages);
478
+ const topLevel = await dropSidechainMessages(projectDir, sessionId, messages);
479
+ const withSubagents = await enrichWithSubagents(projectDir, sessionId, topLevel);
463
480
  const withDetails = await attachToolDetails(projectDir, sessionId, withSubagents);
464
481
  const withBackground = enrichWithBackground(withDetails);
465
482
  return enrichWithLocalCommands(projectDir, sessionId, withBackground);
@@ -40,7 +40,7 @@
40
40
  * A request from another host to a `0.0.0.0`-published port is SNAT'd to that
41
41
  * same address, so "peer is the gateway" does not prove the traffic stayed on
42
42
  * the box. Smoke-test from INSIDE the container instead (`docker compose exec
43
- * paddock curl http://127.0.0.1:4000/mcp …`), where the peer really is loopback.
43
+ * paddock curl http://127.0.0.1:7233/mcp …`), where the peer really is loopback.
44
44
  */
45
45
  import proxyAddr from "proxy-addr";
46
46
  /**
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Turn interlock (issue #731) — stop a chat's in-flight turn BEFORE a destructive
3
+ * operation mutates its transcript.
4
+ *
5
+ * ## The bug this exists to make impossible
6
+ *
7
+ * A chat's transcript is not something paddock writes: `claude` writes
8
+ * `<sessionId>.jsonl` itself, straight through the symlink `ensureProjectAgent`
9
+ * plants (see `transcripts.ts`). So a destructive operation — delete, promote,
10
+ * revert, project delete — is a race against a live subprocess that still holds
11
+ * that path and will append to it whenever it next has something to say.
12
+ *
13
+ * Deleting the file mid-turn therefore did not delete the chat. The unlink
14
+ * landed, the chat left the list, and ~45s later the still-running process
15
+ * re-created the SAME file containing only its own tail: a 3-line transcript
16
+ * opening on an orphan `tool_result` whose `tool_use` no longer existed. The
17
+ * chat came back, named after its raw session id, with every prior turn
18
+ * permanently gone. Promote lost the chat from BOTH projects the same way.
19
+ *
20
+ * ## The guarantee
21
+ *
22
+ * Nothing here tries to out-race the writer, because that race is unwinnable.
23
+ * Instead {@link quiesceSession} refuses to return "go ahead" until the session
24
+ * is verifiably dead — and a caller that cannot get that answer within its
25
+ * timeout must NOT mutate (the routes return 409 instead). A destructive op
26
+ * therefore only ever runs against a transcript no process holds, and a dead
27
+ * session has nothing left to write itself back with.
28
+ *
29
+ * ## Why two liveness signals
30
+ *
31
+ * `hub.isRunning` sees the WS layer's turn, and `herdctl.isSdkSessionLive` sees
32
+ * herdctl's `SessionReaper` — a session held open past its turn (a killed
33
+ * background task's keepAlive, or the re-invocation grace, #397). Either one
34
+ * means a `claude` process may still write. The recovery engine already pairs
35
+ * them for exactly this reason (`ws-turn.ts`); so do we.
36
+ *
37
+ * ## Why cancel is not enough on its own
38
+ *
39
+ * `herdctl.cancel(jobId)` ends the model TURN. On the session runtime that is an
40
+ * `interrupt()`, which deliberately leaves the session — and its subprocess —
41
+ * alive for the next message. That is right for the Stop button and wrong for a
42
+ * delete, so we also reap the managed session. Cancel and reap are both
43
+ * best-effort; the polled liveness check is what actually decides.
44
+ */
45
+ import type { SessionHub } from "./session-hub.js";
46
+ import type { HerdctlService } from "./herdctl.js";
47
+ /** How long to wait for a cancelled turn to actually die before giving up. */
48
+ export declare const DEFAULT_QUIESCE_TIMEOUT_MS = 10000;
49
+ /**
50
+ * The dependencies the interlock needs. Both come off the route context: `hub`
51
+ * via `ctx.managementOpsContext?.hub` (hence optional — a server built without
52
+ * the WS layer has no hub, and correctly reports nothing running).
53
+ */
54
+ export interface TurnInterlockDeps {
55
+ hub: SessionHub | undefined;
56
+ herdctl: HerdctlService;
57
+ }
58
+ /**
59
+ * - `idle` — nothing was running; the caller may proceed.
60
+ * - `stopped` — a turn WAS running, was stopped, and is now verifiably dead;
61
+ * the caller may proceed.
62
+ * - `stuck` — something is still alive after the timeout. The caller MUST NOT
63
+ * mutate the transcript: that is precisely the resurrection case.
64
+ */
65
+ export type QuiesceOutcome = "idle" | "stopped" | "stuck";
66
+ /**
67
+ * Is anything alive on this session right now — a live WS turn, or a session
68
+ * herdctl's reaper is still holding open?
69
+ */
70
+ export declare function sessionIsBusy(deps: TurnInterlockDeps, sessionId: string): boolean;
71
+ /**
72
+ * Stop whatever is running against `sessionId` and WAIT for it to actually be
73
+ * gone. Returns `stuck` rather than throwing, so each caller can pick its own
74
+ * failure shape (a 409, or a `failed` bucket entry in a batch).
75
+ *
76
+ * A running turn is not always cancellable the instant we look at it: `jobId`
77
+ * is null early in a turn (it arrives with the first frames). That is why this
78
+ * polls rather than cancelling once — each pass re-reads `activeInfo` and
79
+ * cancels any job id it has not cancelled yet, so a turn that arms its job id a
80
+ * beat later is still caught inside the same call.
81
+ */
82
+ export declare function quiesceSession(deps: TurnInterlockDeps, sessionId: string, opts?: {
83
+ timeoutMs?: number;
84
+ }): Promise<QuiesceOutcome>;
85
+ /**
86
+ * Quiesce every session with a running turn in `projectSlug`, ahead of a project
87
+ * delete. Returns the ids that could NOT be stopped — empty means it is safe to
88
+ * remove the project directory.
89
+ *
90
+ * Only the hub can enumerate running turns by project, so a server with no hub
91
+ * simply has nothing to stop.
92
+ */
93
+ export declare function quiesceProject(deps: TurnInterlockDeps, projectSlug: string, opts?: {
94
+ timeoutMs?: number;
95
+ }): Promise<string[]>;
96
+ /** The 409 body every refusing route sends, so clients can match one code. */
97
+ export declare function turnRunningError(sessionIds: string[]): {
98
+ error: string;
99
+ code: "turn_running";
100
+ sessionIds: string[];
101
+ };
@@ -0,0 +1,84 @@
1
+ /** How long to wait for a cancelled turn to actually die before giving up. */
2
+ export const DEFAULT_QUIESCE_TIMEOUT_MS = 10_000;
3
+ /** How often to re-check liveness while waiting. */
4
+ const POLL_MS = 25;
5
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
6
+ /**
7
+ * Is anything alive on this session right now — a live WS turn, or a session
8
+ * herdctl's reaper is still holding open?
9
+ */
10
+ export function sessionIsBusy(deps, sessionId) {
11
+ if (deps.hub?.isRunning(sessionId) === true)
12
+ return true;
13
+ return deps.herdctl.isSdkSessionLive(sessionId);
14
+ }
15
+ /**
16
+ * Stop whatever is running against `sessionId` and WAIT for it to actually be
17
+ * gone. Returns `stuck` rather than throwing, so each caller can pick its own
18
+ * failure shape (a 409, or a `failed` bucket entry in a batch).
19
+ *
20
+ * A running turn is not always cancellable the instant we look at it: `jobId`
21
+ * is null early in a turn (it arrives with the first frames). That is why this
22
+ * polls rather than cancelling once — each pass re-reads `activeInfo` and
23
+ * cancels any job id it has not cancelled yet, so a turn that arms its job id a
24
+ * beat later is still caught inside the same call.
25
+ */
26
+ export async function quiesceSession(deps, sessionId, opts = {}) {
27
+ if (!sessionIsBusy(deps, sessionId))
28
+ return "idle";
29
+ const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS);
30
+ const cancelled = new Set();
31
+ let reaped = false;
32
+ // Mark the turn end this is about to cause as OURS, not a user's Stop. The WS
33
+ // layer treats the two oppositely for anything queued behind the turn: a Stop
34
+ // hands the message back to the user's composer, while this caller is about to
35
+ // delete, revert or promote the transcript — so the queue must be left strictly
36
+ // alone. Draining it here would start a fresh turn on a chat being removed (the
37
+ // #730 resurrection), and would make the session busy again so the poll below
38
+ // could never settle.
39
+ deps.hub?.noteCancel(sessionId, { reason: "quiesce", origin: null });
40
+ for (;;) {
41
+ const jobId = deps.hub?.activeInfo(sessionId)?.jobId ?? null;
42
+ if (jobId && !cancelled.has(jobId)) {
43
+ cancelled.add(jobId);
44
+ await deps.herdctl.cancel(jobId).catch(() => false);
45
+ }
46
+ // Close the managed session too — on the session runtime `cancel` only
47
+ // interrupts the turn and leaves the subprocess holding the transcript.
48
+ // Once is enough; a second reap of an already-closed session is a no-op.
49
+ if (!reaped) {
50
+ reaped = true;
51
+ deps.herdctl.reapSession(sessionId);
52
+ }
53
+ if (!sessionIsBusy(deps, sessionId))
54
+ return "stopped";
55
+ if (Date.now() >= deadline)
56
+ return "stuck";
57
+ await delay(POLL_MS);
58
+ }
59
+ }
60
+ /**
61
+ * Quiesce every session with a running turn in `projectSlug`, ahead of a project
62
+ * delete. Returns the ids that could NOT be stopped — empty means it is safe to
63
+ * remove the project directory.
64
+ *
65
+ * Only the hub can enumerate running turns by project, so a server with no hub
66
+ * simply has nothing to stop.
67
+ */
68
+ export async function quiesceProject(deps, projectSlug, opts = {}) {
69
+ const running = (deps.hub?.runningSessions() ?? []).filter((i) => i.projectSlug === projectSlug);
70
+ // Concurrent: each turn dies independently, so the whole call is bounded by one
71
+ // timeout rather than N of them stacked end to end.
72
+ const outcomes = await Promise.all(running.map(async (info) => [info.sessionId, await quiesceSession(deps, info.sessionId, opts)]));
73
+ return outcomes.filter(([, outcome]) => outcome === "stuck").map(([sessionId]) => sessionId);
74
+ }
75
+ /** The 409 body every refusing route sends, so clients can match one code. */
76
+ export function turnRunningError(sessionIds) {
77
+ return {
78
+ error: sessionIds.length === 1
79
+ ? `A turn is still running on chat ${sessionIds[0]} and could not be stopped; try again.`
80
+ : `Turns are still running on ${sessionIds.length} chats and could not be stopped; try again.`,
81
+ code: "turn_running",
82
+ sessionIds,
83
+ };
84
+ }
@@ -214,10 +214,23 @@ export interface ChatSetQueueMessage {
214
214
  /** The queued message text, or null/empty to clear. */
215
215
  text?: string | null;
216
216
  /**
217
- * Client-stamped identity of this queued message (#245): the ms timestamp of
218
- * when it was first enqueued, stable across reloads. The server records the ts
219
- * of the message it last drained per session so it can skip re-sending a stale
220
- * copy a reloaded client re-asserts. Absent → the server stamps `Date.now()`.
217
+ * OPAQUE identity of this client's queue (#245 stable identity, #736). Minted
218
+ * once when the queue is first created and kept across edits, appends and
219
+ * reloads, so the server can tell (a) this client updating its own queue apart
220
+ * from another client queueing alongside it (#629) and (b) a stale re-assert of
221
+ * an already-drained message apart from a new one — by EQUALITY, never ordering.
222
+ *
223
+ * Absent from an older client, which sends only `ts`; the server folds that
224
+ * into an id.
225
+ */
226
+ qid?: string | null;
227
+ /**
228
+ * Legacy client-stamped enqueue time (#245). Superseded by `qid`: it was used
229
+ * as an ORDERED identity, so one client with a fast clock poisoned the dedup
230
+ * marker and every later queued message on that chat — from any client — was
231
+ * silently destroyed (#736). Still accepted (older clients send it, and it
232
+ * still identifies a message) but only ever compared for equality, and never
233
+ * stored as the enqueue time — the server stamps that itself.
221
234
  */
222
235
  ts?: number | null;
223
236
  };
@@ -382,6 +395,64 @@ export interface ChatQueuedFlushedMessage {
382
395
  text?: string;
383
396
  };
384
397
  }
398
+ /**
399
+ * The chat's queued message changed (#629). Broadcast to EVERY socket attached to
400
+ * the session whenever the slot is written, so the queue is shared chat state that
401
+ * all clients render identically instead of invisible per-client state.
402
+ *
403
+ * Before this, a queue was only ever written and never announced: a second client
404
+ * queueing on the same chat overwrote the first client's text with no signal to
405
+ * anyone. The first user's chip sat there showing a message that no longer existed,
406
+ * and when the drain finally fired, their transcript rendered — as their own user
407
+ * bubble — a message they had never typed. The slot now MERGES contributions, and
408
+ * this frame is what makes the merge visible.
409
+ */
410
+ export interface ChatQueuedStateMessage {
411
+ type: "chat:queued_state";
412
+ payload: {
413
+ projectSlug: string;
414
+ sessionId: string;
415
+ /** The chat's full queued text, or null when the slot is now empty. */
416
+ text: string | null;
417
+ /**
418
+ * The slot's identity. A client adopts it as its own queue id, so its next
419
+ * edit updates this shared slot in place rather than appending beside it.
420
+ */
421
+ qid?: string;
422
+ /**
423
+ * Why the slot changed, when it is worth explaining. `returned` means a user
424
+ * pressed Stop and the message went back to THEIR composer — sent to the
425
+ * other clients so a chip that just vanished from under them has a reason
426
+ * attached, rather than looking like the message evaporated. The client that
427
+ * pressed Stop is excluded from this broadcast; it gets
428
+ * {@link ChatQueuedReturnedMessage} instead.
429
+ */
430
+ reason?: "returned";
431
+ };
432
+ }
433
+ /**
434
+ * A user pressed Stop, so the message queued behind that turn is handed BACK to
435
+ * them (#751 follow-up). Sent only to the socket that asked to cancel.
436
+ *
437
+ * Stop means "give me control back". Auto-sending the follow-up would have the
438
+ * agent start working again the instant the user stopped it, and holding the
439
+ * message server-side is what stranded it (#627) — so it returns to the composer,
440
+ * where it is visible, editable, and sent only when the user says so.
441
+ *
442
+ * Deliberately NOT `chat:queued_flushed` with a flag: that frame means "this text
443
+ * was sent, render it as the user's bubble", and a returned message is precisely
444
+ * one that was NOT sent. Overloading it risks a phantom user turn in the
445
+ * transcript for a message the agent never received.
446
+ */
447
+ export interface ChatQueuedReturnedMessage {
448
+ type: "chat:queued_returned";
449
+ payload: {
450
+ projectSlug: string;
451
+ sessionId: string;
452
+ /** The queued text, for the client to put back in its composer. */
453
+ text: string;
454
+ };
455
+ }
385
456
  /**
386
457
  * A background task was killed at the turn boundary and the keeper is idle
387
458
  * (issue #347). Broadcast LIVE by the recovery engine the moment the kill is
@@ -418,6 +489,6 @@ export interface ChatNoticeMessage {
418
489
  export interface PongMessage {
419
490
  type: "pong";
420
491
  }
421
- export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
492
+ export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
422
493
  export declare function isClientMessage(data: unknown): data is ClientMessage;
423
494
  export {};
@@ -245,6 +245,8 @@ export function isClientMessage(data) {
245
245
  return false;
246
246
  if (p.ts !== undefined && p.ts !== null && typeof p.ts !== "number")
247
247
  return false;
248
+ if (p.qid !== undefined && p.qid !== null && typeof p.qid !== "string")
249
+ return false;
248
250
  return true;
249
251
  }
250
252
  return false;