@edspencer/paddock 0.59.1 → 0.61.0

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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -1
  3. package/package.json +4 -3
  4. package/packages/server/dist/adoptable.d.ts +108 -5
  5. package/packages/server/dist/adoptable.js +279 -33
  6. package/packages/server/dist/app.js +15 -0
  7. package/packages/server/dist/claude-home.d.ts +125 -0
  8. package/packages/server/dist/claude-home.js +276 -0
  9. package/packages/server/dist/cli/import-chats.js +9 -6
  10. package/packages/server/dist/cli/paddock.js +9 -31
  11. package/packages/server/dist/config.d.ts +75 -12
  12. package/packages/server/dist/config.js +58 -11
  13. package/packages/server/dist/herdctl-agent-config.d.ts +15 -7
  14. package/packages/server/dist/herdctl-agent-config.js +21 -9
  15. package/packages/server/dist/herdctl.d.ts +95 -0
  16. package/packages/server/dist/herdctl.js +209 -6
  17. package/packages/server/dist/hook-config.d.ts +17 -15
  18. package/packages/server/dist/hook-config.js +11 -9
  19. package/packages/server/dist/routes/chats.js +92 -9
  20. package/packages/server/dist/run-provenance.d.ts +11 -0
  21. package/packages/server/dist/run-provenance.js +18 -0
  22. package/packages/server/dist/transcripts.d.ts +25 -6
  23. package/packages/server/dist/transcripts.js +12 -24
  24. package/packages/server/dist/trigger-config.d.ts +18 -8
  25. package/packages/server/dist/trigger-config.js +18 -8
  26. package/packages/server/dist/ws-turn.d.ts +0 -13
  27. package/packages/server/dist/ws-turn.js +50 -0
  28. package/packages/server/dist/ws.js +43 -11
  29. package/packages/server/package.json +1 -1
  30. package/packages/web/dist/assets/{InstanceConfigPage-CAwSnyga.js → InstanceConfigPage-CzMqTdUU.js} +1 -1
  31. package/packages/web/dist/assets/{MarkdownRenderer-DpGpkMzG.js → MarkdownRenderer-YSEZHWDe.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectRedirect-D1ipv4jr.js → ProjectRedirect-DLWxZbqK.js} +1 -1
  33. package/packages/web/dist/assets/ProjectView-BFX8ff2n.js +13 -0
  34. package/packages/web/dist/assets/{ProjectsGrid-Dwngb-m1.js → ProjectsGrid-DLOeY_ww.js} +1 -1
  35. package/packages/web/dist/assets/{arc-BZh6Z9_D.js → arc-BwC1ik-b.js} +1 -1
  36. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-iCtJiW4k.js → architectureDiagram-3BPJPVTR-CsVvXQWw.js} +1 -1
  37. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BeI9Wi0h.js → blockDiagram-GPEHLZMM-DXQk2bIa.js} +1 -1
  38. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D_j2LKZ-.js → c4Diagram-AAUBKEIU-DXiA26It.js} +1 -1
  39. package/packages/web/dist/assets/channel-DDgTXrGP.js +2 -0
  40. package/packages/web/dist/assets/{chunk-2J33WTMH-DRlfPROq.js → chunk-2J33WTMH-Dt5nC02A.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-4BX2VUAB-BMY66MLs.js → chunk-4BX2VUAB-DcjnWvl6.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-55IACEB6-BAQ92trf.js → chunk-55IACEB6-CRa3LkU4.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-727SXJPM-Dxgb_nNN.js → chunk-727SXJPM-H3V-11qu.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D5cqv3Jv.js → chunk-AQP2D5EJ-DPCrENYV.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-FMBD7UC4-BOEqxzyA.js → chunk-FMBD7UC4-C4UO0ZTc.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-ND2GUHAM-CkTZgv_W.js → chunk-ND2GUHAM-BtI37CnI.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-QZHKN3VN-DIr8TL7M.js → chunk-QZHKN3VN-C5bTaK5j.js} +1 -1
  48. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +2 -0
  49. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +2 -0
  50. package/packages/web/dist/assets/{core-DyOZ2e1O.js → core-DB3cpm5N.js} +1 -1
  51. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-D8Muyf4o.js → cose-bilkent-S5V4N54A-q-cjTZO4.js} +1 -1
  52. package/packages/web/dist/assets/{dagre-BM42HDAG-B_EUbUz6.js → dagre-BM42HDAG-DYEV5HFo.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-2AECGRRQ-DpAM0K6Q.js → diagram-2AECGRRQ-D16XCw78.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dhs9RMKk.js → diagram-5GNKFQAL-CeztryYE.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-KO2AKTUF-LBeTHReu.js → diagram-KO2AKTUF-D5SLIMDS.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-LMA3HP47-CxboRwue.js → diagram-LMA3HP47-Bw-sVPxM.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-OG6HWLK6-BK_P-OSi.js → diagram-OG6HWLK6-DsRMITgx.js} +1 -1
  58. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CPSKRHI5.js → erDiagram-TEJ5UH35-T8Qqc_ku.js} +1 -1
  59. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CbUumAP5.js → flowDiagram-I6XJVG4X-Cyx47UWU.js} +1 -1
  60. package/packages/web/dist/assets/{format-DDL0Q7H2.js → format-D2I0duZa.js} +1 -1
  61. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-l-p25ckA.js → ganttDiagram-6RSMTGT7-DxYJmG9o.js} +1 -1
  62. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-i8kDIgga.js → gitGraphDiagram-PVQCEYII-COK5YOTD.js} +1 -1
  63. package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
  64. package/packages/web/dist/assets/index-wBXQMkGI.js +71 -0
  65. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CbyLnHIe.js → infoDiagram-5YYISTIA-BamRN_Nn.js} +1 -1
  66. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-C1_3z8-9.js → ishikawaDiagram-YF4QCWOH-DuOHdpEH.js} +1 -1
  67. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BzyAtQdy.js → journeyDiagram-JHISSGLW-Bri7GKTW.js} +1 -1
  68. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Fp2VVm-b.js → kanban-definition-UN3LZRKU-BdkgUnzI.js} +1 -1
  69. package/packages/web/dist/assets/{linear-6ZKEeWQ5.js → linear-DOcNPCpB.js} +1 -1
  70. package/packages/web/dist/assets/{mermaid.core-Dorg4Dl2.js → mermaid.core-D9SbVBpN.js} +4 -4
  71. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bk4Ax0bI.js → mindmap-definition-RKZ34NQL-CVmLm7zn.js} +1 -1
  72. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DoTn5jHm.js → pieDiagram-4H26LBE5-D0N10eVU.js} +1 -1
  73. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ClNmIKCj.js → quadrantDiagram-W4KKPZXB-B5gnYdTa.js} +1 -1
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DMF7i5KW.js → requirementDiagram-4Y6WPE33-CwtxezNZ.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-C-ALbVLg.js → sankeyDiagram-5OEKKPKP-D79O3cux.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DsUxj8IQ.js → sequenceDiagram-3UESZ5HK-xhKcI6tI.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-OCZiRRsz.js → stateDiagram-AJRCARHV-DBYMAjcX.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-5ifjxa-s.js → timeline-definition-PNZ67QCA-e-MYF4lm.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Bqvqpot5.js → vennDiagram-CIIHVFJN-B8UsvRDM.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-DwgnVd48.js → wardley-L42UT6IY-1ucACjTl.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-F-VCH-oE.js → wardleyDiagram-YWT4CUSO-DJufLd_u.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-L4q6oV_7.js → xychartDiagram-2RQKCTM6-qqaZNlu9.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/ProjectView-BI1TtrOK.js +0 -13
  87. package/packages/web/dist/assets/channel-DszBVZgr.js +0 -2
  88. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuGqlnbT.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuGqlnbT.js +0 -2
  90. package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
  91. package/packages/web/dist/assets/index-DKDgTDXa.js +0 -71
  92. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BwcJspgG.js +0 -2
