@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.
- package/README.md +36 -4
- package/package.json +1 -1
- package/packages/server/dist/agent-errors.d.ts +41 -0
- package/packages/server/dist/agent-errors.js +127 -0
- package/packages/server/dist/app.js +57 -9
- package/packages/server/dist/claude-credentials.d.ts +116 -0
- package/packages/server/dist/claude-credentials.js +103 -0
- package/packages/server/dist/claude-home.d.ts +164 -25
- package/packages/server/dist/claude-home.js +528 -77
- package/packages/server/dist/claude-instructions.d.ts +121 -0
- package/packages/server/dist/claude-instructions.js +114 -0
- package/packages/server/dist/claude-mcp.d.ts +146 -0
- package/packages/server/dist/claude-mcp.js +367 -0
- package/packages/server/dist/claude-settings.d.ts +159 -0
- package/packages/server/dist/claude-settings.js +159 -0
- package/packages/server/dist/cli/args.d.ts +1 -1
- package/packages/server/dist/cli/args.js +39 -5
- package/packages/server/dist/cli/paddock.js +30 -6
- package/packages/server/dist/config.d.ts +166 -30
- package/packages/server/dist/config.js +106 -26
- package/packages/server/dist/herdctl-agent-config.d.ts +11 -1
- package/packages/server/dist/herdctl-agent-config.js +53 -7
- package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
- package/packages/server/dist/herdctl-agent-names.js +31 -0
- package/packages/server/dist/herdctl.d.ts +69 -13
- package/packages/server/dist/herdctl.js +70 -18
- package/packages/server/dist/instance-config.js +36 -0
- package/packages/server/dist/mcp-servers.d.ts +168 -0
- package/packages/server/dist/mcp-servers.js +305 -0
- package/packages/server/dist/routes/chats.js +10 -4
- package/packages/server/dist/sweep.js +13 -1
- package/packages/server/dist/transcripts.d.ts +62 -24
- package/packages/server/dist/transcripts.js +141 -28
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CzMqTdUU.js → InstanceConfigPage-Ca905oey.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-YSEZHWDe.js → MarkdownRenderer-ChlC1ezH.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-DLWxZbqK.js → ProjectRedirect-CslqbhUb.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-BFX8ff2n.js → ProjectView-DkNdilGo.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-DLOeY_ww.js → ProjectsGrid-BrlvLc-Y.js} +1 -1
- package/packages/web/dist/assets/{arc-BwC1ik-b.js → arc-CGqCI5nQ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CsVvXQWw.js → architectureDiagram-3BPJPVTR-Cp4KTxE2.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DXQk2bIa.js → blockDiagram-GPEHLZMM-yFxaTv04.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DXiA26It.js → c4Diagram-AAUBKEIU-59lhWjRt.js} +1 -1
- package/packages/web/dist/assets/channel-CZs6Z1i5.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-Dt5nC02A.js → chunk-2J33WTMH-BrpXGnJg.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DcjnWvl6.js → chunk-4BX2VUAB-UxBDb5pi.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CRa3LkU4.js → chunk-55IACEB6-D4ZJyUHW.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-H3V-11qu.js → chunk-727SXJPM-DWdPj_8E.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DPCrENYV.js → chunk-AQP2D5EJ-BIpNbflY.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-C4UO0ZTc.js → chunk-FMBD7UC4-f6iJDfdI.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-BtI37CnI.js → chunk-ND2GUHAM-Bz3TKIJk.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-C5bTaK5j.js → chunk-QZHKN3VN-buQmkafK.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +2 -0
- package/packages/web/dist/assets/{core-DB3cpm5N.js → core-TyFI6p0n.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-q-cjTZO4.js → cose-bilkent-S5V4N54A-CIkdi0XF.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DYEV5HFo.js → dagre-BM42HDAG-BnLJCfNs.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-D16XCw78.js → diagram-2AECGRRQ-D0OqfDB9.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-CeztryYE.js → diagram-5GNKFQAL-DQ2spGrK.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-D5SLIMDS.js → diagram-KO2AKTUF-RDIVQ_S3.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-Bw-sVPxM.js → diagram-LMA3HP47-DKoTfh2C.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DsRMITgx.js → diagram-OG6HWLK6-6k0OuSAL.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-T8Qqc_ku.js → erDiagram-TEJ5UH35-B-oQaYAC.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Cyx47UWU.js → flowDiagram-I6XJVG4X-B7qpYe9C.js} +1 -1
- package/packages/web/dist/assets/{format-D2I0duZa.js → format-PHdEBNOi.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DxYJmG9o.js → ganttDiagram-6RSMTGT7-SfBsqMmx.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-COK5YOTD.js → gitGraphDiagram-PVQCEYII-CuM4Rl_i.js} +1 -1
- package/packages/web/dist/assets/{index-wBXQMkGI.js → index-D_1ogxg1.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BamRN_Nn.js → infoDiagram-5YYISTIA-Dq-32Oj4.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DuOHdpEH.js → ishikawaDiagram-YF4QCWOH-BLFHCj_o.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bri7GKTW.js → journeyDiagram-JHISSGLW-CHP-Yu63.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BdkgUnzI.js → kanban-definition-UN3LZRKU-DRuehSqn.js} +1 -1
- package/packages/web/dist/assets/{linear-DOcNPCpB.js → linear-Bwz2vnH7.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D9SbVBpN.js → mermaid.core-CeMMnCip.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CVmLm7zn.js → mindmap-definition-RKZ34NQL-CEdqeoRW.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D0N10eVU.js → pieDiagram-4H26LBE5-BdDXBx87.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B5gnYdTa.js → quadrantDiagram-W4KKPZXB-Bovd1Zhx.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CwtxezNZ.js → requirementDiagram-4Y6WPE33-DiyrKnJ2.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D79O3cux.js → sankeyDiagram-5OEKKPKP-BuX7TRVw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-xhKcI6tI.js → sequenceDiagram-3UESZ5HK-BjetZWJo.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DBYMAjcX.js → stateDiagram-AJRCARHV-4AUBV6dF.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-e-MYF4lm.js → timeline-definition-PNZ67QCA-BpQ8g8Bm.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B8UsvRDM.js → vennDiagram-CIIHVFJN-Cj99BWER.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-1ucACjTl.js → wardley-L42UT6IY-S7AGTOWw.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DJufLd_u.js → wardleyDiagram-YWT4CUSO-LNXkUA7S.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-qqaZNlu9.js → xychartDiagram-2RQKCTM6-DGdTh4Xk.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/channel-DDgTXrGP.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +0 -2
- 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,
|
|
30
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}
|