@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
package/README.md CHANGED
@@ -26,8 +26,34 @@ claude setup-token # Claude Max/Pro
26
26
  export ANTHROPIC_API_KEY=sk-ant-… # or API billing
27
27
  ```
28
28
 
29
- If you already use Claude Code on this machine, your existing login is picked up
30
- automatically and there is nothing to do.
29
+ **If you already use Claude Code on this machine, there is nothing to do.** Paddock
30
+ keeps a Claude home of its own under the data dir, but not a login of its own: it uses
31
+ the one you already have — the macOS Keychain entry on a Mac, your
32
+ `~/.claude/.credentials.json` elsewhere (symlinked in, never copied). Reading a login
33
+ writes nothing, and nothing else is shared with it.
34
+
35
+ To give Paddock its own instead, set `claude: { credentials: own }` in
36
+ `<data-dir>/paddock.config.yaml`; a login is then a token in the environment as above,
37
+ or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. Either way,
38
+ Paddock says at startup if it can find no credentials at all.
39
+
40
+ ## What else Paddock does and does not take from your `~/.claude`
41
+
42
+ Apart from that login: **nothing, by default.** Your `CLAUDE.md`, `agents/`,
43
+ `commands/` and `plugins/` are not loaded, and the hooks your `settings.json` binds to
44
+ tool use do not run. Both are one key in `<data-dir>/paddock.config.yaml`:
45
+
46
+ ```yaml
47
+ claude:
48
+ instructions: host # own | host, default own — CLAUDE.md, agents, commands, plugins
49
+ hooks: host # own | host, default own — shell commands settings.json binds
50
+ ```
51
+
52
+ `instructions: own` is worth knowing about if you have curated a `~/.claude/CLAUDE.md`:
53
+ your Paddock agents will not see it until you set `host`. Each project's own `CLAUDE.md`
54
+ always applies. `hooks` is off by default because inheriting someone's shell commands is
55
+ not a thing to discover after the fact; the rest of your `settings.json` — permissions,
56
+ model, statusline — applies either way.
31
57
 
32
58
  ## Open your own project
33
59
 
@@ -45,8 +71,14 @@ The flag is the consent, so here is everything it does:
45
71
 
46
72
  - creates **`.paddock/`** for the workspace's state and **`.chats/`** for transcripts
47
73
  - appends those two entries to **`.gitignore`**
48
- - **links** `~/.claude/projects/<encoded-dir>` at the workspace — your Claude Code
49
- history is linked, never moved or copied
74
+
75
+ **Nothing is written into your `~/.claude`** — no file, no symlink. Paddock uses a
76
+ Claude home of its own under `.paddock/` and relocates transcripts into `.chats/`.
77
+ Sessions found in `~/.claude` are *offered* for import: nothing is moved, copied or
78
+ linked until you confirm, and your terminal `claude` keeps working exactly as before.
79
+ To share one set of transcripts between Paddock and your terminal instead, set
80
+ `claude: { transcripts: host }` in `<data-dir>/paddock.config.yaml`. (Your *login* is
81
+ already shared, which is why there is nothing to log into — see Credentials above.)
50
82
 
51
83
  To undo it: `rm -rf .paddock .chats` and drop the two `.gitignore` lines.
52
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one-line, actionable reason this failed, if it is one we recognise.
3
+ *
4
+ * `undefined` means "no idea" — and an unknown failure keeps its full detail,
5
+ * because that is exactly the case where somebody needs it.
6
+ */
7
+ export declare function classifyAgentError(err: unknown): string | undefined;
8
+ /**
9
+ * The same error with the reconstructed `claude` argv cut out.
10
+ *
11
+ * execa's message quotes the entire command it ran, which for the sweeper is
12
+ * ~2 KB of system prompt followed by the full `--disallowedTools` list. It is
13
+ * noise to an end user and it buries the signal; it also means the curator
14
+ * prompt is copied into any log or paste of the failure. Everything before the
15
+ * argv (`Command failed with exit code 1`) is kept, because the exit status is
16
+ * the part that is actually diagnostic.
17
+ *
18
+ * Full text is still one env var away — see {@link installHerdctlLogBridge}.
19
+ */
20
+ export declare function summariseAgentError(message: string): string;
21
+ /**
22
+ * Route `@herdctl/core`'s logging through a handler that shapes agent failures.
23
+ *
24
+ * Two behaviours, and they are separate on purpose:
25
+ *
26
+ * - **Always** summarise the argv. The system prompt in a `claude -p` command
27
+ * line is never what somebody is reading the log for, on a laptop or a
28
+ * server. `HERDCTL_LOG_LEVEL=debug` turns it back on — at debug you have
29
+ * asked for everything, and the argv is genuinely useful there.
30
+ * - **In quiet mode** (the CLI's default), collapse a recognised failure to its
31
+ * cause. `LOG_LEVEL=warn` was never enough for this: core logs these at
32
+ * `error`, which passes every level filter there is, and one of them is a
33
+ * bare `console.error`. Quiet-by-default meant "quiet when nothing goes
34
+ * wrong", which is the easy half.
35
+ *
36
+ * Idempotent; installing again replaces the handler. Call `setLogHandler(null)`
37
+ * to restore core's own output.
38
+ */
39
+ export declare function installHerdctlLogBridge(options: {
40
+ quiet: boolean;
41
+ }): void;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Making a failed agent run readable (#684).
3
+ *
4
+ * A credential-less `npx @edspencer/paddock --here` — the path the README, the
5
+ * docs site and the front page all point new users at — greeted them with a
6
+ * multi-screen stack trace containing the entire sweeper system prompt, four
7
+ * times over: once in the `ExecaError`, once in the `[fleet-manager]` line, once
8
+ * in the serialised `err.message` and once inside `err.stack`. The one useful
9
+ * string, `Not logged in · Please run /login`, was on line 40 of the dump. The
10
+ * server was fine and still serving; nothing about the output said so.
11
+ *
12
+ * Three separate things produce that wall, and only one of them is ours:
13
+ *
14
+ * 1. `sweep.ts` logging `{ err }` at pino level 50 — ours, fixed there.
15
+ * 2. `[CLIRuntime] Process failed: …` — `@herdctl/core`.
16
+ * 3. `[fleet-manager] Job … error: …` — `@herdctl/core`.
17
+ *
18
+ * We cannot edit core from here, but we do not have to: `setLogHandler` is an
19
+ * exported hook that replaces the `console.*` output of EVERY `createLogger` in
20
+ * the engine. {@link installHerdctlLogBridge} uses it to shape both of core's
21
+ * lines on the way past. That is a better fix than patching core anyway — it
22
+ * keeps the decision about how much noise a paddock instance emits inside
23
+ * paddock, where the CLI's quiet mode lives.
24
+ */
25
+ import { setLogHandler, getLogLevel, LOG_LEVEL_ORDER } from "@herdctl/core";
26
+ /**
27
+ * Failures that are USER STATE, not exceptions — a known cause with a known
28
+ * remedy, where a stack trace tells the reader nothing they can act on.
29
+ *
30
+ * Matched against the whole error text because the signal arrives in the failed
31
+ * command's own output, not in the message execa constructs.
32
+ */
33
+ const KNOWN_CAUSES = [
34
+ {
35
+ pattern: /Not logged in|Please run \/login|Invalid API key|OAuth token has expired/i,
36
+ cause: "Claude Code is not logged in — run `claude setup-token`, " +
37
+ "or set CLAUDE_CODE_OAUTH_TOKEN",
38
+ },
39
+ {
40
+ pattern: /Credit balance is too low|insufficient[_ ]quota/i,
41
+ cause: "the Claude account is out of credit",
42
+ },
43
+ {
44
+ // Anchored to `claude` specifically. A bare /ENOENT/ would claim a missing
45
+ // CLI for any file the engine failed to open, which is a confident wrong
46
+ // answer — worse than the raw error it replaces.
47
+ pattern: /ENOENT[^\n]{0,40}claude|claude: (command )?not found/i,
48
+ cause: "the `claude` CLI is not on PATH (needed by the sweeper and triggers)",
49
+ },
50
+ ];
51
+ /**
52
+ * The one-line, actionable reason this failed, if it is one we recognise.
53
+ *
54
+ * `undefined` means "no idea" — and an unknown failure keeps its full detail,
55
+ * because that is exactly the case where somebody needs it.
56
+ */
57
+ export function classifyAgentError(err) {
58
+ const text = agentErrorText(err);
59
+ return KNOWN_CAUSES.find((k) => k.pattern.test(text))?.cause;
60
+ }
61
+ /** Everything an error carries as text — message, and stdio execa attached. */
62
+ function agentErrorText(err) {
63
+ if (typeof err === "string")
64
+ return err;
65
+ const e = err;
66
+ return [e?.message, e?.stdout, e?.stderr].filter((v) => typeof v === "string").join("\n");
67
+ }
68
+ /**
69
+ * The same error with the reconstructed `claude` argv cut out.
70
+ *
71
+ * execa's message quotes the entire command it ran, which for the sweeper is
72
+ * ~2 KB of system prompt followed by the full `--disallowedTools` list. It is
73
+ * noise to an end user and it buries the signal; it also means the curator
74
+ * prompt is copied into any log or paste of the failure. Everything before the
75
+ * argv (`Command failed with exit code 1`) is kept, because the exit status is
76
+ * the part that is actually diagnostic.
77
+ *
78
+ * Full text is still one env var away — see {@link installHerdctlLogBridge}.
79
+ */
80
+ export function summariseAgentError(message) {
81
+ return message.replace(
82
+ // Up to and including "claude", then a flag, then everything after it.
83
+ /(:\s*)claude(\.exe)?\s+-{1,2}[\s\S]*/, "$1claude … (argv omitted — set HERDCTL_LOG_LEVEL=debug for the full command)");
84
+ }
85
+ /**
86
+ * Route `@herdctl/core`'s logging through a handler that shapes agent failures.
87
+ *
88
+ * Two behaviours, and they are separate on purpose:
89
+ *
90
+ * - **Always** summarise the argv. The system prompt in a `claude -p` command
91
+ * line is never what somebody is reading the log for, on a laptop or a
92
+ * server. `HERDCTL_LOG_LEVEL=debug` turns it back on — at debug you have
93
+ * asked for everything, and the argv is genuinely useful there.
94
+ * - **In quiet mode** (the CLI's default), collapse a recognised failure to its
95
+ * cause. `LOG_LEVEL=warn` was never enough for this: core logs these at
96
+ * `error`, which passes every level filter there is, and one of them is a
97
+ * bare `console.error`. Quiet-by-default meant "quiet when nothing goes
98
+ * wrong", which is the easy half.
99
+ *
100
+ * Idempotent; installing again replaces the handler. Call `setLogHandler(null)`
101
+ * to restore core's own output.
102
+ */
103
+ export function installHerdctlLogBridge(options) {
104
+ const debug = LOG_LEVEL_ORDER[getLogLevel()] <= LOG_LEVEL_ORDER.debug;
105
+ setLogHandler((level, prefix, message, data) => {
106
+ if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[getLogLevel()])
107
+ return;
108
+ let text = debug ? message : summariseAgentError(message);
109
+ if (options.quiet && level === "error") {
110
+ const cause = classifyAgentError(message);
111
+ // A known cause replaces the message rather than annotating it: the
112
+ // reader does not need the exit code as well as the reason, and the
113
+ // failure is non-fatal — the server is still up and still serving.
114
+ if (cause !== undefined)
115
+ text = `${cause} — run with --verbose for the full error`;
116
+ }
117
+ const line = data === undefined ? `[${prefix}] ${text}` : `[${prefix}] ${text} ${JSON.stringify(data)}`;
118
+ if (level === "error")
119
+ console.error(line);
120
+ else if (level === "warn")
121
+ console.warn(line);
122
+ else if (level === "info")
123
+ console.info(line);
124
+ else
125
+ console.debug(line);
126
+ });
127
+ }
@@ -19,7 +19,10 @@ import { createRequire } from "node:module";
19
19
  import { promises as fs } from "node:fs";
20
20
  import path from "node:path";
21
21
  import { loadPaddockConfig } from "./config.js";
22
- import { ensureClaudeHome, countLegacyTranscriptLinks } from "./claude-home.js";
22
+ import { ensureClaudeHome, countLegacyTranscriptLinks, findPlantedChatsLinks, } from "./claude-home.js";
23
+ import { loadHostMcpSource } from "./claude-mcp.js";
24
+ import { declaredMcpNotices } from "./mcp-servers.js";
25
+ import { installHerdctlLogBridge } from "./agent-errors.js";
23
26
  import { ProjectStore, ROOT_KEY } from "./projects.js";
24
27
  import { AttachmentStore } from "./attachments.js";
25
28
  import { HerdctlService } from "./herdctl.js";
@@ -74,6 +77,11 @@ function hasFileExtension(pathname) {
74
77
  */
75
78
  export async function buildApp(opts = {}) {
76
79
  const cfg = opts.config ?? loadPaddockConfig();
80
+ // Shape the engine's own logging before anything can start a job (#684). Two
81
+ // of the three sources of the credential-failure stack-trace wall are inside
82
+ // `@herdctl/core`, and `setLogHandler` is the supported way to reach them from
83
+ // out here. `PADDOCK_QUIET` is set by `cli/paddock.ts` unless `--verbose`.
84
+ installHerdctlLogBridge({ quiet: (process.env.PADDOCK_QUIET ?? "") !== "" });
77
85
  const app = Fastify({
78
86
  logger: { level: cfg.logLevel },
79
87
  });
@@ -102,7 +110,32 @@ export async function buildApp(opts = {}) {
102
110
  // --- project layer + herdctl ------------------------------------------
103
111
  const projects = new ProjectStore(cfg.projectsRoot);
104
112
  await projects.init();
105
- const herdctl = new HerdctlService(cfg);
113
+ // The user's own MCP servers, under `claude.mcpServers: host` (#691 step 5).
114
+ // Read BEFORE the service is constructed because every keeper's agent config
115
+ // carries them, and read exactly once — a `.claude.json` that grows a server
116
+ // mid-run is picked up at the next restart, which is what the notice says.
117
+ // Under `own` (the default) the file is not opened at all.
118
+ const hostMcp = await loadHostMcpSource(cfg);
119
+ for (const notice of hostMcp.notices)
120
+ app.log[notice.level](notice.message);
121
+ // …and the servers this instance declares for ITSELF, from the top-level
122
+ // `mcpServers:` block (#691 step 6). Resolved during config load (it needs
123
+ // `process.env` for the `env:VAR` references), logged here because that is
124
+ // where the logger is. Nothing below carries a value out of the block: the
125
+ // diagnostics name keys and variable names only, and every server mentioned in
126
+ // a notice is rendered by `describeServer`.
127
+ for (const message of cfg.mcpServersDiagnostics.errors)
128
+ app.log.error(message);
129
+ for (const message of cfg.mcpServersDiagnostics.warnings)
130
+ app.log.warn(message);
131
+ for (const notice of declaredMcpNotices({
132
+ servers: cfg.mcpServers,
133
+ hostNames: Object.keys(hostMcp.source.user),
134
+ browserMcp: cfg.browserMcp,
135
+ })) {
136
+ app.log[notice.level](notice.message);
137
+ }
138
+ const herdctl = new HerdctlService(cfg, { ...hostMcp.source, declared: cfg.mcpServers });
106
139
  const git = new GitService(cfg.projectsRoot, cfg.gitAuthor);
107
140
  const githubAuth = new GithubAuth(path.join(cfg.dataDir, "github-auth.json"), cfg.githubClientId);
108
141
  const archive = new ArchiveStore(cfg.dataDir);
@@ -157,13 +190,28 @@ export async function buildApp(opts = {}) {
157
190
  for (const notice of (await ensureClaudeHome(cfg)).notices) {
158
191
  app.log[notice.level](notice.message);
159
192
  }
160
- if (cfg.ownsClaudeHome) {
161
- const stale = await countLegacyTranscriptLinks(cfg.legacyClaudeHome, cfg.dataDir);
162
- if (stale > 0) {
163
- app.log.info(`${stale} transcript symlink(s) from a previous layout still point into ${cfg.dataDir} ` +
164
- `from ${path.join(cfg.legacyClaudeHome, "projects")}. Nothing reads them any more; ` +
165
- `paddock leaves them alone because it does not write to ~/.claude. Safe to delete.`);
166
- }
193
+ // Unconditional since #691 — paddock always owns its home, so there is no
194
+ // longer a layout in which these links are the live ones.
195
+ const stale = await countLegacyTranscriptLinks(cfg.legacyClaudeHome, cfg.dataDir);
196
+ if (stale > 0) {
197
+ app.log.info(`${stale} transcript symlink(s) from a previous layout still point into ${cfg.dataDir} ` +
198
+ `from ${path.join(cfg.legacyClaudeHome, "projects")}. Nothing reads them any more; ` +
199
+ `paddock leaves them alone because it does not write to ~/.claude. Safe to delete.`);
200
+ }
201
+ // #682: links an affected build planted at a path the user had not used yet.
202
+ // Unlike the residue above these are still LIVE — they redirect the user's own
203
+ // future `claude` sessions — so they warn, and name every path. Paddock does
204
+ // not remove them; it does not write to `~/.claude`.
205
+ const poisoned = await findPlantedChatsLinks(cfg.legacyClaudeHome, cfg.dataDir);
206
+ if (poisoned.length > 0) {
207
+ app.log.warn(`${poisoned.length} symlink(s) in ${path.join(cfg.legacyClaudeHome, "projects")} redirect ` +
208
+ `Claude Code's transcripts into a paddock .chats/ store (#682). While they exist, ` +
209
+ `\`claude\` sessions you start in those directories are written to paddock's store, and ` +
210
+ `deleting that store destroys them. Paddock does not remove anything under ~/.claude — ` +
211
+ `delete them yourself if you did not intend this:\n` +
212
+ poisoned
213
+ .map((p) => ` ${p.link} -> ${p.target}${p.dangling ? " (target is missing)" : ""}`)
214
+ .join("\n"));
167
215
  }