@@ -868,11 +868,18 @@ export function registerChatWorkspaceRoutes(app, ctx) {
868
868
  // optional `when` (epoch-ms) lets the client pass its own timestamp; defaults
869
869
  // to now. Mirrors the archive toggle's shape/validation. Monotonic in the
870
870
  // store (an older `when` is a no-op), so it never resurrects a stale unread.
871
+ //
872
+ // `keepUnread: true` marks the seen as INFERRED rather than intentional (#608):
873
+ // the watermark still advances, but an explicit "mark unread" override is left
874
+ // alone. The web client uses it when a turn happens to land while the chat is
875
+ // focused — "you were watching" must not silently spend a flag the user (or an
876
+ // API caller) set seconds earlier. The response reports the resulting flag so a
877
+ // caller can see what its request did.
871
878
  app.post("/chats/:sessionId/seen", {
872
879
  schema: {
873
880
  tags: ["Chats"],
874
881
  summary: "Mark a project chat as seen",
875
- description: "Persists the user's last-viewed moment for a project chat server-side, driving the cross-device unread affordance. Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Responds 200 with `{ ok, lastSeen }`.",
882
+ description: "Persists the user's last-viewed moment for a project chat server-side, driving the cross-device unread affordance. Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Clears the chat's MANUAL unread override (see the `/unread` route) unless `keepUnread` is true, which marks the request as an INFERRED seen and leaves an explicit flag intact. Responds 200 with `{ ok, lastSeen, unread }`, `unread` being the override's state afterwards.",
876
883
  params: {
877
884
  type: "object",
878
885
  properties: {
@@ -886,12 +893,15 @@ export function registerChatWorkspaceRoutes(app, ctx) {
886
893
  additionalProperties: true,
887
894
  properties: {
888
895
  when: { description: "Epoch-ms timestamp to record as last seen; defaults to now." },
896
+ keepUnread: {
897
+ description: "When true, advance the watermark WITHOUT clearing the manual unread override — for a seen that was inferred rather than intended. Defaults to false.",
898
+ },
889
899
  },
890
900
  required: [],
891
901
  },
892
902
  response: {
893
903
  200: {
894
- description: "Object `{ ok, lastSeen }` with the recorded watermark.",
904
+ description: "Object `{ ok, lastSeen, unread }` with the recorded watermark and the manual unread override's state afterwards.",
895
905
  type: "object",
896
906
  additionalProperties: true,
897
907
  },
@@ -906,9 +916,17 @@ export function registerChatWorkspaceRoutes(app, ctx) {
906
916
  const user = readStateUser(req);
907
917
  await readState.setLastSeen(user, agent, req.params.sessionId, when);
908
918
  // Marking a chat seen also clears any MANUAL unread override (#458): once
909
- // the user has viewed it, the "look at it again later" flag is spent.
910
- await unread.setUnread(user, agent, req.params.sessionId, false).catch(() => undefined);
911
- return reply.code(200).send({ ok: true, lastSeen: when });
919
+ // the user has viewed it, the "look at it again later" flag is spent —
920
+ // UNLESS the caller flagged this as an inferred seen (#608), in which case
921
+ // the explicit intent outranks it and the flag stands.
922
+ let stillUnread = false;
923
+ if (req.body?.keepUnread === true) {
924
+ stillUnread = await unread.isUnread(user, agent, req.params.sessionId);
925
+ }
926
+ else {
927
+ await unread.setUnread(user, agent, req.params.sessionId, false).catch(() => undefined);
928
+ }
929
+ return reply.code(200).send({ ok: true, lastSeen: when, unread: stillUnread });
912
930
  }
913
931
  catch (err) {
914
932
  return sendProjectError(reply, err);
@@ -1311,7 +1329,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1311
1329
  schema: {
1312
1330
  tags: ["Chats"],
1313
1331
  summary: "Count the native Claude Code chats this project could import",
1314
- description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty and slash-command-only transcripts are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
1332
+ description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty transcripts, slash-command-only transcripts and Paddock's own sweeper curation runs (`too-small` / `slash-command-only` / `sweeper-run`) are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
1315
1333
  params: {
1316
1334
  type: "object",
1317
1335
  properties: {
@@ -1330,7 +1348,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1330
1348
  }, async (req, reply) => {
1331
1349
  try {
1332
1350
  const project = await projects.get(req.params.slug);
1333
- return await herdctl.listAdoptable(project);
1351
+ const summary = await herdctl.listAdoptable(project);
1352
+ // `importFrom` is a server-internal detail — the synthetic path a legacy
1353
+ // source is reached through (#620). Clients round-trip `sourceCwd`, so
1354
+ // strip it rather than publishing a `~/.claude` path in the DTO; the
1355
+ // response schema is `additionalProperties: true`, so it would otherwise
1356
+ // serialize.
1357
+ return {
1358
+ ...summary,
1359
+ sources: summary.sources.map(({ importFrom: _importFrom, ...rest }) => rest),
1360
+ };
1334
1361
  }
1335
1362
  catch (err) {
1336
1363
  return sendProjectError(reply, err);
@@ -1340,7 +1367,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1340
1367
  schema: {
1341
1368
  tags: ["Chats"],
1342
1369
  summary: "Import native Claude Code chats into this project",
1343
- description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
1370
+ description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. `sessionIds` narrows the import to a chosen SUBSET of what is on offer (what the confirmation dialog sends); ids that are not on offer are simply not imported, and anything the engine adopts as a side effect of taking a whole source is released again. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
1344
1371
  params: {
1345
1372
  type: "object",
1346
1373
  properties: {
@@ -1355,6 +1382,11 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1355
1382
  sourceCwd: {
1356
1383
  description: "Optional single source working directory to import from. Must be one of the `sourceCwd` values reported by GET …/adoptable-chats (or the project's own working directory); anything else is rejected with 400.",
1357
1384
  },
1385
+ sessionIds: {
1386
+ type: "array",
1387
+ items: { type: "string" },
1388
+ description: "Optional subset of the offered session ids to import. Omit to import everything on offer.",
1389
+ },
1358
1390
  },
1359
1391
  required: [],
1360
1392
  },
@@ -1382,7 +1414,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1382
1414
  .send({ error: `Not an importable source: ${sourceCwd}`, code: "invalid" });
1383
1415
  }
1384
1416
  }
1385
- const result = await herdctl.adoptChats(project, { sourceCwd });
1417
+ const result = await herdctl.adoptChats(project, {
1418
+ sourceCwd,
1419
+ sessionIds: req.body?.sessionIds,
1420
+ });
1386
1421
  // Stamp each imported chat's provenance so the list can badge it as
1387
1422
  // imported rather than as an ordinary human chat. `stampIfAbsent`, not
1388
1423
  // `stamp`: provenance describes how a chat came to exist and is never
@@ -1403,4 +1438,52 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1403
1438
  return sendProjectError(reply, err);
1404
1439
  }
1405
1440
  });
1441
+ app.post("/unadopt-chats", {
1442
+ schema: {
1443
+ tags: ["Chats"],
1444
+ summary: "Undo the most recent native-chat import into this project",
1445
+ description: "Reverses the import this server most recently performed into the workspace: each adoption is released and the copies that import placed are deleted, leaving the user's own `~/.claude` history untouched. `sessionIds` undoes part of that import; omit it to undo all of it. Deliberately scoped to the LAST import and remembered in memory — undo may delete files, so the paths it can touch come from what this process actually did rather than from the request body, and a restart forgets the offer rather than acting on a stale one. Response: `{ released }`, the session ids actually released; an empty array means there was nothing to undo (nothing imported, already undone, or the server restarted since).",
1446
+ params: {
1447
+ type: "object",
1448
+ properties: {
1449
+ slug: { type: "string", description: "Project slug." },
1450
+ },
1451
+ required: ["slug"],
1452
+ },
1453
+ body: {
1454
+ type: ["object", "null"],
1455
+ additionalProperties: true,
1456
+ properties: {
1457
+ sessionIds: {
1458
+ type: "array",
1459
+ items: { type: "string" },
1460
+ description: "Optional subset of the last import's session ids to release. Omit to undo the whole import. Ids outside that import are ignored.",
1461
+ },
1462
+ },
1463
+ required: [],
1464
+ },
1465
+ response: {
1466
+ 200: {
1467
+ description: "Object `{ released }` listing the session ids released.",
1468
+ type: "object",
1469
+ additionalProperties: true,
1470
+ },
1471
+ },
1472
+ },
1473
+ }, async (req, reply) => {
1474
+ try {
1475
+ const project = await projects.get(req.params.slug);
1476
+ const result = await herdctl.unadoptChats(project, {
1477
+ sessionIds: req.body?.sessionIds,
1478
+ });
1479
+ // Drop the provenance marker the import stamped, so a session that is
1480
+ // later re-imported is badged by that import rather than carrying a
1481
+ // marker from one that has been undone.
1482
+ await Promise.all(result.released.map((sessionId) => runProvenance.clear(sessionId).catch(() => undefined)));
1483
+ return result;
1484
+ }
1485
+ catch (err) {
1486
+ return sendProjectError(reply, err);
1487
+ }
1488
+ });
1406
1489
  }
@@ -104,6 +104,17 @@ export declare class RunProvenanceStore {
104
104
  * survives.
105
105
  */
106
106
  stampIfAbsent(sessionId: string, provenance: RunProvenance): Promise<void>;
107
+ /**
108
+ * Forget a chat's provenance (#660).
109
+ *
110
+ * The inverse of {@link stamp}, for the one case where a chat's creation is
111
+ * itself undone: undoing a native-chat import releases the adoption and deletes
112
+ * the copy, and a marker left behind would badge a LATER import of the same
113
+ * session as belonging to the one that was undone. Returns silently when there
114
+ * was nothing recorded, so an undo of a partially-stamped import is not an
115
+ * error.
116
+ */
117
+ clear(sessionId: string): Promise<void>;
107
118
  /** Write-through, serialised so overlapping stamps can't corrupt the file. */
108
119
  private persist;
109
120
  }
@@ -192,6 +192,24 @@ export class RunProvenanceStore {
192
192
  return;
193
193
  await this.stamp(sessionId, provenance);
194
194
  }
195
+ /**
196
+ * Forget a chat's provenance (#660).
197
+ *
198
+ * The inverse of {@link stamp}, for the one case where a chat's creation is
199
+ * itself undone: undoing a native-chat import releases the adoption and deletes
200
+ * the copy, and a marker left behind would badge a LATER import of the same
201
+ * session as belonging to the one that was undone. Returns silently when there
202
+ * was nothing recorded, so an undo of a partially-stamped import is not an
203
+ * error.
204
+ */
205
+ async clear(sessionId) {
206
+ if (!isSafeId(sessionId))
207
+ return;
208
+ const map = await this.ensureLoaded();
209
+ if (!map.delete(sessionId))
210
+ return;
211
+ await this.persist(map);
212
+ }
195
213
  /** Write-through, serialised so overlapping stamps can't corrupt the file. */
196
214
  persist(map) {
197
215
  this.writing = this.writing.then(async () => {
@@ -21,13 +21,32 @@ export declare function projectChatsDir(projectDir: string): string;
21
21
  * store stays in the project's metadata dir (the data repo) — so transcripts
22
22
  * never pollute the external repo's working tree — hence the two are split.
23
23
  *
24
- * `claudeHomePath` is the Claude home to plant the symlink in. Callers holding a
25
- * config MUST pass `cfg.claudeHome` (#588): the engine is constructed with that
26
- * same resolved value, and a symlink planted in a different home than the one
27
- * the engine reads is exactly the "chats list but open empty" failure. The
28
- * env-derived default is kept only for callers with no config (unit tests).
24
+ * `home.path` is the Claude home to plant the symlink in, and it is REQUIRED —
25
+ * no default (#620). Callers must pass `cfg.claudeHome`: the engine is
26
+ * constructed with that same resolved value, and a symlink planted in a
27
+ * different home than the one the engine reads is exactly the "chats list but
28
+ * open empty" failure (#588). It used to default to the env-derived home for
29
+ * callers with no config, and `ensureSweeperHome` silently took that default —
30
+ * so the sweeper's symlink landed in a different home than every other agent's.
31
+ * Making the parameter required is what stops that class of bug at the type
32
+ * level.
33
+ *
34
+ * `home.owned` gates the one destructive branch (#620): migrating an existing
35
+ * real transcript directory into `.chats/` copies the files and then REMOVES the
36
+ * originals. That is correct self-healing inside a home paddock owns, where such
37
+ * a directory can only be paddock's own doing. Inside the user's `~/.claude` it
38
+ * is somebody else's data — a `claude` CLI session run in a directory paddock
39
+ * happens to also manage — so we leave it strictly alone and let the SDK keep
40
+ * writing there. Those transcripts stay importable through adoption (#588),
41
+ * which is the deliberate, user-driven version of the same move.
29
42
  */
30
- export declare function ensureProjectChats(workingDir: string, chatsHostDir?: string, claudeHomePath?: string): Promise<void>;
43
+ export interface ClaudeHomeTarget {
44
+ /** Absolute path to the Claude home to plant the redirect symlink in. */
45
+ path: string;
46
+ /** Whether paddock owns this home — see `PaddockConfig.ownsClaudeHome`. */
47
+ owned: boolean;
48
+ }
49
+ export declare function ensureProjectChats(workingDir: string, chatsHostDir: string, home: ClaudeHomeTarget): Promise<void>;
31
50
  /**
32
51
  * Read a session's FIRST user message text, untruncated, straight from its
33
52
  * transcript JSONL (issue #62). Claude Code's own `preview` is capped at 100
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Transcript relocation (backing-store Phase 3).
3
3
  *
4
- * Claude Code writes a session transcript to `~/.claude/projects/<encoded-cwd>/`,
5
- * where `<encoded-cwd>` is the agent's absolute working directory with every
4
+ * Claude Code writes a session transcript to `<claudeHome>/projects/<encoded-cwd>/`,
5
+ * where `<claudeHome>` is `CLAUDE_CONFIG_DIR` (paddock's own `<dataDir>/claude-home`
6
+ * since #620) and `<encoded-cwd>` is the agent's absolute working directory with every
6
7
  * non-[A-Za-z0-9] char replaced by '-'. That makes transcripts path-coupled and
7
8
  * not portable. We relocate them INTO the project by making the encoded path a
8
9
  * **symlink to `<projectDir>/.chats/`** — Claude then writes through the symlink
@@ -17,7 +18,6 @@
17
18
  import { promises as fs, createReadStream } from "node:fs";
18
19
  import { createInterface } from "node:readline";
19
20
  import path from "node:path";
20
- import { claudeHome } from "./config.js";
21
21
  /**
22
22
  * Encode a working directory the way Claude Code names its transcript dir.
23
23
  * For paths under 200 chars this is just the non-alphanumeric→'-' replacement
@@ -31,27 +31,8 @@ export function encodeProjectDir(dir) {
31
31
  export function projectChatsDir(projectDir) {
32
32
  return path.join(projectDir, ".chats");
33
33
  }
34
- /**
35
- * Ensure `<chatsHostDir>/.chats/` exists and that Claude Code's encoded
36
- * transcript path for `workingDir` is a symlink pointing at it (migrating an
37
- * existing real transcript dir in the process). Safe to call on every agent
38
- * registration. Never throws — a failure here must not block agent registration
39
- * or chat.
40
- *
41
- * `workingDir` is the agent's cwd (which Claude encodes into the transcript dir
42
- * name); `chatsHostDir` is where the `.chats/` store actually lives. For a
43
- * notebook project these are the same dir (the default). For a REPO-BACKED
44
- * project (issue #187) the keeper's cwd is the nested checkout but the `.chats/`
45
- * store stays in the project's metadata dir (the data repo) — so transcripts
46
- * never pollute the external repo's working tree — hence the two are split.
47
- *
48
- * `claudeHomePath` is the Claude home to plant the symlink in. Callers holding a
49
- * config MUST pass `cfg.claudeHome` (#588): the engine is constructed with that
50
- * same resolved value, and a symlink planted in a different home than the one
51
- * the engine reads is exactly the "chats list but open empty" failure. The
52
- * env-derived default is kept only for callers with no config (unit tests).
53
- */
54
- export async function ensureProjectChats(workingDir, chatsHostDir = workingDir, claudeHomePath = claudeHome()) {
34
+ export async function ensureProjectChats(workingDir, chatsHostDir, home) {
35
+ const claudeHomePath = home.path;
55
36
  try {
56
37
  const chatsDir = projectChatsDir(chatsHostDir);
57
38
  await fs.mkdir(chatsDir, { recursive: true });
@@ -68,6 +49,13 @@ export async function ensureProjectChats(workingDir, chatsHostDir = workingDir,
68
49
  }
69
50
  return;
70
51
  }
52
+ // A real directory of existing transcripts. In a home paddock does NOT own
53
+ // these are the user's own CLI transcripts (#620) — leave them exactly where
54
+ // they are. The SDK keeps reading and appending to them, and adoption is how
55
+ // the user pulls them into the project if they want to. Bailing here also
56
+ // means we never plant a symlink over them.
57
+ if (st?.isDirectory() && !home.owned)
58
+ return;
71
59
  // A real directory of existing transcripts — migrate into .chats, then link.
72
60
  if (st?.isDirectory()) {
73
61
  for (const entry of await fs.readdir(encoded)) {
@@ -197,11 +197,21 @@ export declare function curatorTriggerOf(triggers: Record<string, PaddockTrigger
197
197
  /**
198
198
  * Project a trigger's {@link TriggerRun} onto the exact herdctl agent tool-config
199
199
  * fields (snake_case), so an event/webhook trigger's OWN `trigger-<slug>-<name>`
200
- * agent enforces the capability BY CONSTRUCTION. Always sets `allowed_tools` +
200
+ * agent carries the capability BY CONSTRUCTION. Always sets `allowed_tools` +
201
201
  * `max_turns` so a trigger agent never silently inherits the keeper's broad default
202
- * toolset; a tool-less trigger yields `allowed_tools: []` (the CLI runtime then
203
- * denies every tool). The ONE place run→agent-config translation lives. (The exact
204
- * analogue of `hookToAgentToolConfig`.)
202
+ * toolset.
203
+ *
204
+ * CAVEAT on the EMPTY case (#647): a trigger that declares no tools yields
205
+ * `allowed_tools: []`, and BOTH herdctl runtimes emit the allow-list only when it is
206
+ * non-empty (`if (allowed_tools?.length)`, in `cli-runtime` and `sdk-adapter` alike).
207
+ * So an empty list is indistinguishable from an unset one — no `--allowedTools` flag,
208
+ * no `allowedTools` option — and the agent runs with Claude Code's DEFAULT tools.
209
+ * `[]` is a declaration of intent, NOT an enforced deny-all: `max_turns`, the prompt
210
+ * and `permission_mode` are the real bounds. Making a grant enforceable at all is
211
+ * #319. Pinned by `test/unit/empty-allowed-tools-runtime.test.ts`.
212
+ *
213
+ * The ONE place run→agent-config translation lives. (The exact analogue of
214
+ * `hookToAgentToolConfig`.)
205
215
  */
206
216
  export declare function triggerToAgentToolConfig(run: TriggerRun): Record<string, unknown>;
207
217
  /**
@@ -250,7 +260,7 @@ export interface ChatTriggerInfo {
250
260
  agentName: string;
251
261
  /** Whether the trigger is armed (a disabled trigger's past chats are still shown). */
252
262
  enabled: boolean;
253
- /** The exact tool grant (herdctl `allowed_tools`); `[]` = a tool-less trigger. */
263
+ /** The declared tool grant (herdctl `allowed_tools`); `[]` = none, unenforced (#647). */
254
264
  allowedTools: string[];
255
265
  /** The permission mode the trigger's turns run under, when it sets one. */
256
266
  permissionMode?: TriggerPermissionMode;
@@ -262,9 +272,9 @@ export interface ChatTriggerInfo {
262
272
  /**
263
273
  * Project a persisted trigger ({@link TriggerDto} fields) onto the web-facing
264
274
  * {@link ChatTriggerInfo}, resolving the SAME capability defaults
265
- * {@link triggerToAgentToolConfig} uses so the banner mirrors the enforced grant
266
- * exactly: an absent/empty allow-list surfaces as a tool-less `[]`, and an unset
267
- * `maxTurns` surfaces the {@link TRIGGER_DEFAULT_MAX_TURNS} bound herdctl applies.
275
+ * {@link triggerToAgentToolConfig} uses so the banner mirrors the registered grant
276
+ * exactly: an absent/empty allow-list surfaces as `[]`, and an unset `maxTurns`
277
+ * surfaces the {@link TRIGGER_DEFAULT_MAX_TURNS} bound herdctl applies.
268
278
  * The WHEN fields are read off the discriminated `trigger` so the banner can state
269
279
  * the trigger type and the exact firing condition.
270
280
  */
@@ -107,7 +107,7 @@ const runSchema = z.object({
107
107
  session: z.enum(["new", "resume"]).default("new"),
108
108
  /** Optional per-trigger model override (else the keeper default applies). */
109
109
  model: z.string().trim().min(1).optional(),
110
- /** Deny-by-default allow-list = the trigger's capability. `[]` = tool-less. */
110
+ /** The trigger's declared capability. `[]` = no tools declared (see #647: unenforced). */
111
111
  tools: z.array(z.string().trim().min(1)).default([]),
112
112
  /** Recursion bound for internal spawning (reuses B1); 0 = may not spawn. */
113
113
  maxSpawnDepth: z.number().int().nonnegative().optional(),
@@ -298,11 +298,21 @@ export function curatorTriggerOf(triggers) {
298
298
  /**
299
299
  * Project a trigger's {@link TriggerRun} onto the exact herdctl agent tool-config
300
300
  * fields (snake_case), so an event/webhook trigger's OWN `trigger-<slug>-<name>`
301
- * agent enforces the capability BY CONSTRUCTION. Always sets `allowed_tools` +
301
+ * agent carries the capability BY CONSTRUCTION. Always sets `allowed_tools` +
302
302
  * `max_turns` so a trigger agent never silently inherits the keeper's broad default
303
- * toolset; a tool-less trigger yields `allowed_tools: []` (the CLI runtime then
304
- * denies every tool). The ONE place run→agent-config translation lives. (The exact
305
- * analogue of `hookToAgentToolConfig`.)
303
+ * toolset.
304
+ *
305
+ * CAVEAT on the EMPTY case (#647): a trigger that declares no tools yields
306
+ * `allowed_tools: []`, and BOTH herdctl runtimes emit the allow-list only when it is
307
+ * non-empty (`if (allowed_tools?.length)`, in `cli-runtime` and `sdk-adapter` alike).
308
+ * So an empty list is indistinguishable from an unset one — no `--allowedTools` flag,
309
+ * no `allowedTools` option — and the agent runs with Claude Code's DEFAULT tools.
310
+ * `[]` is a declaration of intent, NOT an enforced deny-all: `max_turns`, the prompt
311
+ * and `permission_mode` are the real bounds. Making a grant enforceable at all is
312
+ * #319. Pinned by `test/unit/empty-allowed-tools-runtime.test.ts`.
313
+ *
314
+ * The ONE place run→agent-config translation lives. (The exact analogue of
315
+ * `hookToAgentToolConfig`.)
306
316
  */
307
317
  export function triggerToAgentToolConfig(run) {
308
318
  const out = {
@@ -361,9 +371,9 @@ export function triggersToHerdctlSchedules(map) {
361
371
  /**
362
372
  * Project a persisted trigger ({@link TriggerDto} fields) onto the web-facing
363
373
  * {@link ChatTriggerInfo}, resolving the SAME capability defaults
364
- * {@link triggerToAgentToolConfig} uses so the banner mirrors the enforced grant
365
- * exactly: an absent/empty allow-list surfaces as a tool-less `[]`, and an unset
366
- * `maxTurns` surfaces the {@link TRIGGER_DEFAULT_MAX_TURNS} bound herdctl applies.
374
+ * {@link triggerToAgentToolConfig} uses so the banner mirrors the registered grant
375
+ * exactly: an absent/empty allow-list surfaces as `[]`, and an unset `maxTurns`
376
+ * surfaces the {@link TRIGGER_DEFAULT_MAX_TURNS} bound herdctl applies.
367
377
  * The WHEN fields are read off the discriminated `trigger` so the banner can state
368
378
  * the trigger type and the exact firing condition.
369
379
  */
@@ -1,16 +1,3 @@
1
- /**
2
- * The turn-execution engine, extracted from ws.ts (#403).
3
- *
4
- * `makeTurnEngine({ deps, hub })` relocates — intact — the mutually-recursive core
5
- * that ws.ts's makeChatHandler used to hold in one closure: the trigger cluster,
6
- * the self-MCP injection context, the per-turn injected-MCP builders + wake cache,
7
- * the shared `startAgentTurn` execution engine, and the Layer-2/3 recovery
8
- * (`injectRecoveryNudge` + `RecoveryEngine`). These pieces cross-reference each
9
- * other (startAgentTurn ↔ recoveryEngine.armWatch, triggers ↔ startAgentTurn,
10
- * selfMcp injection ↔ startAgentTurn), so they must share ONE scope — moving them
11
- * as a group keeps that intact with no late-binding. ws.ts's socket layer consumes
12
- * the returned surface.
13
- */
14
1
  import type { SDKMessage, InjectedMcpServerDef } from "@herdctl/core";
15
2
  import type { Project } from "./projects.js";
16
3
  import { SessionHub } from "./session-hub.js";
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The turn-execution engine, extracted from ws.ts (#403).
3
+ *
4
+ * `makeTurnEngine({ deps, hub })` relocates — intact — the mutually-recursive core
5
+ * that ws.ts's makeChatHandler used to hold in one closure: the trigger cluster,
6
+ * the self-MCP injection context, the per-turn injected-MCP builders + wake cache,
7
+ * the shared `startAgentTurn` execution engine, and the Layer-2/3 recovery
8
+ * (`injectRecoveryNudge` + `RecoveryEngine`). These pieces cross-reference each
9
+ * other (startAgentTurn ↔ recoveryEngine.armWatch, triggers ↔ startAgentTurn,
10
+ * selfMcp injection ↔ startAgentTurn), so they must share ONE scope — moving them
11
+ * as a group keeps that intact with no late-binding. ws.ts's socket layer consumes
12
+ * the returned surface.
13
+ */
14
+ import { randomUUID } from "node:crypto";
1
15
  import { createSDKMessageHandler } from "@herdctl/chat";
2
16
  import { keeperAgentName, keeperSlugFromAgent } from "./herdctl.js";
3
17
  import { isKnownDriveMode, getContextLimit } from "./models.js";
@@ -172,6 +186,13 @@ export function makeTurnEngine(engine) {
172
186
  let producedReply = false;
173
187
  let noticeEmitted = false;
174
188
  let sawError = false;
189
+ // #528: the synthetic job id published for this background stretch, and the
190
+ // session id currently registered against it. The stretch is a real running
191
+ // turn as far as the UI is concerned, so it needs a cancellable identity —
192
+ // without one `chat:active` carries `jobId: null`, the client's deferred
193
+ // cancel never fires, and Stop puts nothing on the wire at all.
194
+ let bgJobId = null;
195
+ let registeredSession = null;
175
196
  // #429: sub-agent launches recovered live from the tool_use input, keyed by
176
197
  // toolUseId, so the enriched card renders without a refresh (see subagentLaunchFields).
177
198
  const launches = new Map();
@@ -180,6 +201,19 @@ export function makeTurnEngine(engine) {
180
201
  sessionId: resolvedSession,
181
202
  jobId: turn?.jobId ?? null,
182
203
  });
204
+ /**
205
+ * Point the published job id at whatever session this stream has resolved.
206
+ * Deferred rather than done once at mint time because the id is published when
207
+ * the turn opens, which can precede the first message that names a session;
208
+ * re-run on every message so a stream that resolves (or moves to) a different
209
+ * session stays cancellable.
210
+ */
211
+ const syncBackgroundRegistration = () => {
212
+ if (!bgJobId || !resolvedSession || registeredSession === resolvedSession)
213
+ return;
214
+ deps.herdctl.registerBackgroundTurn(bgJobId, resolvedSession);
215
+ registeredSession = resolvedSession;
216
+ };
183
217
  const emitNotice = (notice) => {
184
218
  if (!turn || noticeEmitted)
185
219
  return;
@@ -196,6 +230,13 @@ export function makeTurnEngine(engine) {
196
230
  return;
197
231
  resolvedSession = sid;
198
232
  turn = hub.startTurn(projectSlug, null, sid);
233
+ // #528: give the stretch a cancellable identity the moment it opens, exactly
234
+ // as the foreground path does via onJobCreated. Stop then routes to a
235
+ // force-reap of this session rather than an interrupt — there is no model
236
+ // turn in flight here to interrupt.
237
+ bgJobId = randomUUID();
238
+ turn.setJobId(bgJobId);
239
+ syncBackgroundRegistration();
199
240
  const t = turn;
200
241
  translate = createSDKMessageHandler({
201
242
  onText: (chunk) => {
@@ -252,6 +293,7 @@ export function makeTurnEngine(engine) {
252
293
  ensureTurn(m.session_id ?? resolvedSession);
253
294
  if (m.session_id)
254
295
  turn.setSession(m.session_id);
296
+ syncBackgroundRegistration();
255
297
  if (messageProducedReply(m))
256
298
  producedReply = true;
257
299
  const notice = noticeFromMessage(m);
@@ -266,6 +308,14 @@ export function makeTurnEngine(engine) {
266
308
  // Finalize the single turn once the background stream ends (reaper reap). No-op
267
309
  // if nothing was ever rendered (a sidechain-only stretch opened no turn).
268
310
  const onDone = () => {
311
+ // Drop the cancellable identity first, and unconditionally: the stream is
312
+ // over, so a later Stop must fall through to the "already finished" path
313
+ // rather than reaping whatever session has since taken this id. Runs even
314
+ // for a sidechain-only stretch that opened no turn, and on a second call.
315
+ if (bgJobId)
316
+ deps.herdctl.unregisterBackgroundTurn(bgJobId);
317
+ bgJobId = null;
318
+ registeredSession = null;
269
319
  if (!turn)
270
320
  return;
271
321
  turn.emit({
@@ -46,13 +46,21 @@ export function makeChatHandler(deps) {
46
46
  // currently attached, so a turn survives the death of the socket that started
47
47
  // it (issue #54). See session-hub.ts.
48
48
  const hub = new SessionHub();
49
- // Per-session marker of the last queued message the server has already drained
50
- // (#245), keyed `agent \0 sessionId` and stamped with that message's client
51
- // timestamp. Lets an idle-drain skip a message it already sent — e.g. a stale
52
- // localStorage copy a reloaded client re-asserts — instead of double-sending.
53
- // In-memory (shared across this handler's sockets); a rare double-send only
54
- // survives a server restart, when the persisted store is already empty anyway.
55
- const lastFlushedTs = new Map();
49
+ // Per-session marker of the queued messages the server has already drained
50
+ // (#245), keyed `agent \0 sessionId`. Lets a drain skip a message it already
51
+ // sent — e.g. a stale localStorage copy a reloaded client re-asserts — instead
52
+ // of double-sending. In-memory (shared across this handler's sockets); a rare
53
+ // double-send only survives a server restart, when the persisted store is
54
+ // already empty anyway.
55
+ //
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.
63
+ const lastFlushed = new Map();
56
64
  // Every currently-connected socket, so a turn's start/stop transition can be
57
65
  // broadcast to all clients — powering the per-chat sidebar streaming dots that
58
66
  // must update even for chats whose pane isn't mounted (issue #53).
@@ -385,7 +393,7 @@ export function makeChatHandler(deps) {
385
393
  // successfully and (b) when a queue is set while the session is idle — a queue
386
394
  // that arrived (e.g. via the reconnect outbox) after the turn it was meant to
387
395
  // follow already ended. `take` makes the read+clear atomic so the two callers
388
- // can never both send it; the `lastFlushedTs` marker skips a message already
396
+ // can never both send it; the `lastFlushed` marker skips a message already
389
397
  // drained (a stale client re-assert on reload) so it isn't sent twice.
390
398
  const drainQueue = async (slug, sessionId) => {
391
399
  if (!deps.queuedMessage)
@@ -395,7 +403,14 @@ export function makeChatHandler(deps) {
395
403
  if (!queued?.text)
396
404
  return;
397
405
  const markerKey = `${agent}${KEY_SEP}${sessionId}`;
398
- const already = (lastFlushedTs.get(markerKey) ?? 0) >= queued.createdAtMs;
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)));
399
414
  // Tell every attached client (origin + reconnected sockets) to clear its copy
400
415
  // of this message. When we're really sending it, carry the text so the client
401
416
  // renders the sent bubble in-transcript; on a stale re-assert we only clear.
@@ -409,7 +424,12 @@ export function makeChatHandler(deps) {
409
424
  });
410
425
  if (already)
411
426
  return;
412
- lastFlushedTs.set(markerKey, queued.createdAtMs);
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]) });
413
433
  // Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
414
434
  // above the reply. Run it detached, like a human send. A leading-slash queued
415
435
  // message is a slash command (e.g. "/compact"): route it through the command
@@ -865,6 +885,11 @@ export function makeChatHandler(deps) {
865
885
  const slug = msg.payload.projectSlug;
866
886
  const { command, sessionId } = msg.payload;
867
887
  let resolvedSession = sessionId ?? null;
888
+ // The turn id Stop cancels this command by (#632). Hardcoded `null` until
889
+ // now, so a `/compact` was permanently un-stoppable: the client's cancel is
890
+ // guarded by `if (meta.jobId)`, and even the deferred pre-arm cancel (#196)
891
+ // waited forever for an id that never arrived.
892
+ let jobId = null;
868
893
  const seen = initTurnUsage();
869
894
  // Same hub-tracked turn as onChatSend so a slash-command turn also survives
870
895
  // a mid-turn socket drop (issue #54). A command always targets an existing
@@ -873,7 +898,7 @@ export function makeChatHandler(deps) {
873
898
  const routing = () => ({
874
899
  projectSlug: slug,
875
900
  sessionId: resolvedSession,
876
- jobId: null,
901
+ jobId,
877
902
  });
878
903
  // #429: sub-agent launches recovered live from the tool_use input (a command
879
904
  // like a custom slash-command can still spawn a Task/Agent), keyed by toolUseId.
@@ -923,6 +948,13 @@ export function makeChatHandler(deps) {
923
948
  const { sessionId: finalSession } = await deps.herdctl.runCommand(agentName, {
924
949
  command,
925
950
  resume: resolvedSession,
951
+ // Arm Stop the moment the session exists (#632) — the same contract
952
+ // onChatSend uses, so the client renders and can fire Stop while the
953
+ // command is still working.
954
+ onJobCreated: (id) => {
955
+ jobId = id;
956
+ turn.setJobId(id);
957
+ },
926
958
  onMessage: async (m) => {
927
959
  // A slash-command turn can spawn a Task too; its nested steps never
928
960
  // render top-level ({@link isSidechainMessage}).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paddock/server",
3
- "version": "0.59.1",
3
+ "version": "0.61.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }