@edspencer/paddock 0.61.0 → 0.62.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 (93) hide show
  1. package/README.md +36 -4
  2. package/package.json +1 -1
  3. package/packages/server/dist/agent-errors.d.ts +41 -0
  4. package/packages/server/dist/agent-errors.js +127 -0
  5. package/packages/server/dist/app.js +57 -9
  6. package/packages/server/dist/claude-credentials.d.ts +116 -0
  7. package/packages/server/dist/claude-credentials.js +103 -0
  8. package/packages/server/dist/claude-home.d.ts +164 -25
  9. package/packages/server/dist/claude-home.js +528 -77
  10. package/packages/server/dist/claude-instructions.d.ts +121 -0
  11. package/packages/server/dist/claude-instructions.js +114 -0
  12. package/packages/server/dist/claude-mcp.d.ts +146 -0
  13. package/packages/server/dist/claude-mcp.js +367 -0
  14. package/packages/server/dist/claude-settings.d.ts +159 -0
  15. package/packages/server/dist/claude-settings.js +159 -0
  16. package/packages/server/dist/cli/args.d.ts +1 -1
  17. package/packages/server/dist/cli/args.js +39 -5
  18. package/packages/server/dist/cli/paddock.js +30 -6
  19. package/packages/server/dist/config.d.ts +166 -30
  20. package/packages/server/dist/config.js +106 -26
  21. package/packages/server/dist/herdctl-agent-config.d.ts +11 -1
  22. package/packages/server/dist/herdctl-agent-config.js +53 -7
  23. package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
  24. package/packages/server/dist/herdctl-agent-names.js +31 -0
  25. package/packages/server/dist/herdctl.d.ts +69 -13
  26. package/packages/server/dist/herdctl.js +70 -18
  27. package/packages/server/dist/instance-config.js +36 -0
  28. package/packages/server/dist/mcp-servers.d.ts +168 -0
  29. package/packages/server/dist/mcp-servers.js +305 -0
  30. package/packages/server/dist/routes/chats.js +10 -4
  31. package/packages/server/dist/sweep.js +13 -1
  32. package/packages/server/dist/transcripts.d.ts +62 -24
  33. package/packages/server/dist/transcripts.js +141 -28
  34. package/packages/server/package.json +1 -1
  35. package/packages/web/dist/assets/{InstanceConfigPage-CzMqTdUU.js → InstanceConfigPage-Ca905oey.js} +1 -1
  36. package/packages/web/dist/assets/{MarkdownRenderer-YSEZHWDe.js → MarkdownRenderer-ChlC1ezH.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-DLWxZbqK.js → ProjectRedirect-CslqbhUb.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectView-BFX8ff2n.js → ProjectView-DkNdilGo.js} +3 -3
  39. package/packages/web/dist/assets/{ProjectsGrid-DLOeY_ww.js → ProjectsGrid-BrlvLc-Y.js} +1 -1
  40. package/packages/web/dist/assets/{arc-BwC1ik-b.js → arc-CGqCI5nQ.js} +1 -1
  41. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CsVvXQWw.js → architectureDiagram-3BPJPVTR-Cp4KTxE2.js} +1 -1
  42. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DXQk2bIa.js → blockDiagram-GPEHLZMM-yFxaTv04.js} +1 -1
  43. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DXiA26It.js → c4Diagram-AAUBKEIU-59lhWjRt.js} +1 -1
  44. package/packages/web/dist/assets/channel-CZs6Z1i5.js +2 -0
  45. package/packages/web/dist/assets/{chunk-2J33WTMH-Dt5nC02A.js → chunk-2J33WTMH-BrpXGnJg.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-4BX2VUAB-DcjnWvl6.js → chunk-4BX2VUAB-UxBDb5pi.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-55IACEB6-CRa3LkU4.js → chunk-55IACEB6-D4ZJyUHW.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-727SXJPM-H3V-11qu.js → chunk-727SXJPM-DWdPj_8E.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DPCrENYV.js → chunk-AQP2D5EJ-BIpNbflY.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-FMBD7UC4-C4UO0ZTc.js → chunk-FMBD7UC4-f6iJDfdI.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-ND2GUHAM-BtI37CnI.js → chunk-ND2GUHAM-Bz3TKIJk.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-QZHKN3VN-C5bTaK5j.js → chunk-QZHKN3VN-buQmkafK.js} +1 -1
  53. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +2 -0
  54. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +2 -0
  55. package/packages/web/dist/assets/{core-DB3cpm5N.js → core-TyFI6p0n.js} +1 -1
  56. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-q-cjTZO4.js → cose-bilkent-S5V4N54A-CIkdi0XF.js} +1 -1
  57. package/packages/web/dist/assets/{dagre-BM42HDAG-DYEV5HFo.js → dagre-BM42HDAG-BnLJCfNs.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-2AECGRRQ-D16XCw78.js → diagram-2AECGRRQ-D0OqfDB9.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-5GNKFQAL-CeztryYE.js → diagram-5GNKFQAL-DQ2spGrK.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-KO2AKTUF-D5SLIMDS.js → diagram-KO2AKTUF-RDIVQ_S3.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-LMA3HP47-Bw-sVPxM.js → diagram-LMA3HP47-DKoTfh2C.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-OG6HWLK6-DsRMITgx.js → diagram-OG6HWLK6-6k0OuSAL.js} +1 -1
  63. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-T8Qqc_ku.js → erDiagram-TEJ5UH35-B-oQaYAC.js} +1 -1
  64. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Cyx47UWU.js → flowDiagram-I6XJVG4X-B7qpYe9C.js} +1 -1
  65. package/packages/web/dist/assets/{format-D2I0duZa.js → format-PHdEBNOi.js} +1 -1
  66. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DxYJmG9o.js → ganttDiagram-6RSMTGT7-SfBsqMmx.js} +1 -1
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-COK5YOTD.js → gitGraphDiagram-PVQCEYII-CuM4Rl_i.js} +1 -1
  68. package/packages/web/dist/assets/{index-wBXQMkGI.js → index-D_1ogxg1.js} +2 -2
  69. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BamRN_Nn.js → infoDiagram-5YYISTIA-Dq-32Oj4.js} +1 -1
  70. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DuOHdpEH.js → ishikawaDiagram-YF4QCWOH-BLFHCj_o.js} +1 -1
  71. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bri7GKTW.js → journeyDiagram-JHISSGLW-CHP-Yu63.js} +1 -1
  72. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BdkgUnzI.js → kanban-definition-UN3LZRKU-DRuehSqn.js} +1 -1
  73. package/packages/web/dist/assets/{linear-DOcNPCpB.js → linear-Bwz2vnH7.js} +1 -1
  74. package/packages/web/dist/assets/{mermaid.core-D9SbVBpN.js → mermaid.core-CeMMnCip.js} +4 -4
  75. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CVmLm7zn.js → mindmap-definition-RKZ34NQL-CEdqeoRW.js} +1 -1
  76. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D0N10eVU.js → pieDiagram-4H26LBE5-BdDXBx87.js} +1 -1
  77. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B5gnYdTa.js → quadrantDiagram-W4KKPZXB-Bovd1Zhx.js} +1 -1
  78. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CwtxezNZ.js → requirementDiagram-4Y6WPE33-DiyrKnJ2.js} +1 -1
  79. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D79O3cux.js → sankeyDiagram-5OEKKPKP-BuX7TRVw.js} +1 -1
  80. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-xhKcI6tI.js → sequenceDiagram-3UESZ5HK-BjetZWJo.js} +1 -1
  81. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DBYMAjcX.js → stateDiagram-AJRCARHV-4AUBV6dF.js} +1 -1
  82. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +2 -0
  83. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-e-MYF4lm.js → timeline-definition-PNZ67QCA-BpQ8g8Bm.js} +1 -1
  84. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B8UsvRDM.js → vennDiagram-CIIHVFJN-Cj99BWER.js} +1 -1
  85. package/packages/web/dist/assets/{wardley-L42UT6IY-1ucACjTl.js → wardley-L42UT6IY-S7AGTOWw.js} +1 -1
  86. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DJufLd_u.js → wardleyDiagram-YWT4CUSO-LNXkUA7S.js} +1 -1
  87. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-qqaZNlu9.js → xychartDiagram-2RQKCTM6-DGdTh4Xk.js} +1 -1
  88. package/packages/web/dist/index.html +1 -1
  89. package/packages/web/dist/sw.js +1 -1
  90. package/packages/web/dist/assets/channel-DDgTXrGP.js +0 -2
  91. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +0 -2
  92. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +0 -2
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +0 -2
@@ -24,6 +24,12 @@ import { fileURLToPath } from "node:url";
24
24
  import YAML from "yaml";
