@edspencer/paddock 0.60.0 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -3
  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/agent-errors.d.ts +41 -0
  7. package/packages/server/dist/agent-errors.js +127 -0
  8. package/packages/server/dist/app.js +36 -0
  9. package/packages/server/dist/claude-home.d.ts +192 -0
  10. package/packages/server/dist/claude-home.js +387 -0
  11. package/packages/server/dist/cli/args.d.ts +9 -1
  12. package/packages/server/dist/cli/args.js +21 -5
  13. package/packages/server/dist/cli/claude-home-choice.d.ts +44 -0
  14. package/packages/server/dist/cli/claude-home-choice.js +108 -0
  15. package/packages/server/dist/cli/import-chats.js +9 -6
  16. package/packages/server/dist/cli/paddock.js +54 -33
  17. package/packages/server/dist/config.d.ts +75 -12
  18. package/packages/server/dist/config.js +58 -11
  19. package/packages/server/dist/herdctl-agent-config.d.ts +15 -7
  20. package/packages/server/dist/herdctl-agent-config.js +21 -9
  21. package/packages/server/dist/herdctl.d.ts +15 -0
  22. package/packages/server/dist/herdctl.js +50 -6
  23. package/packages/server/dist/hook-config.d.ts +17 -15
  24. package/packages/server/dist/hook-config.js +11 -9
  25. package/packages/server/dist/routes/chats.js +33 -6
  26. package/packages/server/dist/sweep.js +13 -1
  27. package/packages/server/dist/transcripts.d.ts +32 -6
  28. package/packages/server/dist/transcripts.js +30 -24
  29. package/packages/server/dist/trigger-config.d.ts +18 -8
  30. package/packages/server/dist/trigger-config.js +18 -8
  31. package/packages/server/dist/ws.js +43 -11
  32. package/packages/server/package.json +1 -1
  33. package/packages/web/dist/assets/{InstanceConfigPage-xn9Rvoah.js → InstanceConfigPage-BgNXqAox.js} +1 -1
  34. package/packages/web/dist/assets/{MarkdownRenderer-DMONssKA.js → MarkdownRenderer-DfD_UtqU.js} +1 -1
  35. package/packages/web/dist/assets/{ProjectRedirect-DHoWtvtY.js → ProjectRedirect-1EXdHXmO.js} +1 -1
  36. package/packages/web/dist/assets/ProjectView-DHb5vNUp.js +13 -0
  37. package/packages/web/dist/assets/{ProjectsGrid-CjfwpeFj.js → ProjectsGrid-Br-EhLZE.js} +1 -1
  38. package/packages/web/dist/assets/{arc-DdFdbD-c.js → arc-C2Jekujs.js} +1 -1
  39. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DC8UKtwV.js → architectureDiagram-3BPJPVTR-CflCA-em.js} +1 -1
  40. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DqFFe_5c.js → blockDiagram-GPEHLZMM-Ds7GLNnC.js} +1 -1
  41. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DbffFSM-.js → c4Diagram-AAUBKEIU-D4WyGO6I.js} +1 -1
  42. package/packages/web/dist/assets/channel-B1K_gwAH.js +2 -0
  43. package/packages/web/dist/assets/{chunk-2J33WTMH-8PP2RHft.js → chunk-2J33WTMH-tQunO_M5.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-4BX2VUAB-Hag68B5N.js → chunk-4BX2VUAB-DT5dOWd2.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-55IACEB6-BXp_gQIa.js → chunk-55IACEB6-CfTUqUEF.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-727SXJPM-DlntjQux.js → chunk-727SXJPM-sK5bOAA0.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BhI9KPrE.js → chunk-AQP2D5EJ-DcMgVe-P.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-FMBD7UC4-WhfORs2z.js → chunk-FMBD7UC4-In3h_-7v.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-ND2GUHAM-CIskmv20.js → chunk-ND2GUHAM-DuaIeRaC.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-QZHKN3VN-CNOTpCW4.js → chunk-QZHKN3VN-DeT5mR5T.js} +1 -1
  51. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +2 -0
  52. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +2 -0
  53. package/packages/web/dist/assets/{core-usOVRkLM.js → core-DgOgLvnM.js} +1 -1
  54. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CniuMIIW.js → cose-bilkent-S5V4N54A-CyO-rznJ.js} +1 -1
  55. package/packages/web/dist/assets/{dagre-BM42HDAG-5CteYfQY.js → dagre-BM42HDAG-B_9Ihzgn.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-2AECGRRQ-BIQjD2LA.js → diagram-2AECGRRQ-z5mvZ5YT.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-5GNKFQAL-1vy3ErTt.js → diagram-5GNKFQAL-Bsz0cgfn.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-KO2AKTUF-D45iqFk6.js → diagram-KO2AKTUF-B29k5f4q.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-LMA3HP47-DNktDktn.js → diagram-LMA3HP47-CFrwsHoI.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-OG6HWLK6-BQ9JgV70.js → diagram-OG6HWLK6-D9Sjc2HR.js} +1 -1
  61. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-oBasZ1uv.js → erDiagram-TEJ5UH35-C3vbkD7M.js} +1 -1
  62. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DxnJHMBS.js → flowDiagram-I6XJVG4X-DkMIgntd.js} +1 -1
  63. package/packages/web/dist/assets/{format-CIAat6sU.js → format-BFDqyFuw.js} +1 -1
  64. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CtJQ2m7z.js → ganttDiagram-6RSMTGT7-nvHanjpW.js} +1 -1
  65. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-5RPscwD_.js → gitGraphDiagram-PVQCEYII-DbCNfOu7.js} +1 -1
  66. package/packages/web/dist/assets/index-DGCs1YIO.js +71 -0
  67. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-zQu3xkW4.js → infoDiagram-5YYISTIA-aJjH9x3D.js} +1 -1
  68. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-ChoEjcdw.js → ishikawaDiagram-YF4QCWOH-DoifwNnZ.js} +1 -1
  69. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DM2KWmDr.js → journeyDiagram-JHISSGLW-Cz9kG9HN.js} +1 -1
  70. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DWzTpYgh.js → kanban-definition-UN3LZRKU-BoXnfr7A.js} +1 -1
  71. package/packages/web/dist/assets/{linear-Bpy9Aj-K.js → linear-BlTTO9yR.js} +1 -1
  72. package/packages/web/dist/assets/{mermaid.core-D43x5bFb.js → mermaid.core-YAG8eSPQ.js} +4 -4
  73. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BhceB-cv.js → mindmap-definition-RKZ34NQL-DtUBKfkr.js} +1 -1
  74. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BgcGlFFA.js → pieDiagram-4H26LBE5-B3Rwpmy0.js} +1 -1
  75. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ZRVaOoSp.js → quadrantDiagram-W4KKPZXB-B8eJtTyk.js} +1 -1
  76. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-2hHcJHBt.js → requirementDiagram-4Y6WPE33-CpAluiY9.js} +1 -1
  77. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js → sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js} +1 -1
  78. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-D4BXQSSM.js → sequenceDiagram-3UESZ5HK-CRpqKSGm.js} +1 -1
  79. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DftS7dIo.js → stateDiagram-AJRCARHV-h3h8gXv_.js} +1 -1
  80. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +2 -0
  81. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C0OAxyWY.js → timeline-definition-PNZ67QCA-PVr5V_pG.js} +1 -1
  82. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C94fINN3.js → vennDiagram-CIIHVFJN-De-4NPAX.js} +1 -1
  83. package/packages/web/dist/assets/{wardley-L42UT6IY-CbFrlwHE.js → wardley-L42UT6IY-Dtv10D2d.js} +1 -1
  84. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CHG2nndd.js → wardleyDiagram-YWT4CUSO-p2fJuQ6C.js} +1 -1
  85. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-U9ESc19z.js → xychartDiagram-2RQKCTM6-DyYF-k92.js} +1 -1
  86. package/packages/web/dist/index.html +1 -1
  87. package/packages/web/dist/sw.js +1 -1
  88. package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +0 -13
  89. package/packages/web/dist/assets/channel-QxsJGL6F.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +0 -2
  91. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +0 -2
  92. package/packages/web/dist/assets/index-DtRblEY_.js +0 -71
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +0 -2
@@ -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);
@@ -38,6 +38,7 @@
38
38
  import { promises as fs } from "node:fs";
