@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
@@ -141,15 +141,22 @@ export function sweeperWorkingDir(cfg, slug) {
141
141
  return path.join(cfg.dataDir, "sweepers", slug === "" ? "_root" : slug);
142
142
  }
143
143
  /**
144
- * A project's sweeper (curator) agent config. TOOL-LESS: the sweeper has NO
145
- * tools (`allowed_tools: []`) — it never reads or writes files. Instead it
146
- * RETURNS the curated content as plain assistant text in marked sections
147
- * (OVERVIEW / CHANGELOG / optional CLAUDE, issue #177); SweepService parses
148
- * that text and writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
144
+ * A project's sweeper (curator) agent config. TOOL-LESS BY DESIGN: it declares
145
+ * `allowed_tools: []` and never reads or writes files. Instead it RETURNS the
146
+ * curated content as plain assistant text in marked sections (OVERVIEW /
147
+ * CHANGELOG / optional CLAUDE, issue #177); SweepService parses that text and
148
+ * writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
149
149
  *
150
150
  * This is cheaper and far more predictable than letting a Haiku agent drive
151
151
  * file edits: no tool-loop turns, no partial writes, no permission_mode /
152
- * denied_tools to reason about (all irrelevant with zero tools).
152
+ * denied_tools to reason about.
153
+ *
154
+ * What makes it tool-less is NOT the empty `allowed_tools` — herdctl emits an
155
+ * allow-list only when it is non-empty, so `[]` restricts nothing (#647). The
156
+ * actual guarantees are: no injected MCP servers (the sweeper is the one turn
157
+ * path that does not even get `send_file`), `max_turns: 4`, a system prompt
158
+ * whose first line is "You DO NOT use any tools", and a non-interactive
159
+ * `claude -p` run that cannot answer a permission prompt.
153
160
  */