25
25
  import { DEFAULT_DRIVE_MODE, isKnownDriveMode, isKnownModel, } from "./models.js";
26
26
  import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
27
+ import { DEFAULT_TRANSCRIPTS_MODE, isKnownTranscriptsMode, } from "./transcripts.js";
28
+ import { DEFAULT_CREDENTIALS_MODE, isKnownCredentialsMode, } from "./claude-credentials.js";
29
+ import { DEFAULT_INSTRUCTIONS_MODE, isKnownInstructionsMode, } from "./claude-instructions.js";
30
+ import { DEFAULT_HOOKS_MODE, isKnownHooksMode } from "./claude-settings.js";
31
+ import { DEFAULT_MCP_SERVERS_MODE, isKnownMcpServersMode, } from "./claude-mcp.js";
32
+ import { resolveDeclaredMcpServers } from "./mcp-servers.js";
27
33
  import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
28
34
  import { DEFAULT_RECOVERY } from "./recovery-config.js";
29
35
  import { DEFAULT_CURATION } from "./curation-config.js";
@@ -286,10 +292,7 @@ export function loadPaddockConfig() {
286
292
  webDist: abs(envOr("PADDOCK_WEB_DIST", fileOr(file.webDist, defaultWebDist))),
287
293
  claudeHome: resolvedClaudeHome,
288
294
  legacyClaudeHome,
289
- // Paddock owns any home that is not the user's own `~/.claude` (#620) —
290
- // including one an operator pointed it at explicitly. The user's home is the
291
- // only one whose contents belong to somebody else.
292
- ownsClaudeHome: path.resolve(resolvedClaudeHome) !== path.resolve(legacyClaudeHome),
295
+ claude: loadClaudeConfig(file.claude),
293
296
  auth: loadAuthConfig(file.auth),
294
297
  transcription: loadTranscriptionConfig(file.transcription),
295
298
  brand: loadBrandConfig(file.brand),
@@ -312,6 +315,13 @@ export function loadPaddockConfig() {
312
315
  const { config, errors, warnings } = resolveManagementApiConfig(file.managementApi, process.env);
313
316
  return { managementApi: config, managementApiDiagnostics: { errors, warnings } };
314
317
  })(),
318
+ ...(() => {
319
+ // #691 step 6: file-only, and resolved against the real environment because
320
+ // any string in the block may be an `env:VAR_NAME` reference. Diagnostics
321
+ // ride along for app.ts to log — they never carry a resolved value.
322
+ const { servers, errors, warnings } = resolveDeclaredMcpServers(file.mcpServers, process.env);
323
+ return { mcpServers: servers, mcpServersDiagnostics: { errors, warnings } };
324
+ })(),
315
325
  hooksMcpEnabled: loadHooksMcpEnabled(file.hooksMcpEnabled),
316
326
  recovery: loadRecoveryConfig(file.recovery),
317
327
  attachments: loadAttachmentsConfig(file.attachments),
@@ -625,35 +635,78 @@ export function userClaudeHome() {
625
635
  return path.join(os.homedir(), ".claude");
626
636
  }
