@edspencer/paddock 0.60.0 → 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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -1
  3. package/package.json +3 -2
  4. package/packages/server/dist/adoptable.d.ts +36 -2
  5. package/packages/server/dist/adoptable.js +51 -11
  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 +15 -0
  16. package/packages/server/dist/herdctl.js +50 -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 +33 -6
  20. package/packages/server/dist/transcripts.d.ts +25 -6
  21. package/packages/server/dist/transcripts.js +12 -24
  22. package/packages/server/dist/trigger-config.d.ts +18 -8
  23. package/packages/server/dist/trigger-config.js +18 -8
  24. package/packages/server/dist/ws.js +43 -11
  25. package/packages/server/package.json +1 -1
  26. package/packages/web/dist/assets/{InstanceConfigPage-xn9Rvoah.js → InstanceConfigPage-CzMqTdUU.js} +1 -1
  27. package/packages/web/dist/assets/{MarkdownRenderer-DMONssKA.js → MarkdownRenderer-YSEZHWDe.js} +1 -1
  28. package/packages/web/dist/assets/{ProjectRedirect-DHoWtvtY.js → ProjectRedirect-DLWxZbqK.js} +1 -1
  29. package/packages/web/dist/assets/ProjectView-BFX8ff2n.js +13 -0
  30. package/packages/web/dist/assets/{ProjectsGrid-CjfwpeFj.js → ProjectsGrid-DLOeY_ww.js} +1 -1
  31. package/packages/web/dist/assets/{arc-DdFdbD-c.js → arc-BwC1ik-b.js} +1 -1
  32. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DC8UKtwV.js → architectureDiagram-3BPJPVTR-CsVvXQWw.js} +1 -1
  33. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DqFFe_5c.js → blockDiagram-GPEHLZMM-DXQk2bIa.js} +1 -1
  34. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DbffFSM-.js → c4Diagram-AAUBKEIU-DXiA26It.js} +1 -1
  35. package/packages/web/dist/assets/channel-DDgTXrGP.js +2 -0
  36. package/packages/web/dist/assets/{chunk-2J33WTMH-8PP2RHft.js → chunk-2J33WTMH-Dt5nC02A.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-4BX2VUAB-Hag68B5N.js → chunk-4BX2VUAB-DcjnWvl6.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-55IACEB6-BXp_gQIa.js → chunk-55IACEB6-CRa3LkU4.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-727SXJPM-DlntjQux.js → chunk-727SXJPM-H3V-11qu.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BhI9KPrE.js → chunk-AQP2D5EJ-DPCrENYV.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-FMBD7UC4-WhfORs2z.js → chunk-FMBD7UC4-C4UO0ZTc.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-ND2GUHAM-CIskmv20.js → chunk-ND2GUHAM-BtI37CnI.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-QZHKN3VN-CNOTpCW4.js → chunk-QZHKN3VN-C5bTaK5j.js} +1 -1
  44. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +2 -0
  45. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +2 -0
  46. package/packages/web/dist/assets/{core-usOVRkLM.js → core-DB3cpm5N.js} +1 -1
  47. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CniuMIIW.js → cose-bilkent-S5V4N54A-q-cjTZO4.js} +1 -1
  48. package/packages/web/dist/assets/{dagre-BM42HDAG-5CteYfQY.js → dagre-BM42HDAG-DYEV5HFo.js} +1 -1
  49. package/packages/web/dist/assets/{diagram-2AECGRRQ-BIQjD2LA.js → diagram-2AECGRRQ-D16XCw78.js} +1 -1
  50. package/packages/web/dist/assets/{diagram-5GNKFQAL-1vy3ErTt.js → diagram-5GNKFQAL-CeztryYE.js} +1 -1
  51. package/packages/web/dist/assets/{diagram-KO2AKTUF-D45iqFk6.js → diagram-KO2AKTUF-D5SLIMDS.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-LMA3HP47-DNktDktn.js → diagram-LMA3HP47-Bw-sVPxM.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-OG6HWLK6-BQ9JgV70.js → diagram-OG6HWLK6-DsRMITgx.js} +1 -1
  54. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-oBasZ1uv.js → erDiagram-TEJ5UH35-T8Qqc_ku.js} +1 -1
  55. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DxnJHMBS.js → flowDiagram-I6XJVG4X-Cyx47UWU.js} +1 -1
  56. package/packages/web/dist/assets/{format-CIAat6sU.js → format-D2I0duZa.js} +1 -1
  57. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CtJQ2m7z.js → ganttDiagram-6RSMTGT7-DxYJmG9o.js} +1 -1
  58. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-5RPscwD_.js → gitGraphDiagram-PVQCEYII-COK5YOTD.js} +1 -1
  59. package/packages/web/dist/assets/index-wBXQMkGI.js +71 -0
  60. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-zQu3xkW4.js → infoDiagram-5YYISTIA-BamRN_Nn.js} +1 -1
  61. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-ChoEjcdw.js → ishikawaDiagram-YF4QCWOH-DuOHdpEH.js} +1 -1
  62. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DM2KWmDr.js → journeyDiagram-JHISSGLW-Bri7GKTW.js} +1 -1
  63. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DWzTpYgh.js → kanban-definition-UN3LZRKU-BdkgUnzI.js} +1 -1
  64. package/packages/web/dist/assets/{linear-Bpy9Aj-K.js → linear-DOcNPCpB.js} +1 -1
  65. package/packages/web/dist/assets/{mermaid.core-D43x5bFb.js → mermaid.core-D9SbVBpN.js} +4 -4
  66. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BhceB-cv.js → mindmap-definition-RKZ34NQL-CVmLm7zn.js} +1 -1
  67. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BgcGlFFA.js → pieDiagram-4H26LBE5-D0N10eVU.js} +1 -1
  68. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ZRVaOoSp.js → quadrantDiagram-W4KKPZXB-B5gnYdTa.js} +1 -1
  69. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-2hHcJHBt.js → requirementDiagram-4Y6WPE33-CwtxezNZ.js} +1 -1
  70. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js → sankeyDiagram-5OEKKPKP-D79O3cux.js} +1 -1
  71. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-D4BXQSSM.js → sequenceDiagram-3UESZ5HK-xhKcI6tI.js} +1 -1
  72. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DftS7dIo.js → stateDiagram-AJRCARHV-DBYMAjcX.js} +1 -1
  73. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +2 -0
  74. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C0OAxyWY.js → timeline-definition-PNZ67QCA-e-MYF4lm.js} +1 -1
  75. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C94fINN3.js → vennDiagram-CIIHVFJN-B8UsvRDM.js} +1 -1
  76. package/packages/web/dist/assets/{wardley-L42UT6IY-CbFrlwHE.js → wardley-L42UT6IY-1ucACjTl.js} +1 -1
  77. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CHG2nndd.js → wardleyDiagram-YWT4CUSO-DJufLd_u.js} +1 -1
  78. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-U9ESc19z.js → xychartDiagram-2RQKCTM6-qqaZNlu9.js} +1 -1
  79. package/packages/web/dist/index.html +1 -1
  80. package/packages/web/dist/sw.js +1 -1
  81. package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +0 -13
  82. package/packages/web/dist/assets/channel-QxsJGL6F.js +0 -2
  83. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +0 -2
  84. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +0 -2
  85. package/packages/web/dist/assets/index-DtRblEY_.js +0 -71
  86. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.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 = {
@@ -165,6 +165,14 @@ export declare class HerdctlService {
165
165
  private jobsIndexOrNull;
166
166
  private get jobsIndex();
167
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();
168
176
  /**
169
177
  * The environment system prompt to append to a keeper turn (issue #635), or
170
178
  * `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
@@ -511,11 +519,18 @@ export declare class HerdctlService {
511
519
  * compacts the real chat history. SDK messages stream via `onMessage` (same
512
520
  * shape as {@link chat}); resolves when the turn completes, then closes the
513
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`.
514
527
  */
515
528
  runCommand(agentName: string, opts: {
516
529
  command: string;
517
530
  resume?: string | null;
518
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;
519
534
  }): Promise<{
520
535
  sessionId: string | null;
521
536
  }>;
@@ -226,6 +226,16 @@ export class HerdctlService {
226
226
  constructor(cfg) {
227
227
  this.cfg = cfg;
228
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
+ }
229
239
  /**
230
240
  * The environment system prompt to append to a keeper turn (issue #635), or
231
241
  * `undefined` when the instance opted out (`environmentPrompt: ""`) — herdctl
@@ -315,7 +325,7 @@ export class HerdctlService {
315
325
  // at the .chats store in the metadata dir — so repo-backed transcripts stay
316
326
  // out of the external checkout's working tree (issue #187). For a notebook
317
327
  // project workingDir === dir, so this is the classic behavior.
318
- await ensureProjectChats(project.workingDir, project.dir, this.cfg.claudeHome);
328
+ await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
319
329
  await this.ensureSweeperHome(project);
320
330
  await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
321
331
  await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
@@ -389,7 +399,7 @@ export class HerdctlService {
389
399
  async ensureProjectAgent(project) {
390
400
  if (!this.fleet)
391
401
  return;
392
- await ensureProjectChats(project.workingDir, project.dir, this.cfg.claudeHome);
402
+ await ensureProjectChats(project.workingDir, project.dir, this.claudeHomeTarget);
393
403
  await this.ensureSweeperHome(project);
394
404
  await this.fleet.addAgent(this.keeperAgentConfig(project), { replace: true });
395
405
  await this.fleet.addAgent(this.sweeperAgentConfig(project), { replace: true });
@@ -490,7 +500,7 @@ export class HerdctlService {
490
500
  */
491
501
  adoptableIndexOrNull = null;
492
502
  get adoptableIndex() {
493
- 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));
494
504
  }
495
505
  /** What `project` could import right now (see `./adoptable.ts`). */
496
506
  async listAdoptable(project) {
@@ -541,9 +551,26 @@ export class HerdctlService {
541
551
  const mode = opts.mode ?? "copy";
542
552
  const dryRun = opts.dryRun ?? false;
543
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
+ };
544
571
  const sources = opts.sourceCwd !== undefined
545
- ? [opts.sourceCwd]
546
- : summary.sources.map((s) => s.sourceCwd);
572
+ ? engineDirs(opts.sourceCwd)
573
+ : summary.sources.map((s) => s.importFrom ?? s.sourceCwd);
547
574
  // sessionId -> why detection withheld it.
548
575
  const withheld = new Map(summary.filtered.map((f) => [f.sessionId, f.reason]));
549
576
  // A selection narrows the offer the same way the noise filter does, and for
@@ -961,9 +988,15 @@ export class HerdctlService {
961
988
  * compacts the real chat history. SDK messages stream via `onMessage` (same
962
989
  * shape as {@link chat}); resolves when the turn completes, then closes the
963
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`.
964
996
  */
965
997
  async runCommand(agentName, opts) {
966
998
  const isResume = typeof opts.resume === "string";
999
+ const turnId = randomUUID();
967
1000
  const session = await this.manager.openChatSession(agentName, {
968
1001
  resume: opts.resume,
969
1002
  // Stream the command's assistant text token-by-token (paddock#315).
@@ -972,6 +1005,14 @@ export class HerdctlService {
972
1005
  // turn, so it gets the same environment context (#635).
973
1006
  systemPromptAppend: this.environmentPromptAppend,
974
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);
975
1016
  let sessionId = isResume ? opts.resume : null;
976
1017
  try {
977
1018
  // Gap A parity: BOTH branches consume via the non-closing consumeResumedTurn
@@ -1002,6 +1043,9 @@ export class HerdctlService {
1002
1043
  // Stream error — fall through and let the caller surface completion.
1003
1044
  }
1004
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);
1005
1049
  await session.close();
1006
1050
  }
1007
1051
  return { sessionId };
@@ -1427,7 +1471,7 @@ export class HerdctlService {
1427
1471
  */
1428
1472
  async ensureSweeperHome(project) {
1429
1473
  const dir = sweeperWorkingDir(this.cfg, project.slug);
1430
- await ensureProjectChats(dir, dir);
1474
+ await ensureProjectChats(dir, dir, this.claudeHomeTarget);
1431
1475
  }
1432
1476
  triggerAgentConfig(project, triggerName, trigger) {
1433
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
  };
@@ -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);
@@ -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);
@@ -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
  */