39
39
  import path from "node:path";
40
40
  import { keeperAgentName } from "./herdctl.js";
41
+ import { classifyAgentError } from "./agent-errors.js";
41
42
  import { DEFAULT_CURATION, resolveCurationConfig, } from "./curation-config.js";
42
43
  import { curatorTriggerOf, triggerPromptFileAbsPath, } from "./trigger-config.js";
43
44
  const STATE_FILE = "sweep-state.json";
@@ -204,7 +205,18 @@ export class SweepService {
204
205
  lastSweptSessionMtime: wm.lastSweptSessionMtime,
205
206
  lastSweptAt: Date.now(),
206
207
  });
207
- this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
208
+ // #684: this is a best-effort background task whose failure is already
209
+ // labelled non-fatal, so a recognised cause gets ONE line at warn — no
210
+ // `err`, because pino serialising it is what put four copies of the
211
+ // curator system prompt on a first-time user's terminal. An unrecognised
212
+ // failure keeps the full object: that is the case somebody needs it.
213
+ const cause = classifyAgentError(err);
214
+ if (cause === undefined) {
215
+ this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
216
+ }
217
+ else {
218
+ this.log.warn({ slug }, `sweep: skipped, ${cause} (non-fatal — chat is unaffected)`);
219
+ }
208
220
  }