627
637
  /**
628
- * Resolve the Claude home paddock runs its agents against.
638
+ * Resolve what this instance shares with the host's Claude Code (#691).
639
+ *
640
+ * Precedence is the file's usual env > file > default. An unrecognised value
641
+ * falls back to the default rather than failing the boot — the same rule every
642
+ * other enum here follows (`driveMode`, whisper `mode`). For `transcripts` that
643
+ * is also the safe direction: a typo isolates rather than silently sharing the
644
+ * user's files. For `credentials` the default is `host` and a typo therefore
645
+ * falls back to sharing — deliberately, because the failure it avoids (#683: an
646
+ * instance that boots clean and fails every turn) is the worse one, and reading a
647
+ * login risks no file of the user's. See `claude-credentials.ts`.
648
+ *
649
+ * `hooks` is the one where falling back on a typo is load-bearing in the other
650
+ * direction: `PADDOCK_CLAUDE_HOOKS=hots` isolates rather than executing the
651
+ * host's shell commands, which is the only acceptable way for a security lever
652
+ * to misread its input. `mcpServers` follows the same direction for a weaker
653
+ * reason — an MCP server is a process paddock spawns, so a typo that attaches
654
+ * nothing is better than one that attaches the user's whole set unasked.
655
+ */
656
+ function loadClaudeConfig(file = {}) {
657
+ const transcripts = (envOpt("PADDOCK_CLAUDE_TRANSCRIPTS") ?? fileOpt(file.transcripts))?.toLowerCase();
658
+ const credentials = (envOpt("PADDOCK_CLAUDE_CREDENTIALS") ?? fileOpt(file.credentials))?.toLowerCase();
659
+ const instructions = (envOpt("PADDOCK_CLAUDE_INSTRUCTIONS") ?? fileOpt(file.instructions))?.toLowerCase();
660
+ const hooks = (envOpt("PADDOCK_CLAUDE_HOOKS") ?? fileOpt(file.hooks))?.toLowerCase();
661
+ const mcpServers = (envOpt("PADDOCK_CLAUDE_MCP_SERVERS") ?? fileOpt(file.mcpServers))?.toLowerCase();
662
+ return {
663
+ transcripts: transcripts && isKnownTranscriptsMode(transcripts) ? transcripts : DEFAULT_TRANSCRIPTS_MODE,
664
+ credentials: credentials && isKnownCredentialsMode(credentials) ? credentials : DEFAULT_CREDENTIALS_MODE,
665
+ instructions: instructions && isKnownInstructionsMode(instructions)
666
+ ? instructions
667
+ : DEFAULT_INSTRUCTIONS_MODE,
668
+ hooks: hooks && isKnownHooksMode(hooks) ? hooks : DEFAULT_HOOKS_MODE,
669
+ mcpServers: mcpServers && isKnownMcpServersMode(mcpServers) ? mcpServers : DEFAULT_MCP_SERVERS_MODE,
670
+ };
671
+ }
672
+ /**
673
+ * Resolve where paddock's OWN Claude home lives.
629
674
  *
630
675
  * Precedence, highest first:
631
676
  *
632
- * 1. **`CLAUDE_HOME`** — paddock's own long-standing override. Setting it to
633
- * `$HOME/.claude` restores the pre-#620 behaviour exactly, which is the
634
- * documented escape hatch if the relocation causes trouble.
635
- * 2. **`CLAUDE_CONFIG_DIR`** — the variable *Claude Code itself* resolves its
677
+ * 1. **`CLAUDE_CONFIG_DIR`** — the variable *Claude Code itself* resolves its
636
678
  * home from. Honouring it is load-bearing, not a courtesy: herdctl
637
679
  * deliberately refuses to clobber an operator-set `CLAUDE_CONFIG_DIR`
638
680
  * (herdctl#423), so if paddock picked a different home the SDK would write
639
681
  * transcripts to one tree while herdctl read from another — the exact
640
682
  * split-brain #588 fixed. Deferring to it makes that state unreachable.
641
- * 3. A `claudeHome:` key in the instance config file.
642
- * 4. **`<dataDir>/claude-home`** — the default since #620.
683
+ * 2. A `claudeHome:` key in the instance config file.
684
+ * 3. **`<dataDir>/claude-home`** — the default.
643
685
  *
644
- * On the default, and why it changed: paddock already nests herdctl's whole
645
- * state dir, the projects tree and the generated `herdctl.yaml` under its data
646
- * dir. Transcripts were the one exception, reached by planting symlinks in the
647
- * user's home — not a design choice but a constraint, because until
648
- * herdctl#423 nothing set `CLAUDE_CONFIG_DIR` and the SDK wrote to `~/.claude`
649
- * no matter what paddock configured. With that gone, owning the home makes the
650
- * data dir relocatable as a unit, leaves `~/.claude` alone, and (because there
651
- * is no longer a `.claude` path component) unblocks agent memory writes, which
652
- * the harness refuses for any path containing `.claude`.
686
+ * ## Paddock always OWNS this home, and the one value it refuses
687
+ *
688
+ * `CLAUDE_HOME` used to sit above all of this, and setting it to `~/.claude` was
689
+ * the documented escape hatch back to the pre-#620 layout. It is gone (#691),
690
+ * because pointing the home at the user's own directory is what welded five
691
+ * unrelated concerns to one lever: it decided whose transcripts were at risk,
692
+ * which keychain entry was visible, where agent memory landed, and whether
693
+ * delete meant delete. Those are now separate keys (`claude:`), so the home no
694
+ * longer needs to move for any of them.
695
+ *
696
+ * Which leaves exactly one value that must not be allowed through, from any
697
+ * source: the user's own `~/.claude`. It re-welds the lever, and it re-breaks
698
+ * agent memory (#690) — the memory dir is `<claudeHome>/projects/<enc>/memory`,
699
+ * and the agent harness refuses to write to any path with a `.claude` component.
700
+ * So it REFUSES TO START rather than silently degrading, which is the same
701
+ * fail-closed shape as the bind-safety guard. `CLAUDE_CONFIG_DIR` cannot simply
702
+ * be ignored instead: it is Claude Code's own variable, and disagreeing with an
703
+ * operator-set value is the herdctl#423 split-brain again.
653
704
  *
654
705
  * NOT canonical()-ed: this exact string is what herdctl hands Claude Code as
655
706
  * `CLAUDE_CONFIG_DIR`, and canonicalising would silently diverge from the
656
707
  * literal path the two of them encode into `<home>/projects/<encoded-cwd>`.
708
+ * The refusal check DOES resolve both sides, so a symlinked or trailing-slash
709
+ * spelling of `~/.claude` cannot slip past it.
657
710
  *
658
711
  * This is the RESOLVER, not the accessor. It is read once by
659
712
  * {@link loadPaddockConfig} into {@link PaddockConfig.claudeHome}; runtime code
@@ -661,12 +714,39 @@ export function userClaudeHome() {
661
714
  * engine can never end up resolving two different homes (#588).
662
715
  */