154
161
  export function buildSweeperConfig(cfg, project) {
155
162
  // T5: the sweeper IS the default `curate-overview` (event/afterTurn) trigger. When a
@@ -171,9 +178,13 @@ export function buildSweeperConfig(cfg, project) {
171
178
  // addAgent does deep-merge the loaded defaults, they just never carry it).
172
179
  runtime: "cli",
173
180
  model: curatorModel ?? SWEEPER_DEFAULT_MODEL,
174
- // Tool-less: a handful of turns is plenty since there are no tool loops.
181
+ // A handful of turns is plenty since there are no tool loops — and this, not
182
+ // the empty allow-list below, is one of the things actually bounding the run.
175
183
  max_turns: 4,
176
- // NO tools. The sweeper returns text only; SweepService does the writing.
184
+ // Declares NO tools: the sweeper returns text only; SweepService does the
185
+ // writing. Note herdctl does not emit an empty allow-list at all, so this
186
+ // restricts nothing on its own (#647) — see the doc comment above for the
187
+ // properties that do make the sweeper tool-less in practice.
177
188
  allowed_tools: [],
178
189
  system_prompt: "You are a concise project curator. You DO NOT use any tools — you only " +
179
190
  "return text. From the recent activity, the current OVERVIEW.md, the " +
@@ -226,7 +237,8 @@ export function buildSweeperConfig(cfg, project) {
226
237
  * (`allowed_tools`/`permission_mode`/`model`/`max_turns`, projected by
227
238
  * {@link triggerToAgentToolConfig} from the trigger's `run`) IS its capability set.
228
239
  * Runs in the project's WORKING dir (so a trigger's Bash/Write act on the same tree
229
- * the keeper does). A tool-less trigger gets `allowed_tools: []` and can only return text.
240
+ * the keeper does). A trigger that declares no tools gets `allowed_tools: []` — which
241
+ * herdctl does not enforce as a deny-all; see {@link triggerToAgentToolConfig} (#647).
230
242
  */
231
243
  export function buildTriggerConfig(cfg, project, triggerName, trigger) {
232
244
  const config = {
@@ -134,6 +134,24 @@ export declare class HerdctlService {
134
134
  * {@link cancel}). Entries are removed when the turn's stream ends.
135
135
  */
136
136
  private liveSessions;
137
+ /**
138
+ * Background-phase turns keyed by the synthetic job id the sink minted for
139
+ * them, valued by the chat session they belong to (paddock#528).
140
+ *
141
+ * After a turn's primary `result` lands, {@link chatSession} returns and drops
142
+ * its {@link liveSessions} entry — but the session itself may live on, held
143
+ * open by the reaper because the turn launched background work, streaming
144
+ * autonomous re-invocation turns. Those turns are real, running, and shown as
145
+ * such; until now they were also uncancellable, because nothing registered
146
+ * them anywhere Stop could reach.
147
+ *
148
+ * `interrupt()` would be the wrong tool even if it could: it targets an
149
+ * in-flight model turn, and the wedge case is a session sitting idle holding
150
+ * background work that will never finish. So these route to a force-reap
151
+ * instead — end the session, let the stream end, and let the ordinary unwind
152
+ * emit `chat:complete`. Entries are removed when the background stream ends.
153
+ */
154
+ private backgroundTurns;
137
155
  /**
138
156
  * Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
139
157
  * (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
@@ -147,6 +165,14 @@ export declare class HerdctlService {
147
165
  private jobsIndexOrNull;
148
166
  private get jobsIndex();
149
167
  constructor(cfg: PaddockConfig);
168
+ /**
169
+ * The Claude home every transcript symlink is planted in, plus whether paddock
170
+ * owns it (#620). One accessor, so no call site can quietly take a default and
171
+ * land in a different home than the FleetManager was built with — the bug
172
+ * `ensureSweeperHome` had, which put the sweeper's symlink in `~/.claude` while
173
+ * every other agent's went to the configured home.
174
+ */
175
+ private get claudeHomeTarget();
150
176
  /**
151
177
  * The environment system prompt to append to a keeper turn (issue #635), or
152
178
  * `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
@@ -325,12 +351,61 @@ export declare class HerdctlService {
325
351
  */
326
352
  adoptChats(project: Project, opts?: {
327
353
  sourceCwd?: string;
354
+ /**
355
+ * Import only these sessions (#660). Anything else the engine adopts along
356
+ * the way is released again, exactly as a noise-filtered session is — see
357
+ * the "honouring the noise filter" note above, which this reuses wholesale.
358
+ * Undefined means "everything on offer", which is what a confirmed
359
+ * select-all sends and what the CLI has always sent.
360
+ */
361
+ sessionIds?: string[];
328
362
  mode?: AdoptionPlacementMode;
329
363
  dryRun?: boolean;
330
364
  }): Promise<{
331
365
  adopted: string[];
332
366
  skipped: AdoptSkipped[];
333
367
  }>;
368
+ /**
369
+ * Did an import of `sessionId` from `fromWorkingDir` place a NEW file?
370
+ *
371
+ * The same test `releaseWithheld` makes before deleting anything: an in-place
372
+ * adoption resolves the destination and the origin to one path, so there is no
373
+ * copy and nothing that may be removed.
374
+ */
375
+ private placedACopy;
376
+ /**
377
+ * What the LAST import into each workspace did, so it can be undone (#660).
378
+ *
379
+ * Keyed by workspace key (which may be `""` — the root). Holds the sessions
380
+ * that import actually brought in, and of those, the ones where it PLACED a
381
+ * copy and may therefore delete one again.
382
+ *
383
+ * Server-side rather than a token the client hands back: undo deletes files, so
384
+ * the set of paths it may touch must come from what this process actually did,
385
+ * not from a request body. A restart forgets it, which is the right failure —
386
+ * the offer to undo is a transient toast, not a durable feature.
387
+ *
388
+ * Only the most recent import per workspace is kept. Undoing an import two
389
+ * imports ago is not offered and would be a lie to describe as "undo".
390
+ */
391
+ private readonly lastImport;
392
+ /**
393
+ * Undo the import this process most recently performed into `project` (#660).
394
+ *
395
+ * Releases each adoption and deletes the copies that import placed, leaving the
396
+ * user's own `~/.claude` history exactly as it was. Returns the sessions it
397
+ * released; an empty array means there was nothing to undo (nothing imported
398
+ * yet, already undone, or the server restarted since).
399
+ *
400
+ * `sessionIds` narrows it to part of that import — the rest stays. Ids that
401
+ * were not part of it are ignored rather than rejected: undo is a best-effort
402
+ * safety net, and the alternative is a 400 for a button the user pressed once.
403
+ */
404
+ unadoptChats(project: Project, opts?: {
405
+ sessionIds?: string[];
406
+ }): Promise<{
407
+ released: string[];
408
+ }>;
334
409
  /**
335
410
  * Undo the adoption of a session detection had withheld, restoring the state
336
411
  * that existed before this import. See {@link adoptChats} for why the file
@@ -444,19 +519,39 @@ export declare class HerdctlService {
444
519
  * compacts the real chat history. SDK messages stream via `onMessage` (same
445
520
  * shape as {@link chat}); resolves when the turn completes, then closes the
446
521
  * session. Returns the resolved session id.
522
+ *
523
+ * Like {@link chatSession}, the session is registered in {@link liveSessions}
524
+ * under a synthetic turn id handed back via `onJobCreated`, so Stop can
525
+ * interrupt a long-running command (#632). The session is still NOT managed by
526
+ * the reaper — teardown remains this method's own `finally`.
447
527
  */
448
528
  runCommand(agentName: string, opts: {
449
529
  command: string;
450
530
  resume?: string | null;
451
531
  onMessage?: (msg: SDKMessage) => void | Promise<void>;
532
+ /** Receives the synthetic turn id Stop cancels this command turn by (#632). */
533
+ onJobCreated?: (jobId: string) => void;
452
534
  }): Promise<{
453
535
  sessionId: string | null;
454
536
  }>;
537
+ /**
538
+ * Register a background-phase turn so Stop can reach it (paddock#528). Called
539
+ * by the background turn sink once it knows both the synthetic job id it
540
+ * published and the session that stream belongs to. Re-registering the same
541
+ * job id with a later session id is fine — the sink refreshes the mapping if
542
+ * the stream resolves a different session.
543
+ */
544
+ registerBackgroundTurn(jobId: string, sessionId: string): void;
545
+ /** Forget a background-phase turn once its stream has ended. */
546
+ unregisterBackgroundTurn(jobId: string): void;
455
547
  /**
456
548
  * Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
457
549
  * modes off the single id the client holds as `jobId`:
458
550
  * - **session mode** — the id is a synthetic turn id in {@link liveSessions};
459
551
  * interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
552
+ * - **background phase** — the id is a synthetic id in
553
+ * {@link backgroundTurns}; there is no model turn to interrupt, so reap the
554
+ * session (paddock#528).
460
555
  * - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
461
556
  * (which kills the CLI subprocess / aborts the SDK query).
462
557
  *
@@ -191,6 +191,24 @@ export class HerdctlService {
191
191
  * {@link cancel}). Entries are removed when the turn's stream ends.
192
192
  */
193
193
  liveSessions = new Map();
194
+ /**
195
+ * Background-phase turns keyed by the synthetic job id the sink minted for
196
+ * them, valued by the chat session they belong to (paddock#528).
197
+ *
198
+ * After a turn's primary `result` lands, {@link chatSession} returns and drops
199
+ * its {@link liveSessions} entry — but the session itself may live on, held
200
+ * open by the reaper because the turn launched background work, streaming
201
+ * autonomous re-invocation turns. Those turns are real, running, and shown as
202
+ * such; until now they were also uncancellable, because nothing registered
203
+ * them anywhere Stop could reach.
204
+ *
205
+ * `interrupt()` would be the wrong tool even if it could: it targets an
206
+ * in-flight model turn, and the wedge case is a session sitting idle holding
207
+ * background work that will never finish. So these route to a force-reap
208
+ * instead — end the session, let the stream end, and let the ordinary unwind
209
+ * emit `chat:complete`. Entries are removed when the background stream ends.
210
+ */
211
+ backgroundTurns = new Map();
194
212
  /**
195
213
  * Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
196
214
  * (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
@@ -208,6 +226,16 @@ export class HerdctlService {
208
226
  constructor(cfg) {
209
227
  this.cfg = cfg;
210
228
  }
229
+ /**
230
+ * The Claude home every transcript symlink is planted in, plus whether paddock
231
+ * owns it (#620). One accessor, so no call site can quietly take a default and
232
+ * land in a different home than the FleetManager was built with — the bug
233
+ * `ensureSweeperHome` had, which put the sweeper's symlink in `~/.claude` while
234
+ * every other agent's went to the configured home.
235
+ */
236
+ get claudeHomeTarget() {
237
+ return { path: this.cfg.claudeHome, owned: this.cfg.ownsClaudeHome };
238
+ }
211
239
  /**
212
240
  * The environment system prompt to append to a keeper turn (issue #635), or
213
241
  * `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
@@ -297,7 +325,7 @@ export class HerdctlService {
297
325
  // at the .chats store in the metadata dir — so repo-backed transcripts stay
298
326
  // out of the external checkout's working tree (issue #187). For a notebook
299
327
  // project workingDir === dir, so this is the classic behavior.
300
- await ensureProjectChats(project.workingDir, project.dir, this.cfg.claudeHome);
328
+ await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
301
329
  await this.ensureSweeperHome(project);
302
330
  await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
303
331
  await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
@@ -371,7 +399,7 @@ export class HerdctlService {
371
399
  async ensureProjectAgent(project) {
372
400
  if (!this.fleet)
373
401
  return;
374
- await ensureProjectChats(project.workingDir, project.dir, this.cfg.claudeHome);
402
+ await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
375
403
  await this.ensureSweeperHome(project);
376
404
  await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
377
405
  await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
@@ -472,7 +500,7 @@ export class HerdctlService {
472
500
  */
473
501
  adoptableIndexOrNull = null;
474
502
  get adoptableIndex() {
475
- return (this.adoptableIndexOrNull ??= new AdoptableIndex(this.cfg.claudeHome, this.cfg.stateDir));
503
+ return (this.adoptableIndexOrNull ??= new AdoptableIndex(this.cfg.claudeHome, this.cfg.stateDir, this.cfg.legacyClaudeHome));
476
504
  }
477
505
  /** What `project` could import right now (see `./adoptable.ts`). */
478
506
  async listAdoptable(project) {
@@ -523,12 +551,51 @@ export class HerdctlService {
523
551
  const mode = opts.mode ?? "copy";
524
552
  const dryRun = opts.dryRun ?? false;
525
553
  const summary = await this.listAdoptable(project);
554
+ // Translate the DISPLAY path a caller sends back into the path the engine
555
+ // actually reads that source through (#620). They differ for the user's own
556
+ // `~/.claude` history, which is reached via a mirror named for a synthetic
557
+ // path — handing the engine the recorded cwd would send it to the project's
558
+ // OWN transcript folder and import nothing.
559
+ //
560
+ // Filter rather than find: post-#620 the user's `~/.claude` folder for a cwd
561
+ // and the project's own `.chats/` are two different directories that both
562
+ // display as that cwd, so one display path can legitimately name two sources.
563
+ const engineDirs = (displayCwd) => {
564
+ const matched = summary.sources
565
+ .filter((s) => s.sourceCwd === displayCwd)
566
+ .map((s) => s.importFrom ?? s.sourceCwd);
567
+ // An unmatched path is one the route already validated as the project's own
568
+ // working directory; pass it straight through, as before.
569
+ return matched.length > 0 ? matched : [displayCwd];
570
+ };
526
571
  const sources = opts.sourceCwd !== undefined
527
- ? [opts.sourceCwd]
528
- : summary.sources.map((s) => s.sourceCwd);
572
+ ? engineDirs(opts.sourceCwd)
573
+ : summary.sources.map((s) => s.importFrom ?? s.sourceCwd);
529
574
  // sessionId -> why detection withheld it.
530
575
  const withheld = new Map(summary.filtered.map((f) => [f.sessionId, f.reason]));
576
+ // A selection narrows the offer the same way the noise filter does, and for
577
+ // the same reason: the engine adopts a whole SOURCE, so anything the user did
578
+ // not tick has to be released again afterwards. Tracked separately from
579
+ // `withheld` because a session the user simply did not choose is not a
580
+ // "skip" worth reporting back — it was never part of this import, and listing
581
+ // 23 of them would read as an import that half failed.
582
+ const deselected = opts.sessionIds === undefined
583
+ ? null
584
+ : (() => {
585
+ const wanted = new Set(opts.sessionIds);
586
+ const out = new Set();
587
+ for (const source of summary.sources) {
588
+ for (const id of source.sessionIds)
589
+ if (!wanted.has(id))
590
+ out.add(id);
591
+ }
592
+ return out;
593
+ })();
531
594
  const adopted = [];
595
+ // Of `adopted`, the ones where this call PLACED a file — the only ones undo
596
+ // may ever delete. Computed here, where `fromWorkingDir` is still known;
597
+ // afterwards nothing can tell a copy from an in-place adoption.
598
+ const copies = [];
532
599
  const skipped = [];
533
600
  for (const fromWorkingDir of sources) {
534
601
  // Sequential, not parallel: each source re-scans the adoption store, so a
@@ -542,8 +609,20 @@ export class HerdctlService {
542
609
  skipped.push(...result.skipped);
543
610
  for (const sessionId of result.adopted) {
544
611
  const reason = withheld.get(sessionId);
612
+ // Not chosen: release it, and say nothing about it. Checked BEFORE the
613
+ // noise filter so a deselected session stays silent even if it would
614
+ // also have been withheld.
615
+ if (deselected?.has(sessionId) === true) {
616
+ if (!dryRun) {
617
+ await this.releaseWithheld(agent, project, fromWorkingDir, sessionId, mode);
618
+ }
619
+ continue;
620
+ }
545
621
  if (reason === undefined) {
546
622
  adopted.push(sessionId);
623
+ if (mode === "copy" && !dryRun && this.placedACopy(project, fromWorkingDir, sessionId)) {
624
+ copies.push(sessionId);
625
+ }
547
626
  continue;
548
627
  }
549
628
  skipped.push({ sessionId, reason });
@@ -558,6 +637,8 @@ export class HerdctlService {
558
637
  this.invalidateSessions(agent);
559
638
  this.jobsIndexOrNull?.invalidate();
560
639
  this.invalidateAdoptable(project.slug);
640
+ // Replaces any earlier import: only the most recent one is undoable.
641
+ this.lastImport.set(project.slug, { adopted: [...adopted], copies });
561
642
  }
562
643
  // A session adopted from one source and then met again in the NEXT source
563
644
  // (its fresh copy now sits in the project's own transcript folder) comes back
@@ -567,6 +648,81 @@ export class HerdctlService {
567
648
  const done = new Set(adopted);
568
649
  return { adopted, skipped: skipped.filter((s) => !done.has(s.sessionId)) };
569
650
  }
651
+ /**
652
+ * Did an import of `sessionId` from `fromWorkingDir` place a NEW file?
653
+ *
654
+ * The same test `releaseWithheld` makes before deleting anything: an in-place
655
+ * adoption resolves the destination and the origin to one path, so there is no
656
+ * copy and nothing that may be removed.
657
+ */
658
+ placedACopy(project, fromWorkingDir, sessionId) {
659
+ const home = this.cfg.claudeHome;
660
+ const placed = getCliSessionFile(project.workingDir, sessionId, home);
661
+ const origin = getCliSessionFile(fromWorkingDir, sessionId, home);
662
+ return path.resolve(placed) !== path.resolve(origin);
663
+ }
664
+ /**
665
+ * What the LAST import into each workspace did, so it can be undone (#660).
666
+ *
667
+ * Keyed by workspace key (which may be `""` — the root). Holds the sessions
668
+ * that import actually brought in, and of those, the ones where it PLACED a
669
+ * copy and may therefore delete one again.
670
+ *
671
+ * Server-side rather than a token the client hands back: undo deletes files, so
672
+ * the set of paths it may touch must come from what this process actually did,
673
+ * not from a request body. A restart forgets it, which is the right failure —
674
+ * the offer to undo is a transient toast, not a durable feature.
675
+ *
676
+ * Only the most recent import per workspace is kept. Undoing an import two
677
+ * imports ago is not offered and would be a lie to describe as "undo".
678
+ */
679
+ lastImport = new Map();
680
+ /**
681
+ * Undo the import this process most recently performed into `project` (#660).
682
+ *
683
+ * Releases each adoption and deletes the copies that import placed, leaving the
684
+ * user's own `~/.claude` history exactly as it was. Returns the sessions it
685
+ * released; an empty array means there was nothing to undo (nothing imported
686
+ * yet, already undone, or the server restarted since).
687
+ *
688
+ * `sessionIds` narrows it to part of that import — the rest stays. Ids that
689
+ * were not part of it are ignored rather than rejected: undo is a best-effort
690
+ * safety net, and the alternative is a 400 for a button the user pressed once.
691
+ */
692
+ async unadoptChats(project, opts = {}) {
693
+ const agent = keeperAgentName(project.slug);
694
+ const last = this.lastImport.get(project.slug);
695
+ if (last === undefined)
696
+ return { released: [] };
697
+ const wanted = opts.sessionIds === undefined ? null : new Set(opts.sessionIds);
698
+ const target = last.adopted.filter((id) => wanted === null || wanted.has(id));
699
+ if (target.length === 0)
700
+ return { released: [] };
701
+ const copies = new Set(last.copies);
702
+ const released = [];
703
+ for (const sessionId of target) {
704
+ await this.manager.unadoptSession(agent, sessionId).catch(() => undefined);
705
+ // Delete ONLY a file this import placed. A session adopted in place has no
706
+ // copy, and its transcript is the user's own — removing it would destroy
707
+ // history rather than restore it.
708
+ if (copies.has(sessionId)) {
709
+ const placed = getCliSessionFile(project.workingDir, sessionId, this.cfg.claudeHome);
710
+ await fs.rm(placed, { force: true }).catch(() => undefined);
711
+ }
712
+ released.push(sessionId);
713
+ }
714
+ // Consume what was undone, so a second undo is a no-op rather than a repeat
715
+ // attempt to delete files that are already gone.
716
+ const remaining = last.adopted.filter((id) => !released.includes(id));
717
+ if (remaining.length === 0)
718
+ this.lastImport.delete(project.slug);
719
+ else
720
+ this.lastImport.set(project.slug, { adopted: remaining, copies: last.copies });
721
+ this.invalidateSessions(agent);
722
+ this.jobsIndexOrNull?.invalidate();
723
+ this.invalidateAdoptable(project.slug);
724
+ return { released };
725
+ }
570
726
  /**
571
727
  * Undo the adoption of a session detection had withheld, restoring the state
572
728
  * that existed before this import. See {@link adoptChats} for why the file
@@ -832,9 +988,15 @@ export class HerdctlService {
832
988
  * compacts the real chat history. SDK messages stream via `onMessage` (same
833
989
  * shape as {@link chat}); resolves when the turn completes, then closes the
834
990
  * session. Returns the resolved session id.
991
+ *
992
+ * Like {@link chatSession}, the session is registered in {@link liveSessions}
993
+ * under a synthetic turn id handed back via `onJobCreated`, so Stop can
994
+ * interrupt a long-running command (#632). The session is still NOT managed by
995
+ * the reaper — teardown remains this method's own `finally`.
835
996
  */
836
997
  async runCommand(agentName, opts) {
837
998
  const isResume = typeof opts.resume === "string";
999
+ const turnId = randomUUID();
838
1000
  const session = await this.manager.openChatSession(agentName, {
839
1001
  resume: opts.resume,
840
1002
  // Stream the command's assistant text token-by-token (paddock#315).
@@ -843,6 +1005,14 @@ export class HerdctlService {
843
1005
  // turn, so it gets the same environment context (#635).
844
1006
  systemPromptAppend: this.environmentPromptAppend,
845
1007
  });
1008
+ // Register the live session under the synthetic turn id exactly as
1009
+ // {@link chatSession} does, so `chat:cancel` routes to `session.interrupt()`
1010
+ // (#632) — without this there is nothing for {@link cancel} to resolve and
1011
+ // Stop is a no-op for the whole command. Removed in the same `finally` that
1012
+ // closes the session, so the entry never outlives the turn.
1013
+ this.liveSessions.set(turnId, session);
1014
+ // Surface the synthetic id as the turn's jobId so the client renders Stop.
1015
+ opts.onJobCreated?.(turnId);
846
1016
  let sessionId = isResume ? opts.resume : null;
847
1017
  try {
848
1018
  // Gap A parity: BOTH branches consume via the non-closing consumeResumedTurn
@@ -873,15 +1043,35 @@ export class HerdctlService {
873
1043
  // Stream error — fall through and let the caller surface completion.
874
1044
  }
875
1045
  finally {
1046
+ // Deregister BEFORE closing, so a Stop racing teardown can't reach a
1047
+ // session that is already shutting down.
1048
+ this.liveSessions.delete(turnId);
876
1049
  await session.close();
877
1050
  }
878
1051
  return { sessionId };
879
1052
  }
1053
+ /**
1054
+ * Register a background-phase turn so Stop can reach it (paddock#528). Called
1055
+ * by the background turn sink once it knows both the synthetic job id it
1056
+ * published and the session that stream belongs to. Re-registering the same
1057
+ * job id with a later session id is fine — the sink refreshes the mapping if
1058
+ * the stream resolves a different session.
1059
+ */
1060
+ registerBackgroundTurn(jobId, sessionId) {
1061
+ this.backgroundTurns.set(jobId, sessionId);
1062
+ }
1063
+ /** Forget a background-phase turn once its stream has ended. */
1064
+ unregisterBackgroundTurn(jobId) {
1065
+ this.backgroundTurns.delete(jobId);
1066
+ }
880
1067
  /**
881
1068
  * Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
882
1069
  * modes off the single id the client holds as `jobId`:
883
1070
  * - **session mode** — the id is a synthetic turn id in {@link liveSessions};
884
1071
  * interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
1072
+ * - **background phase** — the id is a synthetic id in
1073
+ * {@link backgroundTurns}; there is no model turn to interrupt, so reap the
1074
+ * session (paddock#528).
885
1075
  * - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
886
1076
  * (which kills the CLI subprocess / aborts the SDK query).
887
1077
  *
@@ -901,6 +1091,19 @@ export class HerdctlService {
901
1091
  return false;
902
1092
  }
903
1093
  }
1094
+ // Background phase: the primary turn is over and its liveSessions entry is
1095
+ // gone, but the session is still open streaming autonomous re-invocation
1096
+ // turns. Stop here means "end this session" — reaping reaches even a session
1097
+ // the reap policy would hold open forever, and ending the stream is what
1098
+ // drives the sink's onDone → chat:complete → the UI unlocking.
1099
+ const backgroundSession = this.backgroundTurns.get(jobId);
1100
+ if (backgroundSession !== undefined) {
1101
+ const reaped = this.manager.reapChatSession(backgroundSession);
1102
+ if (!reaped) {
1103
+ console.warn(`[herdctl] no live managed session ${backgroundSession} to reap for background turn ${jobId}`);
1104
+ }
1105
+ return reaped;
1106
+ }
904
1107
  try {
905
1108
  await this.manager.cancelJob(jobId);
906
1109
  return true;
@@ -1268,7 +1471,7 @@ export class HerdctlService {
1268
1471
  */
1269
1472
  async ensureSweeperHome(project) {
1270
1473
  const dir = sweeperWorkingDir(this.cfg, project.slug);
1271
- await ensureProjectChats(dir, dir);
1474
+ await ensureProjectChats(dir, dir, this.claudeHomeTarget);
1272
1475
  }
1273
1476
  triggerAgentConfig(project, triggerName, trigger) {
1274
1477
  return buildTriggerConfig(this.cfg, project, triggerName, trigger);
@@ -37,17 +37,17 @@ export declare const HOOK_EVENTS: ReadonlySet<HookEvent>;
37
37
  export type HookPermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan";
38
38
  /**
39
39
  * A hook's capability set (GG-1) — projected verbatim onto the hook's OWN herdctl
40
- * agent's tool config, so the registered agent enforces exactly these tools. An
41
- * absent/empty {@link HookCapabilities.allowedTools} is a **tool-less** hook (it can
42
- * only think + return text); granting `Bash` lets it spin down servers / delete
43
- * clones itself. This is intentionally the whole capability model — there is no
44
- * higher-level profile.
40
+ * agent's tool config, so the registered agent carries exactly these tools. An
41
+ * absent/empty {@link HookCapabilities.allowedTools} DECLARES a **tool-less** hook;
42
+ * granting `Bash` lets it spin down servers / delete clones itself. This is
43
+ * intentionally the whole capability model — there is no higher-level profile.
45
44
  */
46
45
  export interface HookCapabilities {
47
46
  /**
48
- * The tools the hook agent may use (herdctl `allowed_tools`). Omit or `[]` for a
49
- * tool-less hook. The CLI runtime auto-denies any tool NOT on this list, so this
50
- * is the effective grant.
47
+ * The tools the hook agent may use (herdctl `allowed_tools`). Omit or `[]` to
48
+ * declare a tool-less hook — but note herdctl emits the allow-list only when it
49
+ * is NON-empty, so `[]` is not enforced as a deny-all and the agent falls back to
50
+ * Claude Code's default tools (#647; enforcement itself is #319).
51
51
  */
52
52
  allowedTools?: string[];
53
53
  /** Tools explicitly denied even if otherwise allowed (herdctl `denied_tools`). */
@@ -120,7 +120,7 @@ export interface ChatHookInfo {
120
120
  agentName: string;
121
121
  /** Whether the hook is armed (a disabled hook's past chats are still shown). */
122
122
  enabled: boolean;
123
- /** The exact tool grant (herdctl `allowed_tools`); `[]` = a tool-less hook. */
123
+ /** The declared tool grant (herdctl `allowed_tools`); `[]` = none, unenforced (#647). */
124
124
  allowedTools: string[];
125
125
  /** Tools explicitly denied even if otherwise allowed, when the hook sets any. */
126
126
  deniedTools?: string[];
@@ -207,11 +207,13 @@ export declare function sanitizeHook(raw: unknown): PaddockHook | null;
207
207
  export declare function sanitizeHooks(raw: unknown): Record<string, PaddockHook> | undefined;
208
208
  /**
209
209
  * Project a hook's {@link HookCapabilities} onto the exact herdctl agent tool-config
210
- * fields (snake_case), so the registered `hook-<slug>-<name>` agent enforces the
211
- * capability BY CONSTRUCTION. A tool-less hook yields `allowed_tools: []` (the CLI
212
- * runtime then denies every tool). Always sets `allowed_tools` + `max_turns` so a
213
- * hook agent never silently inherits the keeper's broad default toolset; the other
214
- * fields are set only when the capability specifies them (else the fleet defaults
215
- * apply). This is the ONE place capability→config translation lives.
210
+ * fields (snake_case), so the registered `hook-<slug>-<name>` agent carries the
211
+ * capability BY CONSTRUCTION. A hook that declares no tools yields
212
+ * `allowed_tools: []`, which herdctl does NOT enforce as a deny-all — the same
213
+ * caveat spelled out on `triggerToAgentToolConfig` in `trigger-config.ts` (#647).
214
+ * Always sets `allowed_tools` + `max_turns` so a hook agent never silently inherits
215
+ * the keeper's broad default toolset; the other fields are set only when the
216
+ * capability specifies them (else the fleet defaults apply). This is the ONE place
217
+ * capability→config translation lives.
216
218
  */
217
219
  export declare function hookToAgentToolConfig(caps: HookCapabilities | undefined): Record<string, unknown>;
@@ -199,18 +199,20 @@ export function sanitizeHooks(raw) {
199
199
  }
200
200
  /**
201
201
  * Project a hook's {@link HookCapabilities} onto the exact herdctl agent tool-config
202
- * fields (snake_case), so the registered `hook-<slug>-<name>` agent enforces the
203
- * capability BY CONSTRUCTION. A tool-less hook yields `allowed_tools: []` (the CLI
204
- * runtime then denies every tool). Always sets `allowed_tools` + `max_turns` so a
205
- * hook agent never silently inherits the keeper's broad default toolset; the other
206
- * fields are set only when the capability specifies them (else the fleet defaults
207
- * apply). This is the ONE place capability→config translation lives.
202
+ * fields (snake_case), so the registered `hook-<slug>-<name>` agent carries the
203
+ * capability BY CONSTRUCTION. A hook that declares no tools yields
204
+ * `allowed_tools: []`, which herdctl does NOT enforce as a deny-all — the same
205
+ * caveat spelled out on `triggerToAgentToolConfig` in `trigger-config.ts` (#647).
206
+ * Always sets `allowed_tools` + `max_turns` so a hook agent never silently inherits
207
+ * the keeper's broad default toolset; the other fields are set only when the
208
+ * capability specifies them (else the fleet defaults apply). This is the ONE place
209
+ * capability→config translation lives.
208
210
  */
209
211
  export function hookToAgentToolConfig(caps) {
210
212
  const out = {
211
- // A hook's grant is exactly its allowedTools — default to NONE (tool-less), never
212
- // the keeper's broad inherited allowlist. An empty list = the CLI runtime denies
213
- // all tools, which is the correct "no capability" semantics.
213
+ // A hook's grant is exactly its allowedTools — default to NONE, never the
214
+ // keeper's broad inherited allowlist. An empty list expresses "no capability",
215
+ // though herdctl never emits it, so it is not enforced as one (#647).
214
216
  allowed_tools: caps?.allowedTools ?? [],
215
217
  max_turns: caps?.maxTurns ?? HOOK_DEFAULT_MAX_TURNS,
216
218
  };