209
221
  finally {
210
222
  this.running.delete(slug);
@@ -21,13 +21,39 @@ 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 EVERY write into the home (#620, #682). Two of them:
35
+ *
36
+ * - Migrating an existing real transcript directory into `.chats/` copies the
37
+ * files and then REMOVES the originals. That is correct self-healing inside a
38
+ * home paddock owns, where such a directory can only be paddock's own doing.
39
+ * Inside the user's `~/.claude` it is somebody else's data — a `claude` CLI
40
+ * session run in a directory paddock happens to also manage — so we leave it
41
+ * strictly alone and let the SDK keep writing there.
42
+ * - Planting the redirect symlink at a path that does not exist yet. Not
43
+ * destructive on the day it happens, which is why it was missed, but it
44
+ * silently claims where the user's FUTURE sessions for that directory get
45
+ * written (#682). See the branch itself for the full argument.
46
+ *
47
+ * Either way those transcripts stay importable through adoption (#588), which is
48
+ * the deliberate, user-driven version of the same move.
29
49
  */
30
- export declare function ensureProjectChats(workingDir: string, chatsHostDir?: string, claudeHomePath?: string): Promise<void>;
50
+ export interface ClaudeHomeTarget {
51
+ /** Absolute path to the Claude home to plant the redirect symlink in. */
52
+ path: string;
53
+ /** Whether paddock owns this home — see `PaddockConfig.ownsClaudeHome`. */
54
+ owned: boolean;
55
+ }
56
+ export declare function ensureProjectChats(workingDir: string, chatsHostDir: string, home: ClaudeHomeTarget): Promise<void>;
31
57
  /**
32
58
  * Read a session's FIRST user message text, untruncated, straight from its
33
59
  * 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)) {
@@ -88,6 +76,24 @@ export async function ensureProjectChats(workingDir, chatsHostDir = workingDir,
88
76
  await fs.symlink(chatsDir, encoded);
89
77
  return;
90
78
  }
79
+ // Nothing there yet. In a home paddock does NOT own, planting here is the
80
+ // data-loss branch (#682): the encoded path is where the USER's future
81
+ // `claude` sessions for this directory land, so a link redirects every one of
82
+ // them into paddock's `.chats/` — from that moment "their history" and "our
83
+ // store" are the same files, and an ordinary `rm -rf .chats` destroys
84
+ // transcripts paddock never owned. It also breaks unrelated tooling that
85
+ // expects `~/.claude/projects/<enc>` to be a directory. Creating a name the
86
+ // user has not used yet is not "overwriting", so it slipped past the letter
87
+ // of claude-home.ts's invariant while breaking its intent.
88
+ //
89
+ // The cost of bailing is that transcripts stay in the user's home instead of
90
+ // being self-contained in `.chats/`, which is exactly the pre-#620 trade for
91
+ // the `CLAUDE_HOME=~/.claude` escape hatch. Chat still works end to end:
92
+ // paddock and the engine both resolve THIS home, so discovery, resume and
93
+ // rename all read the real directory. Adoption (#588) remains the deliberate,
94
+ // user-driven way to pull those transcripts into a project.
95
+ if (!home.owned)
96
+ return;
91
97
  // Nothing there yet — just create the symlink so future turns land in .chats.
92
98
  await fs.symlink(chatsDir, encoded);
93
99
  }
@@ -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
  */