@edspencer/paddock 0.60.0 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -3
  3. package/package.json +3 -2
  4. package/packages/server/dist/adoptable.d.ts +36 -2
  5. package/packages/server/dist/adoptable.js +51 -11
  6. package/packages/server/dist/agent-errors.d.ts +41 -0
  7. package/packages/server/dist/agent-errors.js +127 -0
  8. package/packages/server/dist/app.js +36 -0
  9. package/packages/server/dist/claude-home.d.ts +192 -0
  10. package/packages/server/dist/claude-home.js +387 -0
  11. package/packages/server/dist/cli/args.d.ts +9 -1
  12. package/packages/server/dist/cli/args.js +21 -5
  13. package/packages/server/dist/cli/claude-home-choice.d.ts +44 -0
  14. package/packages/server/dist/cli/claude-home-choice.js +108 -0
  15. package/packages/server/dist/cli/import-chats.js +9 -6
  16. package/packages/server/dist/cli/paddock.js +54 -33
  17. package/packages/server/dist/config.d.ts +75 -12
  18. package/packages/server/dist/config.js +58 -11
  19. package/packages/server/dist/herdctl-agent-config.d.ts +15 -7
  20. package/packages/server/dist/herdctl-agent-config.js +21 -9
  21. package/packages/server/dist/herdctl.d.ts +15 -0
  22. package/packages/server/dist/herdctl.js +50 -6
  23. package/packages/server/dist/hook-config.d.ts +17 -15
  24. package/packages/server/dist/hook-config.js +11 -9
  25. package/packages/server/dist/routes/chats.js +33 -6
  26. package/packages/server/dist/sweep.js +13 -1
  27. package/packages/server/dist/transcripts.d.ts +32 -6
  28. package/packages/server/dist/transcripts.js +30 -24
  29. package/packages/server/dist/trigger-config.d.ts +18 -8
  30. package/packages/server/dist/trigger-config.js +18 -8
  31. package/packages/server/dist/ws.js +43 -11
  32. package/packages/server/package.json +1 -1
  33. package/packages/web/dist/assets/{InstanceConfigPage-xn9Rvoah.js → InstanceConfigPage-BgNXqAox.js} +1 -1
  34. package/packages/web/dist/assets/{MarkdownRenderer-DMONssKA.js → MarkdownRenderer-DfD_UtqU.js} +1 -1
  35. package/packages/web/dist/assets/{ProjectRedirect-DHoWtvtY.js → ProjectRedirect-1EXdHXmO.js} +1 -1
  36. package/packages/web/dist/assets/ProjectView-DHb5vNUp.js +13 -0
  37. package/packages/web/dist/assets/{ProjectsGrid-CjfwpeFj.js → ProjectsGrid-Br-EhLZE.js} +1 -1
  38. package/packages/web/dist/assets/{arc-DdFdbD-c.js → arc-C2Jekujs.js} +1 -1
  39. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DC8UKtwV.js → architectureDiagram-3BPJPVTR-CflCA-em.js} +1 -1
  40. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DqFFe_5c.js → blockDiagram-GPEHLZMM-Ds7GLNnC.js} +1 -1
  41. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DbffFSM-.js → c4Diagram-AAUBKEIU-D4WyGO6I.js} +1 -1
  42. package/packages/web/dist/assets/channel-B1K_gwAH.js +2 -0
  43. package/packages/web/dist/assets/{chunk-2J33WTMH-8PP2RHft.js → chunk-2J33WTMH-tQunO_M5.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-4BX2VUAB-Hag68B5N.js → chunk-4BX2VUAB-DT5dOWd2.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-55IACEB6-BXp_gQIa.js → chunk-55IACEB6-CfTUqUEF.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-727SXJPM-DlntjQux.js → chunk-727SXJPM-sK5bOAA0.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BhI9KPrE.js → chunk-AQP2D5EJ-DcMgVe-P.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-FMBD7UC4-WhfORs2z.js → chunk-FMBD7UC4-In3h_-7v.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-ND2GUHAM-CIskmv20.js → chunk-ND2GUHAM-DuaIeRaC.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-QZHKN3VN-CNOTpCW4.js → chunk-QZHKN3VN-DeT5mR5T.js} +1 -1
  51. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +2 -0
  52. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +2 -0
  53. package/packages/web/dist/assets/{core-usOVRkLM.js → core-DgOgLvnM.js} +1 -1
  54. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CniuMIIW.js → cose-bilkent-S5V4N54A-CyO-rznJ.js} +1 -1
  55. package/packages/web/dist/assets/{dagre-BM42HDAG-5CteYfQY.js → dagre-BM42HDAG-B_9Ihzgn.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-2AECGRRQ-BIQjD2LA.js → diagram-2AECGRRQ-z5mvZ5YT.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-5GNKFQAL-1vy3ErTt.js → diagram-5GNKFQAL-Bsz0cgfn.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-KO2AKTUF-D45iqFk6.js → diagram-KO2AKTUF-B29k5f4q.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-LMA3HP47-DNktDktn.js → diagram-LMA3HP47-CFrwsHoI.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-OG6HWLK6-BQ9JgV70.js → diagram-OG6HWLK6-D9Sjc2HR.js} +1 -1
  61. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-oBasZ1uv.js → erDiagram-TEJ5UH35-C3vbkD7M.js} +1 -1
  62. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DxnJHMBS.js → flowDiagram-I6XJVG4X-DkMIgntd.js} +1 -1
  63. package/packages/web/dist/assets/{format-CIAat6sU.js → format-BFDqyFuw.js} +1 -1
  64. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CtJQ2m7z.js → ganttDiagram-6RSMTGT7-nvHanjpW.js} +1 -1
  65. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-5RPscwD_.js → gitGraphDiagram-PVQCEYII-DbCNfOu7.js} +1 -1
  66. package/packages/web/dist/assets/index-DGCs1YIO.js +71 -0
  67. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-zQu3xkW4.js → infoDiagram-5YYISTIA-aJjH9x3D.js} +1 -1
  68. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-ChoEjcdw.js → ishikawaDiagram-YF4QCWOH-DoifwNnZ.js} +1 -1
  69. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DM2KWmDr.js → journeyDiagram-JHISSGLW-Cz9kG9HN.js} +1 -1
  70. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DWzTpYgh.js → kanban-definition-UN3LZRKU-BoXnfr7A.js} +1 -1
  71. package/packages/web/dist/assets/{linear-Bpy9Aj-K.js → linear-BlTTO9yR.js} +1 -1
  72. package/packages/web/dist/assets/{mermaid.core-D43x5bFb.js → mermaid.core-YAG8eSPQ.js} +4 -4
  73. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BhceB-cv.js → mindmap-definition-RKZ34NQL-DtUBKfkr.js} +1 -1
  74. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BgcGlFFA.js → pieDiagram-4H26LBE5-B3Rwpmy0.js} +1 -1
  75. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ZRVaOoSp.js → quadrantDiagram-W4KKPZXB-B8eJtTyk.js} +1 -1
  76. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-2hHcJHBt.js → requirementDiagram-4Y6WPE33-CpAluiY9.js} +1 -1
  77. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js → sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js} +1 -1
  78. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-D4BXQSSM.js → sequenceDiagram-3UESZ5HK-CRpqKSGm.js} +1 -1
  79. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DftS7dIo.js → stateDiagram-AJRCARHV-h3h8gXv_.js} +1 -1
  80. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +2 -0
  81. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C0OAxyWY.js → timeline-definition-PNZ67QCA-PVr5V_pG.js} +1 -1
  82. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C94fINN3.js → vennDiagram-CIIHVFJN-De-4NPAX.js} +1 -1
  83. package/packages/web/dist/assets/{wardley-L42UT6IY-CbFrlwHE.js → wardley-L42UT6IY-Dtv10D2d.js} +1 -1
  84. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CHG2nndd.js → wardleyDiagram-YWT4CUSO-p2fJuQ6C.js} +1 -1
  85. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-U9ESc19z.js → xychartDiagram-2RQKCTM6-DyYF-k92.js} +1 -1
  86. package/packages/web/dist/index.html +1 -1
  87. package/packages/web/dist/sw.js +1 -1
  88. package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +0 -13
  89. package/packages/web/dist/assets/channel-QxsJGL6F.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +0 -2
  91. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +0 -2
  92. package/packages/web/dist/assets/index-DtRblEY_.js +0 -71
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +0 -2
@@ -0,0 +1,44 @@
1
+ export interface ClaudeHomeChoiceInput {
2
+ /** The process environment to read. */
3
+ env: NodeJS.ProcessEnv;
4
+ /** The user's own Claude home — `os.homedir()/.claude`. */
5
+ userClaudeHome: string;
6
+ /** Does that directory exist? Nothing to be continuous with if it does not. */
7
+ userHomeExists: boolean;
8
+ /** Did the user ask for isolation explicitly (`--isolated-claude-home`)? */
9
+ forceIsolated: boolean;
10
+ }
11
+ export interface ClaudeHomeChoice {
12
+ /**
13
+ * The value to put in `CLAUDE_HOME`, or `undefined` to leave the default
14
+ * (`<dataDir>/claude-home`) alone.
15
+ */
16
+ claudeHome?: string;
17
+ /** Short, user-facing reason — printed when continuity is chosen. */
18
+ reason: string;
19
+ }
20
+ /**
21
+ * Decide whether the interactive CLI should run against the user's own Claude
22
+ * home for credential continuity.
23
+ *
24
+ * Pure, so the whole decision table is testable on a platform that has no
25
+ * Keychain. Continuity is chosen only when all four hold:
26
+ *
27
+ * 1. **No explicit home in the environment.** `CLAUDE_HOME` or
28
+ * `CLAUDE_CONFIG_DIR` is somebody deciding this already; deferring to them is
29
+ * the same rule herdctl#423 follows.
30
+ * 2. **No token in the environment.** A token authenticates regardless of home,
31
+ * so the isolated home works and is strictly better — this is the shape every
32
+ * CI, container and server run has.
33
+ * 3. **`--isolated-claude-home` not passed.** The explicit opt-out.
34
+ * 4. **`~/.claude` actually exists.** With no user home there is no login to be
35
+ * continuous with, and the owned home self-initializes cleanly.
36
+ *
37
+ * Note what is NOT a condition: the platform. The isolation/continuity trade is
38
+ * the same argument on Linux, where it additionally makes the `.credentials.json`
39
+ * bridge unnecessary rather than merely insufficient. Branching on `darwin` would
40
+ * mean two behaviours to reason about and one of them untested on most machines.
41
+ */
42
+ export declare function chooseClaudeHome(input: ClaudeHomeChoiceInput): ClaudeHomeChoice;
43
+ /** `~/.claude`, given a home directory. Kept here so callers do not re-spell it. */
44
+ export declare function userClaudeHomePath(homedir: string): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Which Claude home the INTERACTIVE CLI runs against (#683).
3
+ *
4
+ * ## The problem this exists for
5
+ *
6
+ * Since #620 paddock owns `<dataDir>/claude-home`, and herdctl therefore hands
7
+ * Claude Code a `CLAUDE_CONFIG_DIR`. Claude Code derives its **secure-storage
8
+ * service name** from whether that variable is SET AT ALL — unset gets the plain
9
+ * name, set gets a path-hash suffix — so a macOS login held in the Keychain under
10
+ * the plain name becomes invisible the moment paddock points at its own home.
11
+ *
12
+ * #620 anticipated this and bridges credentials by symlinking
13
+ * `~/.claude/.credentials.json` in. That is the FILE-based store: it covers Linux
14
+ * and the Docker image and is structurally incapable of covering macOS, because a
15
+ * Keychain entry cannot be symlinked. Net effect on a Mac: `npx paddock --here`
16
+ * boots, the UI loads, and every turn fails with `Not logged in`, two directories
17
+ * away from a perfectly good login.
18
+ *
19
+ * ## Why the fix is a home choice and not a credential copy
20
+ *
21
+ * There is exactly one lever. herdctl injects `CLAUDE_CONFIG_DIR` only when the
22
+ * configured home differs from `~/.claude` — it documents that same service-name
23
+ * branch as its reason for staying quiet otherwise. So the only way to get the
24
+ * plain service name, and with it the user's Keychain login, is to run against
25
+ * `~/.claude` itself.
26
+ *
27
+ * The alternative — reading the Keychain secret and writing it to disk as
28
+ * `.credentials.json` — is deliberately NOT done anywhere in this codebase. It
29
+ * downgrades OS-protected storage to a plaintext file, and it would diverge the
30
+ * moment Claude Code refreshed the token.
31
+ *
32
+ * ## Why the interactive CLI, and only the interactive CLI
33
+ *
34
+ * `--here` means *my* directory, *my* sessions, *my* login; isolation is a server
35
+ * concern. A daemon, the container image and `node dist/index.js` all keep the
36
+ * owned home — nothing here runs for them, and `resolveClaudeHome`'s precedence
37
+ * is untouched. This module only decides what the CLI puts in `CLAUDE_HOME`
38
+ * before it hands over, which is the same mechanism a user has always had.
39
+ *
40
+ * ## What continuity costs
41
+ *
42
+ * Running against the user's home means paddock plants no transcript symlink
43
+ * there (#682, and the reason this was blocked on it), so transcripts stay in
44
+ * `~/.claude/projects/<encoded-cwd>/` rather than being relocated into the
45
+ * workspace's `.chats/`. Chats still list, open, resume and rename — paddock and
46
+ * the engine read the one home — but the workspace directory is no longer
47
+ * self-contained. The CLI says so out loud when it makes this choice; see
48
+ * `paddock.ts`.
49
+ *
50
+ * What it does NOT change, contrary to the obvious guess: **import consent**.
51
+ * Reading the home a user's sessions live in does not make them paddock's chats.
52
+ * Session discovery is attribution-based, so a transcript paddock never ran is
53
+ * offered by `adoptable-chats` and absent from `chats` — exactly as under an
54
+ * owned home — and #663's confirmation still gates every import. Checked against
55
+ * a running instance rather than reasoned about, because the reasoning went the
56
+ * other way.
57
+ */
58
+ import path from "node:path";
59
+ /** Env vars that authenticate Claude Code without any on-disk credential. */
60
+ const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
61
+ /** Variables that are an explicit choice of home, and are never overridden. */
62
+ const HOME_ENV_VARS = ["CLAUDE_HOME", "CLAUDE_CONFIG_DIR"];
63
+ /**
64
+ * Decide whether the interactive CLI should run against the user's own Claude
65
+ * home for credential continuity.
66
+ *
67
+ * Pure, so the whole decision table is testable on a platform that has no
68
+ * Keychain. Continuity is chosen only when all four hold:
69
+ *
70
+ * 1. **No explicit home in the environment.** `CLAUDE_HOME` or
71
+ * `CLAUDE_CONFIG_DIR` is somebody deciding this already; deferring to them is
72
+ * the same rule herdctl#423 follows.
73
+ * 2. **No token in the environment.** A token authenticates regardless of home,
74
+ * so the isolated home works and is strictly better — this is the shape every
75
+ * CI, container and server run has.
76
+ * 3. **`--isolated-claude-home` not passed.** The explicit opt-out.
77
+ * 4. **`~/.claude` actually exists.** With no user home there is no login to be
78
+ * continuous with, and the owned home self-initializes cleanly.
79
+ *
80
+ * Note what is NOT a condition: the platform. The isolation/continuity trade is
81
+ * the same argument on Linux, where it additionally makes the `.credentials.json`
82
+ * bridge unnecessary rather than merely insufficient. Branching on `darwin` would
83
+ * mean two behaviours to reason about and one of them untested on most machines.
84
+ */
85
+ export function chooseClaudeHome(input) {
86
+ const setHomeVar = HOME_ENV_VARS.find((v) => (input.env[v] ?? "").trim().length > 0);
87
+ if (setHomeVar !== undefined) {
88
+ return { reason: `${setHomeVar} is set — honouring it` };
89
+ }
90
+ if (input.forceIsolated) {
91
+ return { reason: "--isolated-claude-home was passed" };
92
+ }
93
+ const tokenVar = TOKEN_ENV_VARS.find((v) => (input.env[v] ?? "").trim().length > 0);
94
+ if (tokenVar !== undefined) {
95
+ return { reason: `${tokenVar} is set — the isolated home can authenticate on its own` };
96
+ }
97
+ if (!input.userHomeExists) {
98
+ return { reason: `no ${input.userClaudeHome} — nothing to inherit` };
99
+ }
100
+ return {
101
+ claudeHome: input.userClaudeHome,
102
+ reason: `using your existing Claude Code login and history in ${input.userClaudeHome}`,
103
+ };
104
+ }
105
+ /** `~/.claude`, given a home directory. Kept here so callers do not re-spell it. */
106
+ export function userClaudeHomePath(homedir) {
107
+ return path.join(homedir, ".claude");
108
+ }
@@ -15,11 +15,13 @@
15
15
  *
16
16
  * ## Deployment note
17
17
  *
18
- * The import needs READ access to the user's transcripts. In a container that
19
- * usually means running this on the HOST against the data dir (`--data-dir`),
20
- * or mounting `~/.claude` in and pointing `CLAUDE_HOME` at the mount. Detection
21
- * silently finds nothing if the transcripts are not visible — which is why
22
- * `--dry-run` prints the sources it found rather than only a count.
18
+ * The import needs READ access to the user's own transcripts, which live in
19
+ * `~/.claude` — a read-only source paddock reads out of and never writes to
20
+ * (#620). In a container that usually means running this on the HOST against
21
+ * the data dir (`--data-dir`), or bind-mounting the user's `~/.claude` in at the
22
+ * same path. Detection silently finds nothing if the transcripts are not
23
+ * visible — which is why `--dry-run` prints the sources it found rather than
24
+ * only a count.
23
25
  *
24
26
  * ## Usage
25
27
  *
@@ -140,8 +142,9 @@ async function main() {
140
142
  : `Nothing to import for "${project.slug || "(root)"}".\n` +
141
143
  `Working directory: ${project.workingDir}\n` +
142
144
  `Claude home: ${cfg.claudeHome}\n` +
145
+ `Importing from: ${cfg.legacyClaudeHome}\n` +
143
146
  `If you expected chats here, check that this process can READ the transcripts\n` +
144
- `(a container only sees what is mounted) and that CLAUDE_HOME points at them.\n`);
147
+ `under that source home (a container only sees what is mounted).\n`);
145
148
  return 0;
146
149
  }
147
150
  const result = await herdctl.adoptChats(project, {
@@ -24,6 +24,7 @@ import path from "node:path";
24
24
  import { spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { HERE_MARKER, isHereWorkspace, countClaudeSessions, ensureGitignored } from "./here.js";
27
+ import { chooseClaudeHome, userClaudeHomePath } from "./claude-home-choice.js";
27
28
  import { CliError, USAGE, parseArgs, nodeVersionProblem, explainListenError, } from "./args.js";
28
29
  /** This module's own directory: `<…>/packages/server/dist/cli`. */
29
30
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -89,34 +90,6 @@ function addBundledBinsToPath() {
89
90
  return;
90
91
  process.env.PATH = current ? `${binDir}${sep}${current}` : binDir;
91
92
  }
92
- /**
93
- * Best-effort credential check — WARNS, never blocks.
94
- *
95
- * Claude Code stores credentials differently per platform (a file under
96
- * ~/.claude on Linux, the Keychain on macOS), so absence of a file does not
97
- * prove absence of a login. Refusing to start on a false negative would be
98
- * worse than a chat that fails with the runtime's own error, so this only
99
- * prints guidance.
100
- */
101
- function warnIfNoCredentials(claudeHome) {
102
- if (process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY)
103
- return;
104
- const looksLoggedIn = fs.existsSync(path.join(claudeHome, ".credentials.json")) ||
105
- fs.existsSync(path.join(os.homedir(), ".claude.json"));
106
- if (looksLoggedIn)
107
- return;
108
- console.warn([
109
- "",
110
- " ⚠ No Claude credentials found.",
111
- "",
112
- " Paddock will start, but chats will fail until it has credentials.",
113
- " Fix with either:",
114
- "",
115
- " claude setup-token # Claude Max/Pro",
116
- " export ANTHROPIC_API_KEY=sk-ant-… # API billing",
117
- "",
118
- ].join("\n"));
119
- }
120
93
  /**
121
94
  * Report where the data lives, the first time we create it.
122
95
  *
@@ -142,12 +115,26 @@ function noteFirstRun(dataDir) {
142
115
  * effects are announced rather than asked about. Printed only on the first
143
116
  * `--here` in a directory; resuming an already-opened one says nothing.
144
117
  */
145
- function announceHereConsent(dir, dataDir) {
118
+ function announceHereConsent(dir, dataDir, inheritedHome) {
146
119
  console.log([
147
120
  "",
148
121
  ` Opening ${dir} as a Paddock workspace.`,
149
122
  ` · ${path.relative(dir, dataDir) || HERE_MARKER}/ and .chats/ created here, and added to .gitignore`,
150
- " · ~/.claude sessions for this directory are linked at the workspace",
123
+ // Import consent is UNCHANGED by continuity, which is worth stating
124
+ // because it is not what you would predict: reading the user's home does
125
+ // not turn their sessions into paddock's chats. Verified against a running
126
+ // instance — a pre-existing transcript for the directory comes back from
127
+ // `adoptable-chats` (offered) and NOT from `chats` (listed), because
128
+ // session discovery is attribution-based, not "whatever is in the folder".
129
+ // So #663's confirmation still gates every import.
130
+ //
131
+ // What continuity DOES change is where transcripts live: with no symlink
132
+ // planted in a home paddock does not own (#682), Claude Code keeps writing
133
+ // to `~/.claude/projects/<encoded-cwd>/` and `.chats/` stays empty.
134
+ inheritedHome === undefined
135
+ ? " · ~/.claude sessions for this directory are offered for import (nothing is moved)"
136
+ : ` · running against your ${inheritedHome}: sessions there are offered for import ` +
137
+ `(nothing is moved), and new transcripts stay there rather than in .chats/`,
151
138
  " Later runs here resume it — no flag needed.",
152
139
  "",
153
140
  ].join("\n"));
@@ -172,7 +159,7 @@ function offerHereIfSessionsExist(dir) {
172
159
  console.log([
173
160
  "",
174
161
  ` This directory has ${sessions} Claude Code session${sessions === 1 ? "" : "s"}.`,
175
- " Run `paddock --here` to open it as a workspace and import them.",
162
+ " Run `paddock --here` to open it as a workspace, where they can be imported.",
176
163
  "",
177
164
  ].join("\n"));
178
165
  }
@@ -232,6 +219,20 @@ async function main() {
232
219
  // is `.git`. A flagless run anywhere else touches nothing.
233
220
  const resuming = !opts.here && isHereWorkspace(cwd);
234
221
  const hereMode = opts.here || resuming;
222
+ // Credential continuity (#683). Decided HERE, in the interactive CLI, and
223
+ // nowhere else: `resolveClaudeHome`'s precedence is untouched, so a server, a
224
+ // container and `node dist/index.js` all keep the owned home. All this does is
225
+ // set the same `CLAUDE_HOME` a user could set by hand — the difference is that
226
+ // on macOS they currently have no way to know they need to.
227
+ const userHome = userClaudeHomePath(os.homedir());
228
+ const homeChoice = chooseClaudeHome({
229
+ env: process.env,
230
+ userClaudeHome: userHome,
231
+ userHomeExists: fs.existsSync(userHome),
232
+ forceIsolated: opts.isolatedClaudeHome,
233
+ });
234
+ if (homeChoice.claudeHome !== undefined)
235
+ process.env.CLAUDE_HOME = homeChoice.claudeHome;
235
236
  // Apply CLI defaults as env vars. This is the whole integration surface: the
236
237
  // server resolves config inside `buildApp()`, so anything set before the
237
238
  // dynamic import below is picked up with no special-casing in config.ts.
@@ -241,7 +242,7 @@ async function main() {
241
242
  (hereMode ? path.join(cwd, HERE_MARKER) : path.join(os.homedir(), ".paddock")));
242
243
  if (hereMode) {
243
244
  if (!resuming)
244
- announceHereConsent(cwd, dataDir);
245
+ announceHereConsent(cwd, dataDir, homeChoice.claudeHome);
245
246
  // The root workspace IS `projectsRoot` (its key is ""), so pointing this at
246
247
  // the user's directory makes that directory the workspace. No schema change,
247
248
  // no new project type — see cli/here.ts.
@@ -267,13 +268,27 @@ async function main() {
267
268
  // `[fleet-manager] …` lines via console.info — pino's
268
269
  // level cannot reach those.
269
270
  // An explicit value for either is left alone, and `--verbose` skips both.
271
+ // A LEVEL is not enough, which is what #684 was about. Background job failures
272
+ // are logged at `error` — level 50, above every threshold either variable can
273
+ // set — and one of them is a bare `console.error` inside the engine. So quiet
274
+ // mode is also stated as a fact the server can act on: `agent-errors.ts` reads
275
+ // this to collapse a recognised, non-fatal failure to its cause instead of
276
+ // printing a stack trace with 2 KB of system prompt in it. Internal to the
277
+ // CLI; `--verbose` leaves it unset and nothing is suppressed.
270
278
  if (!opts.verbose) {
271
279
  if (process.env.LOG_LEVEL === undefined)
272
280
  process.env.LOG_LEVEL = "warn";
273
281
  if (process.env.HERDCTL_LOG_LEVEL === undefined)
274
282
  process.env.HERDCTL_LOG_LEVEL = "warn";
283
+ process.env.PADDOCK_QUIET = "1";
275
284
  }
276
- warnIfNoCredentials(process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude"));
285
+ // No credential preflight here: `ensureClaudeHome` (claude-home.ts) already
286
+ // warns at boot, and it is the only check that can be right. It tests the home
287
+ // paddock will ACTUALLY use — after the bridge has symlinked in any
288
+ // `~/.claude/.credentials.json`, and, on darwin, after probing the Keychain for
289
+ // the login `CLAUDE_CONFIG_DIR` scoping hides (#683). It logs at `warn`, which
290
+ // survives the quiet default set just above. Continuity (chosen above) usually
291
+ // means it has nothing to say, because there is nothing left to bridge.
277
292
  const port = process.env.PORT ?? "4000";
278
293
  const host = process.env.HOST ?? "127.0.0.1";
279
294
  // Imported dynamically, AFTER the env above is set: a static import would
@@ -293,6 +308,12 @@ async function main() {
293
308
  "",
294
309
  ` Paddock is running at ${url}`,
295
310
  ` Workspace: ${hereMode ? cwd : dataDir}${resuming ? " (resumed)" : ""}`,
311
+ // Same rule as the workspace line: running against somebody's own Claude
312
+ // home is a real difference in what this instance reads and writes, so it
313
+ // is stated every run rather than only on the one that decided it.
314
+ ...(homeChoice.claudeHome !== undefined
315
+ ? [` Claude home: ${homeChoice.claudeHome} (yours — sessions and login shared)`]
316
+ : []),
296
317
  " Press Ctrl-C to stop.",
297
318
  "",
298
319
  ].join("\n"));
@@ -94,22 +94,48 @@ export interface PaddockConfig {
94
94
  /** Absolute path to the built web SPA (served in production). */
95
95
  webDist: string;
96
96
  /**
97
- * Absolute path to the Claude home (`~/.claude`, unless `CLAUDE_HOME` says
98
- * otherwise) — the directory whose `projects/<encoded-cwd>/` folders hold
99
- * Claude Code's session transcripts.
97
+ * Absolute path to the Claude home THIS INSTANCE owns — the directory whose
98
+ * `projects/<encoded-cwd>/` folders hold Claude Code's session transcripts.
99
+ * Defaults to `<dataDir>/claude-home` (#620); see {@link resolveClaudeHome}
100
+ * for the full precedence.
100
101
  *
101
102
  * Resolved ONCE here (#588) rather than re-read from `process.env` at each call
102
103
  * site, because it must be the SAME value everywhere: paddock's transcript
103
104
  * relocation (`ensureProjectChats`) symlinks `<claudeHome>/projects/<encoded>`
104
105
  * at the project's `.chats/`, and the engine's session discovery / adoption
105
106
  * resolves transcripts under `FleetManagerOptions.claudeHomePath`. If paddock
106
- * honoured `CLAUDE_HOME` while the engine fell back to `os.homedir()/.claude`,
107
+ * honoured one home while the engine fell back to `os.homedir()/.claude`,
107
108
  * detection would scan one home while reads came from another — chats LIST but
108
109
  * open EMPTY (herdctl#423 / #588 gotcha 1). The divergence is invisible
109
- * whenever `CLAUDE_HOME` equals `$HOME/.claude`, which is exactly why it is
110
- * threaded explicitly instead of being left to a shared default.
110
+ * whenever the two coincide, which is exactly why it is threaded explicitly
111
+ * instead of being left to a shared default.
111
112
  */
112
113
  claudeHome: string;
114
+ /**
115
+ * Absolute path to the USER's own Claude home — always `os.homedir()/.claude`,
116
+ * regardless of what {@link claudeHome} resolved to.
117
+ *
118
+ * This is a READ-ONLY source (#620), never a destination. Paddock consults it
119
+ * for exactly two things: adoption (`~/.claude` is where a user's own Claude
120
+ * Code CLI history lives, which #588 lets them import), and the user-level
121
+ * config bridge (see `claude-home.ts`). Nothing in paddock moves or deletes
122
+ * anything under it.
123
+ */
124
+ legacyClaudeHome: string;
125
+ /**
126
+ * Does paddock OWN {@link claudeHome} — i.e. is it something other than the
127
+ * user's `~/.claude`?
128
+ *
129
+ * The single gate on every mutating transcript operation (#620). Paddock's
130
+ * self-healing relocation is allowed to migrate a stray real transcript
131
+ * directory into `.chats/` inside a home it owns, because that directory is
132
+ * its own doing. Inside the user's home it is somebody else's data — a
133
+ * `claude` CLI session run in a directory paddock happens to also manage — and
134
+ * moving it is the destructive behaviour this flag exists to prevent.
135
+ *
136
+ * Derived, not configured: `resolve(claudeHome) !== resolve(legacyClaudeHome)`.
137
+ */
138
+ ownsClaudeHome: boolean;
113
139
  /** Provider-agnostic user-authentication config (see AUTH.md). */
114
140
  auth: AuthConfig;
115
141
  /** Voice-dictation (Whisper) capability (per-instance; default off). */
@@ -359,6 +385,8 @@ export interface PaddockConfigFile {
359
385
  stateDir?: string;
360
386
  herdctlConfigPath?: string;
361
387
  webDist?: string;
388
+ /** Claude home override; beneath `CLAUDE_HOME`/`CLAUDE_CONFIG_DIR` (#620). */
389
+ claudeHome?: string;
362
390
  auth?: {
363
391
  mode?: string;
364
392
  userHeader?: string;
@@ -525,14 +553,49 @@ export declare function loadConfigFile(dataDir: string): PaddockConfigFile;
525
553
  export declare function resolveDefaultWebDist(moduleUrl: string): string;
526
554
  export declare function loadPaddockConfig(): PaddockConfig;
527
555
  /**
528
- * Resolve the Claude home from the environment — `CLAUDE_HOME`, else
529
- * `~/.claude`.
556
+ * The USER's own Claude home — `~/.claude`, always. A read-only source for
557
+ * adoption and the user-config bridge, never a destination (#620).
558
+ *
559
+ * Stays a function (not a constant) because `os.homedir()` must be read at call
560
+ * time — tests and processes that mutate `HOME` depend on that.
561
+ */
562
+ export declare function userClaudeHome(): string;
563
+ /**
564
+ * Resolve the Claude home paddock runs its agents against.
565
+ *
566
+ * Precedence, highest first:
567
+ *
568
+ * 1. **`CLAUDE_HOME`** — paddock's own long-standing override. Setting it to
569
+ * `$HOME/.claude` restores the pre-#620 behaviour exactly, which is the
570
+ * documented escape hatch if the relocation causes trouble.
571
+ * 2. **`CLAUDE_CONFIG_DIR`** — the variable *Claude Code itself* resolves its
572
+ * home from. Honouring it is load-bearing, not a courtesy: herdctl
573
+ * deliberately refuses to clobber an operator-set `CLAUDE_CONFIG_DIR`
574
+ * (herdctl#423), so if paddock picked a different home the SDK would write
575
+ * transcripts to one tree while herdctl read from another — the exact
576
+ * split-brain #588 fixed. Deferring to it makes that state unreachable.
577
+ * 3. A `claudeHome:` key in the instance config file.
578
+ * 4. **`<dataDir>/claude-home`** — the default since #620.
579
+ *
580
+ * On the default, and why it changed: paddock already nests herdctl's whole
581
+ * state dir, the projects tree and the generated `herdctl.yaml` under its data
582
+ * dir. Transcripts were the one exception, reached by planting symlinks in the
583
+ * user's home — not a design choice but a constraint, because until
584
+ * herdctl#423 nothing set `CLAUDE_CONFIG_DIR` and the SDK wrote to `~/.claude`
585
+ * no matter what paddock configured. With that gone, owning the home makes the
586
+ * data dir relocatable as a unit, leaves `~/.claude` alone, and (because there
587
+ * is no longer a `.claude` path component) unblocks agent memory writes, which
588
+ * the harness refuses for any path containing `.claude`.
589
+ *
590
+ * NOT canonical()-ed: this exact string is what herdctl hands Claude Code as
591
+ * `CLAUDE_CONFIG_DIR`, and canonicalising would silently diverge from the
592
+ * literal path the two of them encode into `<home>/projects/<encoded-cwd>`.
530
593
  *
531
594
  * This is the RESOLVER, not the accessor. It is read once by
532
595
  * {@link loadPaddockConfig} into {@link PaddockConfig.claudeHome}; runtime code
533
596
  * should thread `cfg.claudeHome` rather than call this again, so paddock and the
534
- * engine can never end up resolving two different homes (#588). It stays a
535
- * function (not a constant) because `os.homedir()` must be read at call time —
536
- * tests and processes that mutate `HOME` depend on that.
597
+ * engine can never end up resolving two different homes (#588).
537
598
  */
538
- export declare function claudeHome(): string;
599
+ export declare function resolveClaudeHome(dataDir: string, fromFile?: string): string;
600
+ /** Directory name of paddock's own Claude home inside the data dir (#620). */
601
+ export declare const DEFAULT_CLAUDE_HOME_DIRNAME = "claude-home";
@@ -264,10 +264,10 @@ export function loadPaddockConfig() {
264
264
  const herdctlConfigPath = canonical(envOr("PADDOCK_HERDCTL_CONFIG", fileOr(file.herdctlConfigPath, path.join(dataRoot, "herdctl.yaml"))));
265
265
  // The ONE Claude home for this process (#588): paddock's transcript symlinks,
266
266
  // the engine's session discovery and session adoption all resolve against this
267
- // exact value. NOT canonical()-ed: `claudeHome()` is also what the CLI runtime
268
- // and Claude Code itself see, and canonicalising would silently diverge from
269
- // the literal path they encode into `<home>/projects/<encoded-cwd>`.
270
- const resolvedClaudeHome = claudeHome();
267
+ // exact value — which herdctl in turn hands Claude Code as `CLAUDE_CONFIG_DIR`.
268
+ // Defaults to `<dataDir>/claude-home` (#620); see `resolveClaudeHome`.
269
+ const resolvedClaudeHome = resolveClaudeHome(dataDir, fileOpt(file.claudeHome));
270
+ const legacyClaudeHome = userClaudeHome();
271
271
  const defaultWebDist = resolveDefaultWebDist(import.meta.url);
272
272
  return Object.freeze({
273
273
  port: Number(envOr("PORT", fileOr(file.port, "4000"))),
@@ -285,6 +285,11 @@ export function loadPaddockConfig() {
285
285
  herdctlConfigPath,
286
286
  webDist: abs(envOr("PADDOCK_WEB_DIST", fileOr(file.webDist, defaultWebDist))),
287
287
  claudeHome: resolvedClaudeHome,
288
+ legacyClaudeHome,
289
+ // Paddock owns any home that is not the user's own `~/.claude` (#620) —
290
+ // including one an operator pointed it at explicitly. The user's home is the
291
+ // only one whose contents belong to somebody else.
292
+ ownsClaudeHome: path.resolve(resolvedClaudeHome) !== path.resolve(legacyClaudeHome),
288
293
  auth: loadAuthConfig(file.auth),
289
294
  transcription: loadTranscriptionConfig(file.transcription),
290
295
  brand: loadBrandConfig(file.brand),
@@ -610,16 +615,58 @@ function loadDriveMode(file) {
610
615
  return raw && isKnownDriveMode(raw) ? raw : DEFAULT_DRIVE_MODE;
611
616
  }
612
617
  /**
613
- * Resolve the Claude home from the environment — `CLAUDE_HOME`, else
614
- * `~/.claude`.
618
+ * The USER's own Claude home — `~/.claude`, always. A read-only source for
619
+ * adoption and the user-config bridge, never a destination (#620).
620
+ *
621
+ * Stays a function (not a constant) because `os.homedir()` must be read at call
622
+ * time — tests and processes that mutate `HOME` depend on that.
623
+ */
624
+ export function userClaudeHome() {
625
+ return path.join(os.homedir(), ".claude");
626
+ }
627
+ /**
628
+ * Resolve the Claude home paddock runs its agents against.
629
+ *
630
+ * Precedence, highest first:
631
+ *
632
+ * 1. **`CLAUDE_HOME`** — paddock's own long-standing override. Setting it to
633
+ * `$HOME/.claude` restores the pre-#620 behaviour exactly, which is the
634
+ * documented escape hatch if the relocation causes trouble.
635
+ * 2. **`CLAUDE_CONFIG_DIR`** — the variable *Claude Code itself* resolves its
636
+ * home from. Honouring it is load-bearing, not a courtesy: herdctl
637
+ * deliberately refuses to clobber an operator-set `CLAUDE_CONFIG_DIR`
638
+ * (herdctl#423), so if paddock picked a different home the SDK would write
639
+ * transcripts to one tree while herdctl read from another — the exact
640
+ * split-brain #588 fixed. Deferring to it makes that state unreachable.
641
+ * 3. A `claudeHome:` key in the instance config file.
642
+ * 4. **`<dataDir>/claude-home`** — the default since #620.
643
+ *
644
+ * On the default, and why it changed: paddock already nests herdctl's whole
645
+ * state dir, the projects tree and the generated `herdctl.yaml` under its data
646
+ * dir. Transcripts were the one exception, reached by planting symlinks in the
647
+ * user's home — not a design choice but a constraint, because until
648
+ * herdctl#423 nothing set `CLAUDE_CONFIG_DIR` and the SDK wrote to `~/.claude`
649
+ * no matter what paddock configured. With that gone, owning the home makes the
650
+ * data dir relocatable as a unit, leaves `~/.claude` alone, and (because there
651
+ * is no longer a `.claude` path component) unblocks agent memory writes, which
652
+ * the harness refuses for any path containing `.claude`.
653
+ *
654
+ * NOT canonical()-ed: this exact string is what herdctl hands Claude Code as
655
+ * `CLAUDE_CONFIG_DIR`, and canonicalising would silently diverge from the
656
+ * literal path the two of them encode into `<home>/projects/<encoded-cwd>`.
615
657
  *
616
658
  * This is the RESOLVER, not the accessor. It is read once by
617
659
  * {@link loadPaddockConfig} into {@link PaddockConfig.claudeHome}; runtime code
618
660
  * should thread `cfg.claudeHome` rather than call this again, so paddock and the
619
- * engine can never end up resolving two different homes (#588). It stays a
620
- * function (not a constant) because `os.homedir()` must be read at call time —
621
- * tests and processes that mutate `HOME` depend on that.
661
+ * engine can never end up resolving two different homes (#588).
622
662
  */
623
- export function claudeHome() {
624
- return process.env.CLAUDE_HOME ?? path.join(os.homedir(), ".claude");
663
+ export function resolveClaudeHome(dataDir, fromFile) {
664
+ const env = envOpt("CLAUDE_HOME") ?? envOpt("CLAUDE_CONFIG_DIR");
665
+ if (env !== undefined)
666
+ return abs(env);
667
+ if (fromFile !== undefined && fromFile !== "")
668
+ return abs(fromFile);
669
+ return path.join(dataDir, DEFAULT_CLAUDE_HOME_DIRNAME);
625
670
  }
671
+ /** Directory name of paddock's own Claude home inside the data dir (#620). */
672
+ export const DEFAULT_CLAUDE_HOME_DIRNAME = "claude-home";
@@ -56,15 +56,22 @@ export declare function buildAgentConfig(cfg: PaddockConfig, project: Project, m
56
56
  */
57
57
  export declare function sweeperWorkingDir(cfg: PaddockConfig, slug: string): string;
58
58
  /**
59
- * A project's sweeper (curator) agent config. TOOL-LESS: the sweeper has NO
60
- * tools (`allowed_tools: []`) — it never reads or writes files. Instead it
61
- * RETURNS the curated content as plain assistant text in marked sections
62
- * (OVERVIEW / CHANGELOG / optional CLAUDE, issue #177); SweepService parses
63
- * that text and writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
59
+ * A project's sweeper (curator) agent config. TOOL-LESS BY DESIGN: it declares
60
+ * `allowed_tools: []` and never reads or writes files. Instead it RETURNS the
61
+ * curated content as plain assistant text in marked sections (OVERVIEW /
62
+ * CHANGELOG / optional CLAUDE, issue #177); SweepService parses that text and
63
+ * writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
64
64
  *
65
65
  * This is cheaper and far more predictable than letting a Haiku agent drive
66
66
  * file edits: no tool-loop turns, no partial writes, no permission_mode /
67
- * denied_tools to reason about (all irrelevant with zero tools).
67
+ * denied_tools to reason about.
68
+ *
69
+ * What makes it tool-less is NOT the empty `allowed_tools` — herdctl emits an
70
+ * allow-list only when it is non-empty, so `[]` restricts nothing (#647). The
71
+ * actual guarantees are: no injected MCP servers (the sweeper is the one turn
72
+ * path that does not even get `send_file`), `max_turns: 4`, a system prompt
73
+ * whose first line is "You DO NOT use any tools", and a non-interactive
74
+ * `claude -p` run that cannot answer a permission prompt.
68
75
  */
69
76
  export declare function buildSweeperConfig(cfg: PaddockConfig, project: Project): Record<string, unknown> & {
70
77
  name: string;
@@ -77,7 +84,8 @@ export declare function buildSweeperConfig(cfg: PaddockConfig, project: Project)
77
84
  * (`allowed_tools`/`permission_mode`/`model`/`max_turns`, projected by
78
85
  * {@link triggerToAgentToolConfig} from the trigger's `run`) IS its capability set.
79
86
  * Runs in the project's WORKING dir (so a trigger's Bash/Write act on the same tree
80
- * the keeper does). A tool-less trigger gets `allowed_tools: []` and can only return text.
87
+ * the keeper does). A trigger that declares no tools gets `allowed_tools: []` — which
88
+ * herdctl does not enforce as a deny-all; see {@link triggerToAgentToolConfig} (#647).
81
89
  */
82
90
  export declare function buildTriggerConfig(cfg: PaddockConfig, project: Project, triggerName: string, trigger: PaddockTrigger): Record<string, unknown> & {
83
91
  name: string;
@@ -141,15 +141,22 @@ export function sweeperWorkingDir(cfg, slug) {
141
141
  return path.join(cfg.dataDir, "sweepers", slug === "" ? "_root" : slug);
142
142
  }
143
143
  /**
144
- * A project's sweeper (curator) agent config. TOOL-LESS: the sweeper has NO
145
- * tools (`allowed_tools: []`) — it never reads or writes files. Instead it
146
- * RETURNS the curated content as plain assistant text in marked sections
147
- * (OVERVIEW / CHANGELOG / optional CLAUDE, issue #177); SweepService parses
148
- * that text and writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
144
+ * A project's sweeper (curator) agent config. TOOL-LESS BY DESIGN: it declares
145
+ * `allowed_tools: []` and never reads or writes files. Instead it RETURNS the
146
+ * curated content as plain assistant text in marked sections (OVERVIEW /
147
+ * CHANGELOG / optional CLAUDE, issue #177); SweepService parses that text and
148
+ * writes OVERVIEW.md / CHANGELOG.md / CLAUDE.md itself.
149
149
  *
150
150
  * This is cheaper and far more predictable than letting a Haiku agent drive
151
151
  * file edits: no tool-loop turns, no partial writes, no permission_mode /
152
- * denied_tools to reason about (all irrelevant with zero tools).
152
+ * denied_tools to reason about.
153
+ *
154
+ * What makes it tool-less is NOT the empty `allowed_tools` — herdctl emits an
155
+ * allow-list only when it is non-empty, so `[]` restricts nothing (#647). The
156
+ * actual guarantees are: no injected MCP servers (the sweeper is the one turn
157
+ * path that does not even get `send_file`), `max_turns: 4`, a system prompt
158
+ * whose first line is "You DO NOT use any tools", and a non-interactive
159
+ * `claude -p` run that cannot answer a permission prompt.
153
160
  */
154
161
  export function buildSweeperConfig(cfg, project) {
155
162
  // T5: the sweeper IS the default `curate-overview` (event/afterTurn) trigger. When a
@@ -171,9 +178,13 @@ export function buildSweeperConfig(cfg, project) {
171
178
  // addAgent does deep-merge the loaded defaults, they just never carry it).
172
179
  runtime: "cli",
173
180
  model: curatorModel ?? SWEEPER_DEFAULT_MODEL,
174
- // Tool-less: a handful of turns is plenty since there are no tool loops.
181
+ // A handful of turns is plenty since there are no tool loops — and this, not
182
+ // the empty allow-list below, is one of the things actually bounding the run.
175
183
  max_turns: 4,
176
- // NO tools. The sweeper returns text only; SweepService does the writing.
184
+ // Declares NO tools: the sweeper returns text only; SweepService does the
185
+ // writing. Note herdctl does not emit an empty allow-list at all, so this
186
+ // restricts nothing on its own (#647) — see the doc comment above for the
187
+ // properties that do make the sweeper tool-less in practice.
177
188
  allowed_tools: [],
178
189
  system_prompt: "You are a concise project curator. You DO NOT use any tools — you only " +
179
190
  "return text. From the recent activity, the current OVERVIEW.md, the " +
@@ -226,7 +237,8 @@ export function buildSweeperConfig(cfg, project) {
226
237
  * (`allowed_tools`/`permission_mode`/`model`/`max_turns`, projected by
227
238
  * {@link triggerToAgentToolConfig} from the trigger's `run`) IS its capability set.
228
239
  * Runs in the project's WORKING dir (so a trigger's Bash/Write act on the same tree
229
- * the keeper does). A tool-less trigger gets `allowed_tools: []` and can only return text.
240
+ * the keeper does). A trigger that declares no tools gets `allowed_tools: []` — which
241
+ * herdctl does not enforce as a deny-all; see {@link triggerToAgentToolConfig} (#647).
230
242
  */
231
243
  export function buildTriggerConfig(cfg, project, triggerName, trigger) {
232
244
  const config = {