663
716
  export function resolveClaudeHome(dataDir, fromFile) {
664
- const env = envOpt("CLAUDE_HOME") ?? envOpt("CLAUDE_CONFIG_DIR");
665
- if (env !== undefined)
666
- return abs(env);
667
- if (fromFile !== undefined && fromFile !== "")
668
- return abs(fromFile);
669
- return path.join(dataDir, DEFAULT_CLAUDE_HOME_DIRNAME);
717
+ const env = envOpt("CLAUDE_CONFIG_DIR");
718
+ const home = env !== undefined
719
+ ? abs(env)
720
+ : fromFile !== undefined && fromFile !== ""
721
+ ? abs(fromFile)
722
+ : path.join(dataDir, DEFAULT_CLAUDE_HOME_DIRNAME);
723
+ const refusal = claudeHomeRefusal(home, userClaudeHome(), env !== undefined);
724
+ if (refusal !== undefined)
725
+ throw new Error(refusal);
726
+ return home;
727
+ }
728
+ /**
729
+ * Why `home` is unusable as paddock's own Claude home, or `undefined` if it is
730
+ * fine. Pure, so the whole decision is testable without touching `$HOME`.
731
+ *
732
+ * `fromEnv` only changes which fix is named first — the refusal itself is the
733
+ * same whichever layer supplied the value.
734
+ */
735
+ export function claudeHomeRefusal(home, userHome, fromEnv) {
736
+ if (path.resolve(home) !== path.resolve(userHome))
737
+ return undefined;
738
+ return (`refusing to start: the Claude home resolves to your own ${userHome}, which paddock ` +
739
+ `must not run as its own home. It re-couples everything paddock keeps separate — ` +
740
+ `whose transcripts a delete removes, which login is visible, where agent memory is ` +
741
+ `written (agents cannot write under a \`.claude\` path at all, #690). ` +
742
+ (fromEnv
743
+ ? `CLAUDE_CONFIG_DIR is what points it there: unset it, or point it at a directory ` +
744
+ `of paddock's own. `
745
+ : `The \`claudeHome:\` key in paddock.config.yaml points it there: remove it, or name a ` +
746
+ `directory of paddock's own. `) +
747
+ `To SHARE your Claude Code transcripts, set \`claude: { transcripts: host }\` (or ` +
748
+ `PADDOCK_CLAUDE_TRANSCRIPTS=host) — that shares the files themselves and keeps ` +
749
+ `paddock's home where it belongs.`);
670
750
  }
671
751
  /** Directory name of paddock's own Claude home inside the data dir (#620). */
672
752
  export const DEFAULT_CLAUDE_HOME_DIRNAME = "claude-home";
@@ -1,5 +1,6 @@
1
1
  import type { PaddockConfig } from "./config.js";
2
2
  import type { Project } from "./projects.js";
3
+ import { type McpSources } from "./claude-mcp.js";
3
4
  import { type PaddockTrigger } from "./trigger-config.js";
4
5
  /**
5
6
  * A project's keeper agent config. Inherits the fleet `defaults` (runtime,
@@ -24,8 +25,17 @@ import { type PaddockTrigger } from "./trigger-config.js";
24
25
  * not `<dataDir>`: `projectsRoot` IS the instance's backing repo, so the file is
25
26
  * version-controlled. Since #516 the ROOT project reaches it the same way —
26
27
  * its cwd IS `projectsRoot`, so the walk-up needs no special-casing.
28
+ *
29
+ * `mcpSources` is the external MCP servers this instance attaches — the user's
30
+ * own under `claude.mcpServers: host` (#691 step 5) plus the ones the instance
31
+ * declares in its own `mcpServers:` block (step 6). Both are resolved once at
32
+ * boot and passed in rather than re-read here, so this stays pure and so a
33
+ * `.claude.json` is never opened under `mcpServers: own`. It defaults to empty,
34
+ * which is both the isolated mode and what every existing caller means. The
35
+ * KEEPER is the only agent that gets them: the sweeper is tool-less and each
36
+ * trigger declares its own narrow allowlist, so neither could call one anyway.
27
37
  */
28
- export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, modelOverride?: string): Record<string, unknown> & {
38
+ export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, modelOverride?: string, mcpSources?: McpSources): Record<string, unknown> & {
29
39
  name: string;
30
40
  };