168
216
  const rootWorkspace = await projects.get(ROOT_KEY);
169
217
  const initialProjects = [...(await projects.list()), rootWorkspace];
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The `claude.credentials` lever (#691): whose Claude Code LOGIN this instance
3
+ * uses.
4
+ *
5
+ * Paddock always owns its Claude home (`<dataDir>/claude-home`, #691), and that
6
+ * is what made a macOS login invisible (#683). Claude Code files its secure
7
+ * storage entry under a service name derived from the config dir, so the moment
8
+ * paddock points `CLAUDE_CONFIG_DIR` at its own home, `claude /login`'s Keychain
9
+ * entry is under a name paddock's agents never look up. On a Mac with no token in
10
+ * the environment that is an instance which boots cleanly and fails every single
11
+ * turn with "Not logged in".
12
+ *
13
+ * ## The mechanism
14
+ *
15
+ * Read out of the SDK bundle (`@anthropic-ai/claude-agent-sdk/sdk.mjs`), which
16
+ * derives the service name like this:
17
+ *
18
+ * ```js
19
+ * var _z = "-credentials";
20
+ * function yz(e = "") {
21
+ * let t = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,
22
+ * r = t !== undefined ? !t : !process.env.CLAUDE_CONFIG_DIR,
23
+ * n = t !== undefined ? t.normalize("NFC") : Yt(),
24
+ * o = r ? "" : `-${sha256(n).digest("hex").substring(0, 8)}`;
25
+ * return `Claude Code${OAUTH_FILE_SUFFIX}${e}${o}`;
26
+ * }
27
+ * ```
28
+ *
29
+ * `OAUTH_FILE_SUFFIX` is `""` in production and the only caller passes `_z`, so a
30
+ * plain `claude /login` files under exactly **`Claude Code-credentials`** — which
31
+ * is the name `probeMacosKeychainLogin` looks for, now confirmed rather than
32
+ * guessed.
33
+ *
34
+ * The load-bearing line is `r`. **`CLAUDE_SECURESTORAGE_CONFIG_DIR`, when
35
+ * DEFINED, overrides `CLAUDE_CONFIG_DIR` for secure storage only.** Define it as
36
+ * the EMPTY STRING and `!t` is true, so the hash suffix is dropped and the
37
+ * plain-name entry — the user's real login — is the one read, while
38
+ * `CLAUDE_CONFIG_DIR` still points at paddock's own home. That decoupling is the
39
+ * whole lever: one variable, no home moved, nothing else shared.
40
+ *
41
+ * The empty string is a value, not a mistake. Anywhere it looks like a bug to
42
+ * "clean up", it is the signal.
43
+ *
44
+ * ## Why this defaults to `host`, against the pattern
45
+ *
46
+ * Every other key in the `claude:` block defaults to `own`, and this one does
47
+ * not. **Isolation is about writes.** Reading a Keychain entry (or a symlinked
48
+ * `~/.claude/.credentials.json`) puts no file of the user's at risk: nothing is
49
+ * created, moved or deleted, and the user's own `claude` is unaffected. What
50
+ * defaulting to `own` WOULD do is recreate #683 — a first run that boots fine,
51
+ * says everything is ready, and fails every turn — which is the worst first
52
+ * impression paddock can make and the one it just made.
53
+ *
54
+ * So the guarantee `own` everywhere buys ("nothing outside the data dir is
55
+ * written") survives this default intact, and the deliberate exception is stated
56
+ * here rather than left to be inferred from a table.
57
+ */
58
+ /**
59
+ * Whose Claude Code login this instance uses — the `claude.credentials` key.
60
+ *
61
+ * `own` = only what is inside paddock's own Claude home: a token in the
62
+ * environment, or a `.credentials.json` an operator put there / logged in with.
63
+ * `host` = this machine's Claude Code login as well — the macOS Keychain entry on
64
+ * darwin, and the user's `~/.claude/.credentials.json` (bridged by symlink) on
65
+ * everything else.
66
+ */
67
+ export type CredentialsMode = "own" | "host";
68
+ /** Sharing a login writes nothing. See the module doc for why this is not `own`. */
69
+ export declare const DEFAULT_CREDENTIALS_MODE: CredentialsMode;
70
+ /** Type guard, so an unknown config value falls back instead of failing a boot. */
71
+ export declare function isKnownCredentialsMode(value: string): value is CredentialsMode;
72
+ /**
73
+ * The variable Claude Code resolves its SECURE-STORAGE scope from, independently
74
+ * of `CLAUDE_CONFIG_DIR`. Not exported by the SDK; read from its bundle.
75
+ */
76
+ export declare const SECURE_STORAGE_DIR_VAR = "CLAUDE_SECURESTORAGE_CONFIG_DIR";
77
+ /**
78
+ * The value that means "use the unsuffixed service name" — i.e. the entry a
79
+ * plain `claude /login` wrote. Empty on purpose; see the module doc.
80
+ */
81
+ export declare const HOST_SECURE_STORAGE_DIR = "";
82
+ /** What {@link applyCredentialsMode} did, for the boot notice. */
83
+ export type CredentialsDecision =
84
+ /** `host`: the variable was set to `""`, so the plain-name login is used. */
85
+ {
86
+ action: "shared";
87
+ }
88
+ /** `own`: the variable is unset, so the login is scoped to paddock's home. */
89
+ | {
90
+ action: "isolated";
91
+ }
92
+ /** An operator set the variable to something of their own; left alone. */
93
+ | {
94
+ action: "deferred";
95
+ value: string;
96
+ };
97
+ /**
98
+ * Put this instance's credentials mode into the environment Claude Code runs in.
99
+ *
100
+ * **Mutates `env`, and by default that is `process.env` — deliberately.** There
101
+ * is no per-invocation seam to use instead: herdctl builds the SDK's `options.env`
102
+ * itself (`withClaudeConfigDir`, which spreads `process.env`) and the CLI runtime
103
+ * lets execa merge over the inherited environment, and neither takes an env
104
+ * contribution from paddock. Unlike the Claude home — which herdctl warns must be
105
+ * per-invocation because one process hosts many agents with potentially different
106
+ * homes — the credentials mode is a property of the INSTANCE: every agent in it
107
+ * has the same answer, so a process-wide value cannot leak one agent's setting
108
+ * into another's. Called once, at boot, before the fleet starts.
109
+ *
110
+ * An operator who set the variable to a non-empty value themselves is honoured
111
+ * rather than clobbered, the same courtesy `CLAUDE_CONFIG_DIR` gets (herdctl#423):
112
+ * they have named a third secure-storage scope that neither mode can express. An
113
+ * EMPTY value is not treated that way — it is exactly what `host` writes, so it is
114
+ * ours by construction and `own` clears it.
115
+ */
116
+ export declare function applyCredentialsMode(mode: CredentialsMode, env?: NodeJS.ProcessEnv): CredentialsDecision;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The `claude.credentials` lever (#691): whose Claude Code LOGIN this instance
3
+ * uses.
4
+ *
5
+ * Paddock always owns its Claude home (`<dataDir>/claude-home`, #691), and that
6
+ * is what made a macOS login invisible (#683). Claude Code files its secure
7
+ * storage entry under a service name derived from the config dir, so the moment
8
+ * paddock points `CLAUDE_CONFIG_DIR` at its own home, `claude /login`'s Keychain
9
+ * entry is under a name paddock's agents never look up. On a Mac with no token in
10
+ * the environment that is an instance which boots cleanly and fails every single
11
+ * turn with "Not logged in".
12
+ *
13
+ * ## The mechanism
14
+ *
15
+ * Read out of the SDK bundle (`@anthropic-ai/claude-agent-sdk/sdk.mjs`), which
16
+ * derives the service name like this:
17
+ *
18
+ * ```js
19
+ * var _z = "-credentials";
20
+ * function yz(e = "") {
21
+ * let t = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,
22
+ * r = t !== undefined ? !t : !process.env.CLAUDE_CONFIG_DIR,
23
+ * n = t !== undefined ? t.normalize("NFC") : Yt(),
24
+ * o = r ? "" : `-${sha256(n).digest("hex").substring(0, 8)}`;
25
+ * return `Claude Code${OAUTH_FILE_SUFFIX}${e}${o}`;
26
+ * }
27
+ * ```
28
+ *
29
+ * `OAUTH_FILE_SUFFIX` is `""` in production and the only caller passes `_z`, so a
30
+ * plain `claude /login` files under exactly **`Claude Code-credentials`** — which
31
+ * is the name `probeMacosKeychainLogin` looks for, now confirmed rather than
32
+ * guessed.
33
+ *
34
+ * The load-bearing line is `r`. **`CLAUDE_SECURESTORAGE_CONFIG_DIR`, when
35
+ * DEFINED, overrides `CLAUDE_CONFIG_DIR` for secure storage only.** Define it as
36
+ * the EMPTY STRING and `!t` is true, so the hash suffix is dropped and the
37
+ * plain-name entry — the user's real login — is the one read, while
38
+ * `CLAUDE_CONFIG_DIR` still points at paddock's own home. That decoupling is the
39
+ * whole lever: one variable, no home moved, nothing else shared.
40
+ *
41
+ * The empty string is a value, not a mistake. Anywhere it looks like a bug to
42
+ * "clean up", it is the signal.
43
+ *
44
+ * ## Why this defaults to `host`, against the pattern
45
+ *
46
+ * Every other key in the `claude:` block defaults to `own`, and this one does
47
+ * not. **Isolation is about writes.** Reading a Keychain entry (or a symlinked
48
+ * `~/.claude/.credentials.json`) puts no file of the user's at risk: nothing is
49
+ * created, moved or deleted, and the user's own `claude` is unaffected. What
50
+ * defaulting to `own` WOULD do is recreate #683 — a first run that boots fine,
51
+ * says everything is ready, and fails every turn — which is the worst first
52
+ * impression paddock can make and the one it just made.
53
+ *
54
+ * So the guarantee `own` everywhere buys ("nothing outside the data dir is
55
+ * written") survives this default intact, and the deliberate exception is stated
56
+ * here rather than left to be inferred from a table.
57
+ */
58
+ /** Sharing a login writes nothing. See the module doc for why this is not `own`. */
59
+ export const DEFAULT_CREDENTIALS_MODE = "host";
60
+ /** Type guard, so an unknown config value falls back instead of failing a boot. */
61
+ export function isKnownCredentialsMode(value) {
62
+ return value === "own" || value === "host";
63
+ }
64
+ /**
65
+ * The variable Claude Code resolves its SECURE-STORAGE scope from, independently
66
+ * of `CLAUDE_CONFIG_DIR`. Not exported by the SDK; read from its bundle.
67
+ */
68
+ export const SECURE_STORAGE_DIR_VAR = "CLAUDE_SECURESTORAGE_CONFIG_DIR";
69
+ /**
70
+ * The value that means "use the unsuffixed service name" — i.e. the entry a
71
+ * plain `claude /login` wrote. Empty on purpose; see the module doc.
72
+ */
73
+ export const HOST_SECURE_STORAGE_DIR = "";
74
+ /**
75
+ * Put this instance's credentials mode into the environment Claude Code runs in.
76
+ *
77
+ * **Mutates `env`, and by default that is `process.env` — deliberately.** There
78
+ * is no per-invocation seam to use instead: herdctl builds the SDK's `options.env`
79
+ * itself (`withClaudeConfigDir`, which spreads `process.env`) and the CLI runtime
80
+ * lets execa merge over the inherited environment, and neither takes an env
81
+ * contribution from paddock. Unlike the Claude home — which herdctl warns must be
82
+ * per-invocation because one process hosts many agents with potentially different
83
+ * homes — the credentials mode is a property of the INSTANCE: every agent in it
84
+ * has the same answer, so a process-wide value cannot leak one agent's setting
85
+ * into another's. Called once, at boot, before the fleet starts.
86
+ *
87
+ * An operator who set the variable to a non-empty value themselves is honoured
88
+ * rather than clobbered, the same courtesy `CLAUDE_CONFIG_DIR` gets (herdctl#423):
89
+ * they have named a third secure-storage scope that neither mode can express. An
90
+ * EMPTY value is not treated that way — it is exactly what `host` writes, so it is
91
+ * ours by construction and `own` clears it.
92
+ */
93
+ export function applyCredentialsMode(mode, env = process.env) {
94
+ const operator = env[SECURE_STORAGE_DIR_VAR];
95
+ if (operator !== undefined && operator !== "")
96
+ return { action: "deferred", value: operator };
97
+ if (mode === "host") {
98
+ env[SECURE_STORAGE_DIR_VAR] = HOST_SECURE_STORAGE_DIR;
99
+ return { action: "shared" };
100
+ }
101
+ delete env[SECURE_STORAGE_DIR_VAR];
102
+ return { action: "isolated" };
103
+ }