@edspencer/paddock 0.61.1 → 0.63.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 (94) hide show
  1. package/README.md +65 -16
  2. package/package.json +2 -2
  3. package/packages/server/dist/app.js +48 -8
  4. package/packages/server/dist/claude-credentials.d.ts +116 -0
  5. package/packages/server/dist/claude-credentials.js +103 -0
  6. package/packages/server/dist/claude-home.d.ts +102 -30
  7. package/packages/server/dist/claude-home.js +437 -97
  8. package/packages/server/dist/claude-instructions.d.ts +121 -0
  9. package/packages/server/dist/claude-instructions.js +114 -0
  10. package/packages/server/dist/claude-mcp.d.ts +158 -0
  11. package/packages/server/dist/claude-mcp.js +389 -0
  12. package/packages/server/dist/claude-plugins.d.ts +131 -0
  13. package/packages/server/dist/claude-plugins.js +394 -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 -9
  17. package/packages/server/dist/cli/args.js +46 -19
  18. package/packages/server/dist/cli/paddock.js +20 -39
  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 +17 -1
  22. package/packages/server/dist/herdctl-agent-config.js +69 -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 +75 -13
  26. package/packages/server/dist/herdctl.js +77 -18
  27. package/packages/server/dist/instance-config.js +36 -0
  28. package/packages/server/dist/mcp-servers.d.ts +187 -0
  29. package/packages/server/dist/mcp-servers.js +399 -0
  30. package/packages/server/dist/routes/chats.js +10 -4
  31. package/packages/server/dist/transcripts.d.ts +61 -30
  32. package/packages/server/dist/transcripts.js +141 -46
  33. package/packages/server/package.json +1 -1
  34. package/packages/web/dist/assets/{InstanceConfigPage-BgNXqAox.js → InstanceConfigPage-CCk2hVT9.js} +1 -1
  35. package/packages/web/dist/assets/{MarkdownRenderer-DfD_UtqU.js → MarkdownRenderer-B6gST_0U.js} +1 -1
  36. package/packages/web/dist/assets/{ProjectRedirect-1EXdHXmO.js → ProjectRedirect-BCmKT4mf.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectView-DHb5vNUp.js → ProjectView-CCN98MWu.js} +3 -3
  38. package/packages/web/dist/assets/{ProjectsGrid-Br-EhLZE.js → ProjectsGrid-CYzuN_yD.js} +1 -1
  39. package/packages/web/dist/assets/{arc-C2Jekujs.js → arc-BxvtMCZQ.js} +1 -1
  40. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CflCA-em.js → architectureDiagram-3BPJPVTR-Bp6ffJ_u.js} +1 -1
  41. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Ds7GLNnC.js → blockDiagram-GPEHLZMM-BIY2X8nW.js} +1 -1
  42. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D4WyGO6I.js → c4Diagram-AAUBKEIU-Br9stEJS.js} +1 -1
  43. package/packages/web/dist/assets/channel-CMdgNeFH.js +2 -0
  44. package/packages/web/dist/assets/{chunk-2J33WTMH-tQunO_M5.js → chunk-2J33WTMH-Djc7uqMV.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-4BX2VUAB-DT5dOWd2.js → chunk-4BX2VUAB-yEVMLksS.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-55IACEB6-CfTUqUEF.js → chunk-55IACEB6-tJaEdPZg.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-727SXJPM-sK5bOAA0.js → chunk-727SXJPM-bWEqh672.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DcMgVe-P.js → chunk-AQP2D5EJ-B8U9fB-T.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-FMBD7UC4-In3h_-7v.js → chunk-FMBD7UC4-BeEITYig.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-ND2GUHAM-DuaIeRaC.js → chunk-ND2GUHAM-L2rpLaoK.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-QZHKN3VN-DeT5mR5T.js → chunk-QZHKN3VN-qqzPMZL_.js} +1 -1
  52. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +2 -0
  53. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +2 -0
  54. package/packages/web/dist/assets/{core-DgOgLvnM.js → core-CSaNbmLO.js} +1 -1
  55. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CyO-rznJ.js → cose-bilkent-S5V4N54A-BRF7Nech.js} +1 -1
  56. package/packages/web/dist/assets/{dagre-BM42HDAG-B_9Ihzgn.js → dagre-BM42HDAG-D10rJWOR.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-2AECGRRQ-z5mvZ5YT.js → diagram-2AECGRRQ-DGjT5mAO.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-5GNKFQAL-Bsz0cgfn.js → diagram-5GNKFQAL-DaH3bxHh.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-KO2AKTUF-B29k5f4q.js → diagram-KO2AKTUF-QrLW9Wn6.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-LMA3HP47-CFrwsHoI.js → diagram-LMA3HP47-BX15wgYN.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-OG6HWLK6-D9Sjc2HR.js → diagram-OG6HWLK6-DXQ_p63s.js} +1 -1
  62. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C3vbkD7M.js → erDiagram-TEJ5UH35-C_ioRruE.js} +1 -1
  63. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DkMIgntd.js → flowDiagram-I6XJVG4X-7d-xLXLM.js} +1 -1
  64. package/packages/web/dist/assets/{format-BFDqyFuw.js → format-BSDZezwD.js} +1 -1
  65. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-nvHanjpW.js → ganttDiagram-6RSMTGT7-DZYx5xcj.js} +1 -1
  66. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DbCNfOu7.js → gitGraphDiagram-PVQCEYII-dimo-osm.js} +1 -1
  67. package/packages/web/dist/assets/{index-DGCs1YIO.js → index-CUnFKMga.js} +2 -2
  68. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-aJjH9x3D.js → infoDiagram-5YYISTIA-BzApY25x.js} +1 -1
  69. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DoifwNnZ.js → ishikawaDiagram-YF4QCWOH-DtX47_9p.js} +1 -1
  70. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Cz9kG9HN.js → journeyDiagram-JHISSGLW-C7fITKg1.js} +1 -1
  71. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BoXnfr7A.js → kanban-definition-UN3LZRKU-BOrWLf_W.js} +1 -1
  72. package/packages/web/dist/assets/{linear-BlTTO9yR.js → linear-DuyAaBu6.js} +1 -1
  73. package/packages/web/dist/assets/{mermaid.core-YAG8eSPQ.js → mermaid.core-BQlzuVvO.js} +4 -4
  74. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DtUBKfkr.js → mindmap-definition-RKZ34NQL-CmP748B5.js} +1 -1
  75. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-B3Rwpmy0.js → pieDiagram-4H26LBE5-CRd5Pdfq.js} +1 -1
  76. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B8eJtTyk.js → quadrantDiagram-W4KKPZXB-DpJm0Yw_.js} +1 -1
  77. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CpAluiY9.js → requirementDiagram-4Y6WPE33-B0o2PRMC.js} +1 -1
  78. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js → sankeyDiagram-5OEKKPKP-CBi3Ek3v.js} +1 -1
  79. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CRpqKSGm.js → sequenceDiagram-3UESZ5HK-C1stxfQg.js} +1 -1
  80. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-h3h8gXv_.js → stateDiagram-AJRCARHV-lCkiETWK.js} +1 -1
  81. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +2 -0
  82. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-PVr5V_pG.js → timeline-definition-PNZ67QCA-D3DkWJMC.js} +1 -1
  83. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-De-4NPAX.js → vennDiagram-CIIHVFJN-CO2-yC7K.js} +1 -1
  84. package/packages/web/dist/assets/{wardley-L42UT6IY-Dtv10D2d.js → wardley-L42UT6IY-Dfjp0kqX.js} +1 -1
  85. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-p2fJuQ6C.js → wardleyDiagram-YWT4CUSO-CIl0OPwS.js} +1 -1
  86. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DyYF-k92.js → xychartDiagram-2RQKCTM6-CuvlQuup.js} +1 -1
  87. package/packages/web/dist/index.html +1 -1
  88. package/packages/web/dist/sw.js +1 -1
  89. package/packages/server/dist/cli/claude-home-choice.d.ts +0 -44
  90. package/packages/server/dist/cli/claude-home-choice.js +0 -108
  91. package/packages/web/dist/assets/channel-B1K_gwAH.js +0 -2
  92. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +0 -2
  93. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +0 -2
  94. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +0 -2