31
41
  /**
@@ -13,9 +13,10 @@
13
13
  import { promises as fs } from "node:fs";
14
14
  import path from "node:path";
15
15
  import YAML from "yaml";
16
+ import { EMPTY_MCP_SOURCES, mcpServersFor, mcpToolPattern, } from "./claude-mcp.js";
16
17
  import { DEFAULT_MODEL, SWEEPER_DEFAULT_MODEL, DEFAULT_PERMISSION_MODE, DEFAULT_MAX_TURNS, } from "./models.js";
17
18
  import { triggerToAgentToolConfig, triggersToHerdctlSchedules, curatorTriggerOf, } from "./trigger-config.js";
18
- import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers, BROWSER_MCP_TOOL, DENIED_TOOLS, KEEPER_MAX_CONCURRENT, KEEPER_SESSION_TIMEOUT, } from "./herdctl-agent-names.js";
19
+ import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers, FLEET_ALLOWED_TOOLS, DENIED_TOOLS, KEEPER_MAX_CONCURRENT, KEEPER_SESSION_TIMEOUT, } from "./herdctl-agent-names.js";
19
20
  /**
20
21
  * A project's keeper agent config. Inherits the fleet `defaults` (runtime,
21
22
  * max_turns, permission_mode, allowed/denied tools) via addAgent's deep-merge;
@@ -39,8 +40,17 @@ import { keeperAgentName, sweeperAgentName, triggerAgentName, browserMcpServers,
39
40
  * not `<dataDir>`: `projectsRoot` IS the instance's backing repo, so the file is
40
41
  * version-controlled. Since #516 the ROOT project reaches it the same way —
41
42
  * its cwd IS `projectsRoot`, so the walk-up needs no special-casing.
43
+ *
44
+ * `mcpSources` is the external MCP servers this instance attaches — the user's
45
+ * own under `claude.mcpServers: host` (#691 step 5) plus the ones the instance
46
+ * declares in its own `mcpServers:` block (step 6). Both are resolved once at
47
+ * boot and passed in rather than re-read here, so this stays pure and so a
48
+ * `.claude.json` is never opened under `mcpServers: own`. It defaults to empty,
49
+ * which is both the isolated mode and what every existing caller means. The
50
+ * KEEPER is the only agent that gets them: the sweeper is tool-less and each
51
+ * trigger declares its own narrow allowlist, so neither could call one anyway.
42
52
  */
43
- export function buildAgentConfig(cfg, project, modelOverride) {
53
+ export function buildAgentConfig(cfg, project, modelOverride, mcpSources = EMPTY_MCP_SOURCES) {
44
54
  const config = {
45
55
  name: keeperAgentName(project.slug),
46
56
  description: project.summary || `Claude Code agent for project ${project.name}.`,
@@ -101,11 +111,42 @@ export function buildAgentConfig(cfg, project, modelOverride) {
101
111
  const schedules = triggersToHerdctlSchedules(project.triggers);
102
112
  if (schedules)
103
113
  config.schedules = schedules;
104
- // Browser MCP (headless Chromium) when enabled for this box; `mcp__playwright__*`
105
- // is already on the inherited defaults.allowed_tools.
114
+ // MCP servers, from three sources, all landing on the SAME `mcp_servers` key:
115
+ //
116
+ // - the browser MCP (headless Chromium) when enabled for this box —
117
+ // `mcp__playwright__*` is already on the inherited defaults.allowed_tools;
118
+ // - the user's own servers under `claude.mcpServers: host` (#691 step 5);
119
+ // - the ones this instance declares in its own `mcpServers:` block (#691 step
120
+ // 6), which win over the inherited ones (`mcpServersFor` merges them last).
121
+ //
122
+ // Neither of the latter two is on the default allowlist, because their names are
123
+ // not knowable at config-file-write time — hence the widening below.
124
+ //
125
+ // Paddock's own server still wins a name collision, against BOTH: its flags are
126
+ // box-specific (the Ansible-installed chromium engine, --no-sandbox for an
127
+ // unprivileged LXC) and someone else's `playwright` would almost certainly not
128
+ // start here. A declared collision is warned about by name at boot
129
+ // (`declaredMcpNotices`) so it is not silent.
106
130
  const browser = browserMcpServers(cfg.browserMcp);
107
- if (browser)
108
- config.mcp_servers = browser;
131
+ const external = mcpServersFor(mcpSources, project.workingDir);
132
+ const servers = { ...external, ...(browser ?? {}) };
133
+ if (Object.keys(servers).length > 0)
134
+ config.mcp_servers = servers;
135
+ // Widen the allowlist by exactly those servers' tool patterns. WITHOUT this
136
+ // both levers are a no-op with no error: both runtimes auto-deny any tool
137
+ // missing from an explicit `allowed_tools`, so an attached-but-unlisted server
138
+ // connects and then has every call refused. herdctl does this automatically for
139
+ // INJECTED servers only (`cli-runtime.js`), never for config `mcp_servers` —
140
+ // which is why `mcp__playwright__*` is hard-coded into the defaults.
141
+ //
142
+ // Patterns already on the defaults are filtered out, so nothing is restated
143
+ // for an instance with no external servers (or one whose only server paddock's
144
+ // own overrode above) — that config stays byte-identical to before this lever.
145
+ const extra = Object.keys(external)
146
+ .map(mcpToolPattern)
147
+ .filter((pattern) => !FLEET_ALLOWED_TOOLS.includes(pattern));
148
+ if (extra.length > 0)
149
+ config.allowed_tools = [...FLEET_ALLOWED_TOOLS, ...extra];
109
150
  return config;
110
151
  }
111
152
  /**
@@ -330,7 +371,12 @@ export async function ensureConfigFile(cfg) {
330
371
  // several of these surface as deferred tools reached through it. These
331
372
  // only actually DO anything in session drive-mode (the reaper re-fires
332
373
  // them); in batch mode they're inert (documented in the box CLAUDE.md).
333
- allowed_tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep", "WebFetch", "WebSearch", "Task", "TodoWrite", "Skill", "NotebookEdit", "ToolSearch", "ScheduleWakeup", "Monitor", "CronCreate", "CronList", "CronDelete", BROWSER_MCP_TOOL],
374
+ // The list itself lives in `herdctl-agent-names.ts` as FLEET_ALLOWED_TOOLS,
375
+ // because a keeper with host MCP servers attached (#691 step 5) has to
376
+ // restate it plus their `mcp__<server>__*` patterns — herdctl's
377
+ // `mergeAgentConfig` REPLACES arrays rather than merging them, so an agent
378
+ // that widens the allowlist inherits nothing from here.
379
+ allowed_tools: [...FLEET_ALLOWED_TOOLS],
334
380
  // Best-effort denylist (#179): narrow, honest catastrophic-wipe patterns
335
381
  // that do NOT block legitimate absolute-path cleanup. See DENIED_TOOLS.
336
382
  denied_tools: [...DENIED_TOOLS],
@@ -100,6 +100,17 @@ export declare function visibleProjectAgentNames(project: Project): string[];
100
100
  * no-op.
101
101
  */
102
102
  export declare const BROWSER_MCP_TOOL = "mcp__playwright__*";
103
+ /**
104
+ * The fleet-wide `defaults.allowed_tools` a keeper inherits.
105
+ *
106
+ * Extracted from `ensureConfigFile` — where the reasoning for each entry still
107
+ * lives — because `claude.mcpServers: host` (#691 step 5) has to hand a keeper
108
+ * this list PLUS one `mcp__<server>__*` pattern per attached host server. Arrays
109
+ * are replaced rather than merged by herdctl's `mergeAgentConfig`, so widening
110
+ * the allowlist per-agent means restating the defaults, and restating them means
111
+ * they can only be written down once.
112
+ */
113
+ export declare const FLEET_ALLOWED_TOOLS: readonly string[];
103
114
  /**
104
115
  * The keeper's default `denied_tools` — a **best-effort, defence-in-depth**
105
116
  * denylist, NOT a sandbox. Real isolation (per-agent filesystem confinement)
@@ -116,6 +116,37 @@ export function visibleProjectAgentNames(project) {
116
116
  * no-op.
117
117
  */
118
118
  export const BROWSER_MCP_TOOL = "mcp__playwright__*";
119
+ /**
120
+ * The fleet-wide `defaults.allowed_tools` a keeper inherits.
121
+ *
122
+ * Extracted from `ensureConfigFile` — where the reasoning for each entry still
123
+ * lives — because `claude.mcpServers: host` (#691 step 5) has to hand a keeper
124
+ * this list PLUS one `mcp__<server>__*` pattern per attached host server. Arrays
125
+ * are replaced rather than merged by herdctl's `mergeAgentConfig`, so widening
126
+ * the allowlist per-agent means restating the defaults, and restating them means
127
+ * they can only be written down once.
128
+ */
129
+ export const FLEET_ALLOWED_TOOLS = [
130
+ "Read",
131
+ "Edit",
132
+ "Write",
133
+ "Bash",
134
+ "Glob",
135
+ "Grep",
136
+ "WebFetch",
137
+ "WebSearch",
138
+ "Task",
139
+ "TodoWrite",
140
+ "Skill",
141
+ "NotebookEdit",
142
+ "ToolSearch",
143
+ "ScheduleWakeup",
144
+ "Monitor",
145
+ "CronCreate",
146
+ "CronList",
147
+ "CronDelete",
148
+ BROWSER_MCP_TOOL,
149
+ ];
119
150
  /**
120
151
  * The keeper's default `denied_tools` — a **best-effort, defence-in-depth**
121
152
  * denylist, NOT a sandbox. Real isolation (per-agent filesystem confinement)
@@ -50,6 +50,7 @@ import { FleetManager, type DiscoveredSession, type ChatMessage, type SDKMessage
50
50
  import type { PaddockConfig } from "./config.js";
51
51
  import type { Project } from "./projects.js";
52
52
  import { type PaddockTrigger } from "./trigger-config.js";
53
+ import { type McpSources } from "./claude-mcp.js";
53
54
  import { type AdoptableSummary, type FilterReason } from "./adoptable.js";
54
55
  /**
55
56
  * One session an import did NOT bring in, with a reason a UI can show verbatim.
@@ -63,6 +64,20 @@ export type AdoptSkipped = AdoptSkippedSession | {
63
64
  reason: FilterReason;
64
65
  detail?: string;
65
66
  };
67
+ /**
68
+ * Outcome of deleting a chat (#689).
69
+ *
70
+ * Two fields rather than one boolean because "the transcript is gone" and "the
71
+ * chat is no longer listed here" stopped being the same thing: in a home paddock
72
+ * does not own, the file belongs to the user's terminal `claude` and is released
73
+ * rather than removed. See {@link HerdctlService.deleteSession}.
74
+ */
75
+ export interface ChatDeletion {
76
+ /** A transcript file was unlinked. False when none existed, or when retained. */
77
+ removed: boolean;
78
+ /** The transcript was deliberately left on disk because it is not paddock's. */
79
+ retained: boolean;
80
+ }
66
81
  /** Options passed through to a streamed trigger. */
67
82
  export interface ChatTurnOptions {
68
83
  prompt: string;
@@ -105,6 +120,7 @@ export { keeperAgentName, keeperSlugFromAgent, SWEEPER_PREFIX, sweeperAgentName,
105
120
  export declare function sliceTranscriptAtUuid(raw: string, cutUuid: string, endOnCompletedAssistant?: boolean): string | null;
106
121
  export declare class HerdctlService {
107
122
  private readonly cfg;
123
+ private readonly mcpSources;
108
124
  private fleet;
109
125
  private started;
110
126
  /**
@@ -164,13 +180,27 @@ export declare class HerdctlService {
164
180
  */
165
181
  private jobsIndexOrNull;
166
182
  private get jobsIndex();
167
- constructor(cfg: PaddockConfig);
168
183
  /**
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.
184
+ * The external MCP servers every keeper gets: the user's own under
185
+ * `claude.mcpServers: host` (#691 step 5, read once at boot by
186
+ * `loadHostMcpSource`) plus the ones this instance declares in its own
187
+ * `mcpServers:` block (step 6, resolved during config load). Both are merged
188
+ * into one value by `buildApp` and handed in here.
189
+ *
190
+ * A constructor argument rather than something this service reads for itself,
191
+ * for the same reason `ensureClaudeHome` runs in `buildApp`: the read has
192
+ * notices an operator needs to see in the boot log, and it must not happen at
193
+ * all under `mcpServers: own`. Defaults to empty so every existing caller —
194
+ * including the several tests that construct this with `{} as PaddockConfig` —
195
+ * keeps meaning "isolated".
196
+ */
197
+ constructor(cfg: PaddockConfig, mcpSources?: McpSources);
198
+ /**
199
+ * The Claude home every transcript symlink is planted in, plus where those
200
+ * symlinks point (#620, #691). One accessor, so no call site can quietly take
201
+ * a default and land in a different home than the FleetManager was built with
202
+ * — the bug `ensureSweeperHome` had, which put the sweeper's symlink in
203
+ * `~/.claude` while every other agent's went to the configured home.
174
204
  */
175
205
  private get claudeHomeTarget();
176
206
  /**
@@ -421,13 +451,39 @@ export declare class HerdctlService {
421
451
  */
422
452
  removeProjectAgent(slug: string, hookNames?: string[], triggerNames?: string[]): Promise<void>;
423
453
  /**
424
- * Delete a single chat (session) by agent name + session id. The FleetManager
425
- * resolves the agent's working directory, removes the transcript JSONL, and
426
- * invalidates the discovery cache so the list reflects it immediately.
427
- * Validates the sessionId (rejects path traversal). Returns true if a
428
- * transcript file was removed, false if none existed.
429
- */
430
- deleteSession(agentName: string, sessionId: string): Promise<boolean>;
454
+ * Delete a single chat (session) by agent name + session id.
455
+ *
456
+ * Under `claude.transcripts: own`, this removes the transcript JSONL: the file
457
+ * lives in paddock's own tree (via the project's `.chats/` symlink), so it is
458
+ * ours to delete. The FleetManager resolves the agent's working directory,
459
+ * unlinks the file, validates the sessionId against traversal, and invalidates
460
+ * the discovery cache so the list reflects it immediately.
461
+ *
462
+ * Under `claude.transcripts: host`, it **releases the session instead** (#689).
463
+ * There is no copy to delete there: the symlink points OUT at
464
+ * `~/.claude/projects/<encoded-cwd>/`, so the transcript the agent reads and
465
+ * writes is the user's own — for an adopted chat, literally their terminal
466
+ * history. `rm`-ing it destroys history paddock never owned, which is the same
467
+ * class of mistake #682 was, pointed the other way: that one claimed where
468
+ * future sessions get written, this one deletes the past ones.
469
+ *
470
+ * `undoImport` already draws exactly this line ("A session adopted in place has
471
+ * no copy, and its transcript is the user's own — removing it would destroy
472
+ * history rather than restore it"); the invariant simply never reached here.
473
+ *
474
+ * The discriminator changed with #691 and the meaning did not. It used to be
475
+ * `ownsClaudeHome` — "is this home ours?" — which is now always true; the
476
+ * question that survives is "is this transcript ours?", and that is exactly
477
+ * what `transcripts` says. Deliberately NOT narrowed to "only adopted chats":
478
+ * under `host` a chat paddock started itself is in the user's tree too, and
479
+ * distinguishing them well enough to risk an `rm` is not worth the failure mode
480
+ * of getting it wrong. The cost is a transcript left behind, which the caller
481
+ * is told about; the alternative cost is somebody's history.
482
+ *
483
+ * The chat leaves paddock's list either way — which is what the user asked for
484
+ * — so `retained` is how a caller tells "gone" from "no longer shown here".
485
+ */
486
+ deleteSession(agentName: string, sessionId: string): Promise<ChatDeletion>;
431
487
  /**
432
488
  * Set (or clear) a chat's custom display name. Writes through the fleet's
433
489
  * shared SessionMetadataStore so a subsequent getAgentSessions reflects it
@@ -56,6 +56,7 @@ import { DEFAULT_MODEL } from "./models.js";
56
56
  import { ensureProjectChats, projectChatsDir } from "./transcripts.js";
57
57
  import { triggerRunsOnOwnAgent, isCuratorTrigger, } from "./trigger-config.js";
58
58
  import { buildAgentConfig, buildSweeperConfig, sweeperWorkingDir, buildTriggerConfig, ensureConfigFile as writeBootConfigFile, } from "./herdctl-agent-config.js";
59
+ import { EMPTY_MCP_SOURCES } from "./claude-mcp.js";
59
60
  import * as jobs from "./herdctl-jobs.js";
60
61
  import { JobsDirIndex } from "./herdctl-jobs-index.js";
61
62
  import { AdoptableIndex } from "./adoptable.js";
@@ -162,6 +163,7 @@ export function sliceTranscriptAtUuid(raw, cutUuid, endOnCompletedAssistant = fa
162
163
  }
163
164
  export class HerdctlService {
164
165
  cfg;
166
+ mcpSources;
165
167
  fleet = null;
166
168
  started = false;
167
169
  /**
@@ -223,18 +225,37 @@ export class HerdctlService {
223
225
  get jobsIndex() {
224
226
  return (this.jobsIndexOrNull ??= new JobsDirIndex(this.cfg.stateDir));
225
227
  }
226
- constructor(cfg) {
228
+ /**
229
+ * The external MCP servers every keeper gets: the user's own under
230
+ * `claude.mcpServers: host` (#691 step 5, read once at boot by
231
+ * `loadHostMcpSource`) plus the ones this instance declares in its own
232
+ * `mcpServers:` block (step 6, resolved during config load). Both are merged
233
+ * into one value by `buildApp` and handed in here.
234
+ *
235
+ * A constructor argument rather than something this service reads for itself,
236
+ * for the same reason `ensureClaudeHome` runs in `buildApp`: the read has
237
+ * notices an operator needs to see in the boot log, and it must not happen at
238
+ * all under `mcpServers: own`. Defaults to empty so every existing caller —
239
+ * including the several tests that construct this with `{} as PaddockConfig` —
240
+ * keeps meaning "isolated".
241
+ */
242
+ constructor(cfg, mcpSources = EMPTY_MCP_SOURCES) {
227
243
  this.cfg = cfg;
244
+ this.mcpSources = mcpSources;
228
245
  }
229
246
  /**
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.
247
+ * The Claude home every transcript symlink is planted in, plus where those
248
+ * symlinks point (#620, #691). One accessor, so no call site can quietly take
249
+ * a default and land in a different home than the FleetManager was built with
250
+ * — the bug `ensureSweeperHome` had, which put the sweeper's symlink in
251
+ * `~/.claude` while every other agent's went to the configured home.
235
252
  */
236
253
  get claudeHomeTarget() {
237
- return { path: this.cfg.claudeHome, owned: this.cfg.ownsClaudeHome };
254
+ return {
255
+ path: this.cfg.claudeHome,
256
+ transcripts: this.cfg.claude.transcripts,
257
+ userHome: this.cfg.legacyClaudeHome,
258
+ };
238
259
  }
239
260
  /**
240
261
  * The environment system prompt to append to a keeper turn (issue #635), or
@@ -309,10 +330,10 @@ export class HerdctlService {
309
330
  allowScheduleMutation: this.cfg.scheduleMutationEnabled,
310
331
  // Hand the engine the SAME Claude home paddock resolved (#588). Omitting
311
332
  // it makes the engine fall back to `os.homedir()/.claude` while paddock
312
- // honours `CLAUDE_HOME`, so discovery, adoption and paddock's `.chats/`
313
- // symlinks would resolve against different directories — sessions list
314
- // from one home and open empty from the other. Masked whenever the two
315
- // coincide, which is why it must be explicit.
333
+ // uses its own, so discovery, adoption and paddock's transcript symlinks
334
+ // would resolve against different directories — sessions list from one
335
+ // home and open empty from the other. Masked whenever the two coincide,
336
+ // which is why it must be explicit.
316
337
  claudeHomePath: this.cfg.claudeHome,
317
338
  });
318
339
  await this.fleet.initialize();
@@ -763,14 +784,45 @@ export class HerdctlService {
763
784
  }
764
785
  }
765
786
  /**
766
- * Delete a single chat (session) by agent name + session id. The FleetManager
767
- * resolves the agent's working directory, removes the transcript JSONL, and
768
- * invalidates the discovery cache so the list reflects it immediately.
769
- * Validates the sessionId (rejects path traversal). Returns true if a
770
- * transcript file was removed, false if none existed.
787
+ * Delete a single chat (session) by agent name + session id.
788
+ *
789
+ * Under `claude.transcripts: own`, this removes the transcript JSONL: the file
790
+ * lives in paddock's own tree (via the project's `.chats/` symlink), so it is
791
+ * ours to delete. The FleetManager resolves the agent's working directory,
792
+ * unlinks the file, validates the sessionId against traversal, and invalidates
793
+ * the discovery cache so the list reflects it immediately.
794
+ *
795
+ * Under `claude.transcripts: host`, it **releases the session instead** (#689).
796
+ * There is no copy to delete there: the symlink points OUT at
797
+ * `~/.claude/projects/<encoded-cwd>/`, so the transcript the agent reads and
798
+ * writes is the user's own — for an adopted chat, literally their terminal
799
+ * history. `rm`-ing it destroys history paddock never owned, which is the same
800
+ * class of mistake #682 was, pointed the other way: that one claimed where
801
+ * future sessions get written, this one deletes the past ones.
802
+ *
803
+ * `undoImport` already draws exactly this line ("A session adopted in place has
804
+ * no copy, and its transcript is the user's own — removing it would destroy
805
+ * history rather than restore it"); the invariant simply never reached here.
806
+ *
807
+ * The discriminator changed with #691 and the meaning did not. It used to be
808
+ * `ownsClaudeHome` — "is this home ours?" — which is now always true; the
809
+ * question that survives is "is this transcript ours?", and that is exactly
810
+ * what `transcripts` says. Deliberately NOT narrowed to "only adopted chats":
811
+ * under `host` a chat paddock started itself is in the user's tree too, and
812
+ * distinguishing them well enough to risk an `rm` is not worth the failure mode
813
+ * of getting it wrong. The cost is a transcript left behind, which the caller
814
+ * is told about; the alternative cost is somebody's history.
815
+ *
816
+ * The chat leaves paddock's list either way — which is what the user asked for
817
+ * — so `retained` is how a caller tells "gone" from "no longer shown here".
771
818
  */
772
819
  async deleteSession(agentName, sessionId) {
773
- return this.manager.deleteSession(agentName, sessionId);
820
+ if (this.cfg.claude.transcripts === "own") {
821
+ return { removed: await this.manager.deleteSession(agentName, sessionId), retained: false };
822
+ }
823
+ await this.manager.unadoptSession(agentName, sessionId).catch(() => undefined);
824
+ this.invalidateSessions(agentName);
825
+ return { removed: false, retained: true };
774
826
  }
775
827
  /**
776
828
  * Set (or clear) a chat's custom display name. Writes through the fleet's
@@ -1456,7 +1508,7 @@ export class HerdctlService {
1456
1508
  // existing unit-test seams (which reach `sweeperAgentConfig`/`ensureConfigFile`
1457
1509
  // via a cast) and internal callers keep working.
1458
1510
  keeperAgentConfig(project, modelOverride) {
1459
- return buildAgentConfig(this.cfg, project, modelOverride);
1511
+ return buildAgentConfig(this.cfg, project, modelOverride, this.mcpSources);
1460
1512
  }
1461
1513
  sweeperAgentConfig(project) {
1462
1514
  return buildSweeperConfig(this.cfg, project);