@@ -15,7 +15,8 @@
15
15
  * READ-ONLY source. This module owns everything that follows from that:
16
16
  *
17
17
  * - **{@link ensureClaudeHome}** — create the home, bridge the user-level config
18
- * that lives in `~/.claude`, and report anything an operator needs to know.
18
+ * that lives in `~/.claude`, put `claude.credentials` into the environment
19
+ * Claude Code runs in, and report anything an operator needs to know.
19
20
  * - **{@link mirrorLegacyTranscriptFolders}** — keep adoption (#588) able to see
20
21
  * the user's own CLI history, which by definition is in `~/.claude`.
21
22
  *
@@ -30,39 +31,74 @@
30
31
  * what a user wants; it is not paddock relocating their data behind their back.
31
32
  * The thing #620 exists to stop — `ensureProjectChats` copying a user's
32
33
  * transcripts out and then `fs.rm`-ing the originals, unprompted, inside a bare
33
- * `catch {}` — is gone, gated on `PaddockConfig.ownsClaudeHome`.
34
+ * `catch {}` — is gone: paddock's home is never the user's, so the directory it
35
+ * migrates can only be its own (#691, `resolveClaudeHome`).
36
+ *
37
+ * The one thing that DOES write under `~/.claude` is `claude.transcripts: host`,
38
+ * which creates the encoded folder its symlink points at. That is the user
39
+ * asking for their transcripts to be shared, by name, in a config file — see
40
+ * `ensureProjectChats`.
34
41
  */
35
42
  import { promises as fs } from "node:fs";
43
+ import { createHash } from "node:crypto";
36
44
  import path from "node:path";
37
45
  import { encodePathForCli } from "@herdctl/core";
46
+ import { applyCredentialsMode, SECURE_STORAGE_DIR_VAR } from "./claude-credentials.js";
47
+ import { INSTRUCTION_ENTRIES } from "./claude-instructions.js";
48
+ import { planHostSettings, GENERATED_MARKER_FILE, SETTINGS_ENTRY, } from "./claude-settings.js";
49
+ /**
50
+ * The user's file-based Claude Code login, bridged in only under
51
+ * `claude.credentials: host` (#691).
52
+ *
53
+ * This is what the lever means on Linux and in the Docker image, where `claude
54
+ * login` writes a file rather than using a keychain: on darwin `credentials: host`
55
+ * is the `CLAUDE_SECURESTORAGE_CONFIG_DIR` empty-string trick, here it is this
56
+ * symlink, and one config key has to mean the same thing on both.
57
+ *
58
+ * Symlinked rather than copied — a copy would duplicate a live secret onto disk
59
+ * AND diverge the moment Claude Code refreshed the token.
60
+ *
61
+ * It was bridged unconditionally before the lever existed, which is why
62
+ * `credentials` defaulting to `host` keeps every existing install working
63
+ * unchanged; `own` is the new behaviour, and it has to also REMOVE a link a
64
+ * previous boot planted, or "isolated" would be a claim that only holds for
65
+ * instances that were never anything else.
66
+ */
67
+ export const CREDENTIAL_ENTRY = ".credentials.json";
38
68
  /**
39
- * Entries of a Claude home that are USER-LEVEL configuration rather than
40
- * per-instance state, symlinked from the user's home into paddock's own when
41
- * paddock's does not already have its own.
42
- *
43
- * Without this, relocating the home silently changes agent behaviour: the user's
44
- * `~/.claude/CLAUDE.md` is auto-loaded into every session, `settings.json`
45
- * carries permissions and hooks, and `agents/` `commands/` `plugins/` are
46
- * capability. Dropping them is not "a clean home", it is a behaviour regression
47
- * nobody asked for.
48
- *
49
- * `.credentials.json` is the one that matters most: on Linux (and in the Docker
50
- * image) it is where `claude login` puts the OAuth token, so without the bridge
51
- * every file-authenticated install would come back logged out. Symlinked rather
52
- * than copied — a copy would duplicate a live secret onto disk AND diverge the
53
- * moment Claude Code refreshed the token.
54
- *
55
- * Deliberately NOT bridged: `projects/` (that's the whole point), and everything
56
- * that is per-instance runtime state — `todos/`, `shell-snapshots/`, `statsig/`,
57
- * `sessions/`. Those are exactly what should now be per-instance.
69
+ * Entries of the user's Claude home that paddock can bridge into its own — the
70
+ * complete list, for callers that want to reason about the surface rather than
71
+ * about one lever.
72
+ *
73
+ * There is no longer an "always bridged" set: since #691 step 4, every entry here
74
+ * belongs to exactly one key of the `claude:` block, and the mapping is the whole
75
+ * point of the block.
76
+ *
77
+ * | Entry | Lever |
78
+ * |---|---|
79
+ * | `CLAUDE.md`, `agents/`, `commands/`, `plugins/` | `claude.instructions` |
80
+ * | `settings.json` (its `hooks` key) | `claude.hooks` |
81
+ * | `.credentials.json` | `claude.credentials` |
82
+ *
83
+ * `settings.json` appears under `hooks` because that is the half of it a lever
84
+ * governs; its OTHER keys — permissions, model, statusline, `enabledPlugins` —
85
+ * are inherited in every mode, which is why `hooks: own` writes a filtered file
86
+ * rather than declining to bridge one. See `claude-settings.ts`.
87
+ *
88
+ * Deliberately NOT bridged in any mode: `projects/` (that's the whole point), and
89
+ * everything that is per-instance runtime state — `todos/`, `shell-snapshots/`,
90
+ * `statsig/`, `sessions/`. Those are exactly what should be per-instance.
91
+ *
92
+ * The fifth lever, `claude.mcpServers`, has NO row here on purpose: MCP servers
93
+ * are declared in `~/.claude.json`, which is a sibling of the home rather than an
94
+ * entry inside it, so it is not bridgeable at all — paddock reads it and passes
95
+ * the servers programmatically (`claude-mcp.ts`). That asymmetry is the reason
96
+ * MCP inheritance broke separately from everything in this table.
58
97
  */
59
- export const BRIDGED_ENTRIES = [
60
- ".credentials.json",
61
- "settings.json",
62
- "CLAUDE.md",
63
- "agents",
64
- "commands",
65
- "plugins",
98
+ export const BRIDGEABLE_ENTRIES = [
99
+ SETTINGS_ENTRY,
100
+ ...INSTRUCTION_ENTRIES,
101
+ CREDENTIAL_ENTRY,
66
102
  ];
67
103
  /** Env vars that authenticate Claude Code without any on-disk credential. */
68
104
  const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
@@ -74,9 +110,13 @@ const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROP
74
110
  * home can already see. What we are looking for is the login made before paddock
75
111
  * was involved.
76
112
  *
77
- * More than one candidate because the name is Claude Code's private detail, not
78
- * a contract — see {@link probeMacosKeychainLogin} for why guessing wrong here
79
- * is designed to be harmless.
113
+ * The first name is now **confirmed exact**, not guessed (#691): the SDK bundle
114
+ * builds it as `` `Claude Code${OAUTH_FILE_SUFFIX}${"-credentials"}${hash}` ``,
115
+ * with an empty production suffix and no hash when the config dir is unscoped —
116
+ * see `claude-credentials.ts`, which quotes the function. The second is kept as a
117
+ * cheap hedge against an older or future spelling; it costs one `security` call
118
+ * that fails fast, and {@link probeMacosKeychainLogin} is built so a miss is
119
+ * harmless either way.
80
120
  */
81
121
  const KEYCHAIN_SERVICE_NAMES = ["Claude Code-credentials", "Claude Code"];
82
122
  /**
@@ -90,15 +130,24 @@ const KEYCHAIN_SERVICE_NAMES = ["Claude Code-credentials", "Claude Code"];
90
130
  * ## The asymmetry, which is deliberate
91
131
  *
92
132
  * A positive result is trustworthy: an entry under that service name is a login.
93
- * A negative result is NOT — the service name is Claude Code's internal detail
94
- * and can change, and `security` can fail for a dozen reasons that have nothing
95
- * to do with the user. So a miss returns `unknown`, and the caller falls back to
96
- * the existing, correct, generic warning. Guessing the name wrong therefore
97
- * costs nothing beyond the improvement not happening.
133
+ * A negative result is NOT — `security` can fail for a dozen reasons that have
134
+ * nothing to do with the user, and the service name, though now read straight out
135
+ * of the SDK bundle rather than guessed, is still Claude Code's internal detail
136
+ * and can change. So a miss returns `unknown`, and the caller falls back to the
137
+ * existing, correct, generic warning. Being wrong about the name therefore costs
138
+ * nothing beyond the improvement not happening.
98
139
  *
99
140
  * Never prompts and never reads a secret: `find-generic-password` WITHOUT `-w`
100
141
  * reports existence only (attributes, which are discarded), so nothing sensitive
101
142
  * reaches a log, a transcript, or this process's memory.
143
+ *
144
+ * One known imprecision, left in on purpose: Claude Code's own lookup also passes
145
+ * `-a` (`process.env.USER`, falling back to the OS username), and this does not.
146
+ * So an entry filed under a DIFFERENT account still reads as `found` here while
147
+ * Claude Code would miss it — a service running under a different `USER` than the
148
+ * terminal that logged in. Matching that exactly would trade a rare false
149
+ * positive for a rarer false negative, and a false negative is the one that costs
150
+ * something: it downgrades a specific instruction back to the generic warning.
102
151
  */
103
152
  export async function probeMacosKeychainLogin(platform = process.platform) {
104
153
  if (platform !== "darwin")
@@ -124,18 +173,251 @@ async function exists(p) {
124
173
  .catch(() => false);
125
174
  }
126
175
  /**
127
- * Create paddock's Claude home and bridge the user-level config into it.
176
+ * Symlink one entry of the user's home into paddock's, if paddock's does not
177
+ * already have that name. Returns whether a link was planted THIS call.
178
+ *
179
+ * Non-clobbering by `lstat`, not `stat`: a bridge symlink planted on a previous
180
+ * boot whose target has since been removed still occupies the name, and
181
+ * re-linking it would be a no-op churn at best. Never throws — a race with
182
+ * another instance, or a read-only home, is not a reason to fail a boot.
183
+ */
184
+ async function bridgeEntry(cfg, entry) {
185
+ const target = path.join(cfg.legacyClaudeHome, entry);
186
+ const link = path.join(cfg.claudeHome, entry);
187
+ const occupied = await fs
188
+ .lstat(link)
189
+ .then(() => true)
190
+ .catch(() => false);
191
+ if (occupied)
192
+ return false;
193
+ if (!(await exists(target)))
194
+ return false;
195
+ return fs.symlink(target, link).then(() => true, () => false);
196
+ }
197
+ /**
198
+ * Undo {@link bridgeEntry} for one entry: remove it IF it is a symlink pointing
199
+ * into the user's home. Returns whether anything was removed.
200
+ *
201
+ * Deliberately narrow. A real file at that name is this instance's own — an
202
+ * operator's, or the product of a `CLAUDE_CONFIG_DIR=<own home> claude login` —
203
+ * and removing it would be paddock deleting a credential nobody asked it to
204
+ * touch. A symlink pointing somewhere OTHER than the user's home is not ours
205
+ * either. Only a link paddock itself would have planted is withdrawn, and the
206
+ * withdrawal happens inside paddock's own home, so the "never write to
207
+ * `~/.claude`" invariant is untouched.
208
+ */
209
+ async function unbridgeEntry(cfg, entry) {
210
+ const link = path.join(cfg.claudeHome, entry);
211
+ const target = await fs.readlink(link).catch(() => null);
212
+ if (target === null)
213
+ return false; // absent, or a real file: not ours
214
+ const resolved = path.resolve(path.dirname(link), target);
215
+ if (resolved !== path.resolve(cfg.legacyClaudeHome, entry))
216
+ return false;
217
+ return fs.unlink(link).then(() => true, () => false);
218
+ }
219
+ const sha256 = (content) => createHash("sha256").update(content, "utf8").digest("hex");
220
+ /** Read the entry → sha256 map of files paddock generated. Never throws. */
221
+ async function readGeneratedHashes(home) {
222
+ const raw = await fs.readFile(path.join(home, GENERATED_MARKER_FILE), "utf8").catch(() => null);
223
+ if (raw === null)
224
+ return {};
225
+ try {
226
+ const parsed = JSON.parse(raw);
227
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
228
+ return {};
229
+ return parsed;
230
+ }
231
+ catch {
232
+ return {};
233
+ }
234
+ }
235
+ /** Record (or, with `null`, forget) the hash of a file paddock generated. */
236
+ async function recordGenerated(home, entry, hash) {
237
+ const hashes = await readGeneratedHashes(home);
238
+ if (hash === null) {
239
+ if (!(entry in hashes))
240
+ return;
241
+ delete hashes[entry];
242
+ }
243
+ else {
244
+ if (hashes[entry] === hash)
245
+ return;
246
+ hashes[entry] = hash;
247
+ }
248
+ const marker = path.join(home, GENERATED_MARKER_FILE);
249
+ await fs.writeFile(marker, `${JSON.stringify(hashes, null, 2)}\n`, "utf8").catch(() => { });
250
+ }
251
+ /**
252
+ * Decide whether paddock may replace `<ownHome>/<entry>`.
253
+ *
254
+ * The `generated` case is the one that is new (#691 step 4) and the one the
255
+ * whole copy-and-filter mechanism rests on. A symlink needs no such question —
256
+ * paddock can always recognise its own by where it points — but a real file it
257
+ * wrote is indistinguishable from a real file a human wrote, and getting that
258
+ * wrong in either direction is bad: refuse to regenerate and the filtered copy
259
+ * is stale forever, regenerate anyway and paddock silently overwrites an
260
+ * operator's settings. Hashing what we wrote and comparing on the next boot
261
+ * answers it exactly, and answers `foreign` — the safe way — for any file we
262
+ * did not write or that has been edited since.
263
+ */
264
+ async function classifyOwnEntry(cfg, entry) {
265
+ const own = path.join(cfg.claudeHome, entry);
266
+ const st = await fs.lstat(own).catch(() => null);
267
+ if (st === null)
268
+ return "absent";
269
+ if (st.isSymbolicLink()) {
270
+ const raw = await fs.readlink(own).catch(() => null);
271
+ if (raw === null)
272
+ return "foreign";
273
+ const resolved = path.resolve(path.dirname(own), raw);
274
+ return resolved === path.resolve(cfg.legacyClaudeHome, entry) ? "bridge" : "foreign";
275
+ }
276
+ const content = await fs.readFile(own, "utf8").catch(() => null);
277
+ if (content === null)
278
+ return "foreign";
279
+ const recorded = (await readGeneratedHashes(cfg.claudeHome))[entry];
280
+ return recorded !== undefined && recorded === sha256(content) ? "generated" : "foreign";
281
+ }
282
+ /** Remove a file paddock generated, and forget it. Returns whether it went. */
283
+ async function removeGenerated(home, entry) {
284
+ const ok = await fs.rm(path.join(home, entry), { force: true }).then(() => true, () => false);
285
+ if (ok)
286
+ await recordGenerated(home, entry, null);
287
+ return ok;
288
+ }
289
+ /**
290
+ * Put the right `settings.json` in paddock's home for the `claude.hooks` mode
291
+ * (#691 step 4).
292
+ *
293
+ * Four states have to compose, and all four are reachable by editing one config
294
+ * key on a running instance:
295
+ *
296
+ * | own home has | `hooks: host` | `hooks: own` |
297
+ * |---|---|---|
298
+ * | nothing | symlink the user's | symlink, or write the filtered copy |
299
+ * | paddock's bridge symlink | leave it | replace with the filtered copy |
300
+ * | paddock's generated file | delete it, symlink | regenerate from the user's |
301
+ * | a human's file | leave it | leave it, and say so if hooks were in play |
128
302
  *
129
- * Idempotent and non-clobbering: an entry paddock's home already has (whether a
130
- * real file the user put there or a symlink from a previous boot) is left
131
- * completely alone, so this is safe to run on every start.
303
+ * The last row is the existing non-clobbering bridge behaviour, extended to a
304
+ * file rather than a name — with one addition, because silence there is
305
+ * dangerous: if the user's settings define hooks and paddock is not allowed to
306
+ * write the filtered copy, `hooks: own` is NOT in force for whatever that file
307
+ * says, so it warns rather than leaving an operator to believe a guarantee that
308
+ * is not holding.
309
+ *
310
+ * Never throws; a home it cannot write just keeps whatever it had.
311
+ */
312
+ async function materializeSettings(cfg, hooks, report) {
313
+ const entry = SETTINGS_ENTRY;
314
+ const own = path.join(cfg.claudeHome, entry);
315
+ const state = await classifyOwnEntry(cfg, entry);
316
+ const raw = await fs.readFile(path.join(cfg.legacyClaudeHome, entry), "utf8").catch(() => null);
317
+ if (raw === null) {
318
+ // The user has no settings.json at all — including the case where they
319
+ // deleted one paddock had already bridged or filtered. Withdraw ours.
320
+ if (state === "bridge" && (await unbridgeEntry(cfg, entry)))
321
+ report.withdrawn.push(entry);
322
+ else if (state === "generated" && (await removeGenerated(cfg.claudeHome, entry)))
323
+ report.withdrawn.push(entry);
324
+ return;
325
+ }
326
+ const plan = planHostSettings(hooks, raw);
327
+ if (state === "foreign") {
328
+ if (plan.action === "write") {
329
+ report.notices.push({
330
+ level: "warn",
331
+ message: `\`claude.hooks: own\` cannot take effect: ${own} is a file paddock did not write, ` +
332
+ `so it is left alone (as any entry of your own always is). Your ` +
333
+ `~/.claude/${entry} defines hooks, and whatever THAT file says is what runs. ` +
334
+ `Move or delete it to let paddock generate the filtered copy.`,
335
+ });
336
+ }
337
+ return;
338
+ }
339
+ if (plan.action === "skip") {
340
+ // Fail closed: a settings.json that will not parse cannot be filtered, and
341
+ // linking it instead would hand over exactly the hooks `own` withholds.
342
+ if (state === "bridge" && (await unbridgeEntry(cfg, entry)))
343
+ report.withdrawn.push(entry);
344
+ else if (state === "generated" && (await removeGenerated(cfg.claudeHome, entry)))
345
+ report.withdrawn.push(entry);
346
+ report.notices.push({
347
+ level: "warn",
348
+ message: `not using your ~/.claude/${entry}: ${plan.reason}. Paddock could not filter it, and ` +
349
+ `\`claude.hooks: own\` means it must not be used unfiltered — so this instance runs ` +
350
+ `with no user-level settings at all. Fix the JSON and restart.`,
351
+ });
352
+ return;
353
+ }
354
+ if (plan.action === "link") {
355
+ // Nothing to filter (no hooks, or `hooks: host`): the symlink is strictly
356
+ // better than a copy, so prefer it even under `own`. Only users who actually
357
+ // have hooks pay the staleness of a copy.
358
+ if (state === "generated")
359
+ await removeGenerated(cfg.claudeHome, entry);
360
+ if (await bridgeEntry(cfg, entry))
361
+ report.bridged.push(entry);
362
+ return;
363
+ }
364
+ if (state === "bridge")
365
+ await fs.unlink(own).catch(() => { });
366
+ const existing = state === "generated" ? await fs.readFile(own, "utf8").catch(() => null) : null;
367
+ if (existing !== plan.content) {
368
+ const ok = await fs.writeFile(own, plan.content, "utf8").then(() => true, () => false);
369
+ if (!ok) {
370
+ report.notices.push({
371
+ level: "warn",
372
+ message: `could not write ${own}, so \`claude.hooks: own\` is not in force — this instance ` +
373
+ `has no user-level settings rather than filtered ones.`,
374
+ });
375
+ return;
376
+ }
377
+ report.generated.push(entry);
378
+ }
379
+ await recordGenerated(cfg.claudeHome, entry, sha256(plan.content));
380
+ report.notices.push({
381
+ level: "info",
382
+ message: `Claude hooks: your ~/.claude/${entry} defines ${plan.dropped.join(", ")}, which this ` +
383
+ `instance does NOT run (\`claude.hooks: own\`). Paddock writes its own ${entry} carrying ` +
384
+ `every other key of yours — permissions, model, statusline — and regenerates it at ` +
385
+ `startup, so restart paddock after editing yours. Set \`claude.hooks: host\` to run them.`,
386
+ });
387
+ }
388
+ /**
389
+ * Create paddock's Claude home and put the user-level config into it that this
390
+ * instance's `claude:` levers say it should have (#691).
391
+ *
392
+ * Idempotent and non-clobbering: a real file the user put in paddock's home is
393
+ * left completely alone, so this is safe to run on every start. What it DOES
394
+ * maintain is what paddock itself planted — bridge symlinks, withdrawn when a
395
+ * lever flips to `own`, and the generated `settings.json`, regenerated from the
396
+ * user's current one. Ownership of a generated file is decided by hash
397
+ * ({@link classifyOwnEntry}), never by "it is at a name we use".
132
398
  *
133
399
  * Never throws. A home that cannot be created is not a reason to refuse to boot
134
400
  * — Claude Code falls back to its own default and chat still works, which is the
135
401
  * same failure mode `ensureProjectChats` has always had.
402
+ *
403
+ * **Mutates `env`** (`process.env` by default) to carry `claude.credentials` into
404
+ * the environment Claude Code runs in — see {@link applyCredentialsMode} for why
405
+ * that is the only seam available and why it is safe process-wide. Done here
406
+ * because this is already the one call that runs before the fleet starts, and
407
+ * because the credential warning below has to describe the environment it just
408
+ * set rather than the one it found.
136
409
  */
137
410
  export async function ensureClaudeHome(cfg, env = process.env, probeKeychain = probeMacosKeychainLogin) {
138
- const report = { bridged: [], notices: [] };
411
+ const report = { bridged: [], withdrawn: [], generated: [], notices: [] };
412
+ const credentials = cfg.claude.credentials;
413
+ const decision = applyCredentialsMode(credentials, env);
414
+ if (decision.action === "deferred") {
415
+ report.notices.push({
416
+ level: "info",
417
+ message: `${SECURE_STORAGE_DIR_VAR} is already set to "${decision.value}"; honouring it and ` +
418
+ `ignoring \`claude.credentials: ${credentials}\`. Unset it to let paddock choose.`,
419
+ });
420
+ }
139
421
  try {
140
422
  await fs.mkdir(path.join(cfg.claudeHome, "projects"), { recursive: true });
141
423
  }
@@ -148,78 +430,122 @@ export async function ensureClaudeHome(cfg, env = process.env, probeKeychain = p
148
430
  });
149
431
  return report;
150
432
  }
151
- // Running in the user's own home is the pre-#620 layout: nothing to bridge
152
- // (the files are already there) and nothing to warn about.
153
- if (!cfg.ownsClaudeHome) {
433
+ // No "is this home ours?" branch any more (#691): it always is, and a config
434
+ // that resolves the home to `~/.claude` never gets this far — `resolveClaudeHome`
435
+ // refuses to start. What remains is one lever per entry.
436
+ // The login — `claude.credentials`, step 3. Its file half on Linux/Docker.
437
+ if (credentials === "host") {
438
+ if (await bridgeEntry(cfg, CREDENTIAL_ENTRY))
439
+ report.bridged.push(CREDENTIAL_ENTRY);
440
+ }
441
+ else if (await unbridgeEntry(cfg, CREDENTIAL_ENTRY)) {
442
+ report.withdrawn.push(CREDENTIAL_ENTRY);
154
443
  report.notices.push({
155
444
  level: "info",
156
- message: `Claude home: ${cfg.claudeHome} (the user's own — paddock is not managing it)`,
445
+ message: `removed the bridged ${CREDENTIAL_ENTRY} symlink into ${cfg.legacyClaudeHome}: ` +
446
+ `\`claude.credentials: own\` means this instance uses only its own login. A real ` +
447
+ `${CREDENTIAL_ENTRY} in ${cfg.claudeHome} is left alone.`,
157
448
  });
158
- return report;
159
449
  }
160
- for (const entry of BRIDGED_ENTRIES) {
161
- const target = path.join(cfg.legacyClaudeHome, entry);
162
- const link = path.join(cfg.claudeHome, entry);
163
- // lstat, not stat: a bridge symlink planted on a previous boot whose target
164
- // has since been removed still occupies the name, and re-linking it would be
165
- // a no-op churn at best.
166
- const occupied = await fs
167
- .lstat(link)
168
- .then(() => true)
169
- .catch(() => false);
170
- if (occupied)
171
- continue;
172
- if (!(await exists(target)))
173
- continue;
174
- try {
175
- await fs.symlink(target, link);
176
- report.bridged.push(entry);
450
+ // CLAUDE.md, agents, commands, plugins — `claude.instructions`, step 4.
451
+ const instructions = cfg.claude.instructions;
452
+ for (const entry of INSTRUCTION_ENTRIES) {
453
+ if (instructions === "host") {
454
+ if (await bridgeEntry(cfg, entry))
455
+ report.bridged.push(entry);
177
456
  }
178
- catch {
179
- /* a race with another instance, or a read-only home — not fatal */
457
+ else if (await unbridgeEntry(cfg, entry)) {
458
+ report.withdrawn.push(entry);
180
459
  }
181
460
  }
461
+ if (instructions === "own") {
462
+ // Named rather than left to be discovered. `own` is the default and it is a
463
+ // real behaviour change for anyone with a curated `~/.claude/CLAUDE.md`:
464
+ // their agents quietly stop knowing things, with no error to search for. So
465
+ // paddock says which of their files it is not using, and which key turns
466
+ // them back on. Only when they HAVE some — otherwise it is noise.
467
+ const present = [];
468
+ for (const entry of INSTRUCTION_ENTRIES) {
469
+ if (await exists(path.join(cfg.legacyClaudeHome, entry)))
470
+ present.push(entry);
471
+ }
472
+ if (present.length > 0) {
473
+ report.notices.push({
474
+ level: "info",
475
+ message: `Claude instructions: this instance's own only (\`claude.instructions: own\`). Your ` +
476
+ `~/.claude ${present.join(", ")} ${present.length === 1 ? "is" : "are"} NOT loaded — ` +
477
+ `set \`claude.instructions: host\` (or PADDOCK_CLAUDE_INSTRUCTIONS=host) to share ` +
478
+ `them. Each project's own CLAUDE.md is unaffected either way.`,
479
+ });
480
+ }
481
+ }
482
+ // settings.json — `claude.hooks`, step 4. Not a bridge decision: the file is a
483
+ // mixed bag, so `own` writes a filtered copy. See `materializeSettings`.
484
+ await materializeSettings(cfg, cfg.claude.hooks, report);
182
485
  report.notices.push({
183
486
  level: "info",
184
487
  message: `Claude home: ${cfg.claudeHome} (paddock-owned; ~/.claude is read-only)` +
185
- (report.bridged.length > 0 ? ` — bridged from ~/.claude: ${report.bridged.join(", ")}` : ""),
488
+ (report.bridged.length > 0 ? ` — bridged from ~/.claude: ${report.bridged.join(", ")}` : "") +
489
+ (report.generated.length > 0 ? ` — generated: ${report.generated.join(", ")}` : "") +
490
+ (report.withdrawn.length > 0 ? ` — withdrawn: ${report.withdrawn.join(", ")}` : ""),
186
491
  });
187
- // The credential question (#620). Claude Code derives its secure-storage
492
+ // The credential question (#620, #683). Claude Code derives its secure-storage
188
493
  // service name from whether `CLAUDE_CONFIG_DIR` is SET AT ALL — unset gets the
189
- // plain name, set gets a path-hash suffix. Pointing paddock at its own home
190
- // means it is always set, so a login held in the macOS Keychain under the plain
191
- // name will not be found. A token in the environment is unaffected, and a
192
- // file-based login is bridged above; if neither applies we cannot see the
193
- // credential at all, so say so BEFORE the first turn fails with "Not logged in"
194
- // rather than leaving the operator to work it out from a chat that never
195
- // replies.
494
+ // plain name, set gets a path-hash suffix. Paddock always sets it, so a login
495
+ // held in the macOS Keychain under the plain name is invisible unless
496
+ // `credentials: host` un-scopes it above. A token in the environment is
497
+ // unaffected, and a file-based login is bridged (under `host`); if none of that
498
+ // applies we cannot see a credential at all, so say so BEFORE the first turn
499
+ // fails with "Not logged in" rather than leaving the operator to work it out
500
+ // from a chat that never replies.
196
501
  const hasTokenEnv = TOKEN_ENV_VARS.some((v) => (env[v] ?? "").trim().length > 0);
197
- const hasCredentialFile = await exists(path.join(cfg.claudeHome, ".credentials.json"));
502
+ const hasCredentialFile = await exists(path.join(cfg.claudeHome, CREDENTIAL_ENTRY));
198
503
  if (!hasTokenEnv && !hasCredentialFile) {
199
504
  // #683: "you have no login" and "your login exists but I changed the service
200
- // name it is filed under" are the same message today, and only one of them is
201
- // the user's problem to solve. On darwin we can tell them apart, and a hit
202
- // upgrades a paragraph of explanation into an instruction. A miss is
203
- // inconclusive by design (see probeMacosKeychainLogin), so it falls through
204
- // to the generic wording rather than asserting anything.
505
+ // name it is filed under" were the same message, and only one of them is the
506
+ // user's problem to solve. On darwin we can tell them apart, and a hit either
507
+ // resolves the question outright (`host`) or upgrades a paragraph of
508
+ // explanation into an instruction (`own`). A miss is inconclusive by design
509
+ // (see probeMacosKeychainLogin), so it falls through to the generic wording
510
+ // rather than asserting anything.
205
511
  const keychain = await probeKeychain();
206
- report.notices.push({
207
- level: "warn",
208
- message: keychain === "found"
209
- ? `you ARE logged in to Claude Code, but paddock cannot see it. The login is in the ` +
210
- `macOS Keychain, and Claude Code files it under a service name derived from whether ` +
211
- `CLAUDE_CONFIG_DIR is set — paddock sets it, to ${cfg.claudeHome}, so it looks under ` +
212
- `a different name. A Keychain entry cannot be bridged the way ~/.claude files are. ` +
213
- `Fix it either way: run \`CLAUDE_CONFIG_DIR=${cfg.claudeHome} claude login\` to log ` +
214
- `in for this home too, or set CLAUDE_HOME=${cfg.legacyClaudeHome} (what \`paddock\` ` +
215
- `does by default) to run against your own home and use the login you already have.`
216
- : `no Claude credentials found for ${cfg.claudeHome}: no .credentials.json and no ` +
217
- `${TOKEN_ENV_VARS.join("/")} in the environment. Claude Code scopes its credential ` +
218
- `store to CLAUDE_CONFIG_DIR, so a login stored in the OS keychain against the ` +
219
- `default home will NOT be found here. If turns fail with "Not logged in", either ` +
220
- `re-run \`claude login\` with CLAUDE_CONFIG_DIR=${cfg.claudeHome}, or set ` +
221
- `CLAUDE_HOME=${cfg.legacyClaudeHome} to keep using the previous layout.`,
222
- });
512
+ if (keychain === "found" && credentials === "host") {
513
+ // Not a problem: this is the lever working. Said out loud anyway, because
514
+ // "paddock found no credentials of its own and is using yours" is exactly
515
+ // the kind of thing an operator should not have to deduce.
516
+ report.notices.push({
517
+ level: "info",
518
+ message: `Claude login: this machine's own, from the macOS Keychain ` +
519
+ `(\`claude.credentials: host\`). Paddock sets ${SECURE_STORAGE_DIR_VAR}="" so Claude ` +
520
+ `Code reads the same unsuffixed entry your own \`claude\` does, while its config dir ` +
521
+ `stays at ${cfg.claudeHome}. Set \`claude.credentials: own\` to use only a login of ` +
522
+ `this instance's.`,
523
+ });
524
+ }
525
+ else {
526
+ report.notices.push({
527
+ level: "warn",
528
+ message: keychain === "found"
529
+ ? `you ARE logged in to Claude Code, but paddock cannot see it. The login is in the ` +
530
+ `macOS Keychain, and Claude Code files it under a service name derived from ` +
531
+ `whether CLAUDE_CONFIG_DIR is set — paddock sets it, to ${cfg.claudeHome}, so it ` +
532
+ `looks under a different name. \`claude.credentials: own\` is what keeps it that ` +
533
+ `way. Fix it either way: drop that key (the default is \`host\`, which shares this ` +
534
+ `machine's login and writes nothing), or log in for this home too with ` +
535
+ `\`CLAUDE_CONFIG_DIR=${cfg.claudeHome} claude login\`.`
536
+ : `no Claude credentials found for ${cfg.claudeHome}: no ${CREDENTIAL_ENTRY} and no ` +
537
+ `${TOKEN_ENV_VARS.join("/")} in the environment` +
538
+ (credentials === "host"
539
+ ? ` — and \`claude.credentials: host\` found nothing on this machine to share ` +
540
+ `either (no ~/.claude/${CREDENTIAL_ENTRY}, and no macOS Keychain login under ` +
541
+ `the name Claude Code uses). `
542
+ : `, and \`claude.credentials: own\` means this instance shares none of yours. ` +
543
+ `Claude Code scopes its credential store to CLAUDE_CONFIG_DIR, so a login in ` +
544
+ `the OS keychain against the default home will NOT be found here. `) +
545
+ `If turns fail with "Not logged in", re-run \`claude login\` with ` +
546
+ `CLAUDE_CONFIG_DIR=${cfg.claudeHome}, or put a token in the environment.`,
547
+ });
548
+ }
223
549
  }
224
550
  return report;
225
551
  }
@@ -274,6 +600,14 @@ export function legacySourcePath(legacyHome, folderName) {
274
600
  * mirroring them would re-offer paddock's own chats as "adoptable". That also
275
601
  * makes the leftovers from before this change inert rather than confusing.
276
602
  *
603
+ * A folder paddock's own home ALREADY links to directly is skipped for the same
604
+ * reason. That is the `claude.transcripts: host` case (#691): there the folder in
605
+ * the user's home is a real directory that paddock itself writes through, so
606
+ * mirroring it would offer every project's own live chats back as importable —
607
+ * the #658 mistake with the arrow reversed. (Adoption de-duplicates sources by
608
+ * resolved real path, so this was noise rather than breakage; it is skipped here
609
+ * because a link paddock plants and then mirrors is confusing to find on disk.)
610
+ *
277
611
  * Idempotent and never throws. Returns every mirror it maintains, not only the
278
612
  * ones planted this call, so a caller can rely on the mapping after any call.
279
613
  */
@@ -293,6 +627,12 @@ export async function mirrorLegacyTranscriptFolders(claudeHome, legacyHome) {
293
627
  const st = await fs.lstat(legacyDir).catch(() => null);
294
628
  if (!st?.isDirectory())
295
629
  continue; // skips files AND paddock's old symlinks
630
+ // Already reachable directly from paddock's own home (`transcripts: host`)?
631
+ // Then it is not an outside source to import; it is where this instance's
632
+ // own chats live.
633
+ const direct = await fs.readlink(path.join(to, folderName)).catch(() => null);
634
+ if (direct !== null && path.resolve(to, direct) === path.resolve(legacyDir))
635
+ continue;
296
636
  const engineCwd = legacySourcePath(legacyHome, folderName);
297
637
  // encodePathForCli, not a local encoder: this name must be byte-identical
298
638
  // to what the engine derives from `engineCwd`, including the truncate+hash