@cat-factory/executor-harness 1.134.0 → 1.135.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 +2 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-runner.d.ts +7 -0
- package/dist/agent-runner.js +26 -183
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.js +0 -6
- package/dist/claude-cli.d.ts +90 -0
- package/dist/claude-cli.js +181 -0
- package/dist/claude-home.d.ts +41 -0
- package/dist/claude-home.js +159 -0
- package/dist/multi-repo-coding.js +6 -8
- package/dist/pi-workspace.js +80 -58
- package/package.json +1 -1
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-runner.ts +26 -214
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +0 -6
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/multi-repo-coding.ts +6 -8
- package/src/pi-workspace.ts +90 -58
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Logger } from './logger.js'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The Claude Code CLI's INVOCATION surface: which of its built-in tools a run asks for, the argv
|
|
5
|
+
// that asks, and the read-back that says what the CLI actually granted.
|
|
6
|
+
//
|
|
7
|
+
// The three belong together because they are one decision seen from three sides. Before this
|
|
8
|
+
// module the harness declared nothing and took whatever the CLI's headless default happened to
|
|
9
|
+
// be, which drifted across CLI versions: 2.1.226 offered the plan tools, 2.1.245 did not, and
|
|
10
|
+
// nothing in the run said so. Both halves of that default were wrong for a disposable container:
|
|
11
|
+
// no `Grep`/`Glob` (so every search went through `Bash` and counted against the progress guard's
|
|
12
|
+
// no-edit budget), no plan tools (so `step.progress` had no signal to lift), and a dozen tools
|
|
13
|
+
// (`CronCreate`, `DesignSync`, `EnterWorktree`, `ScheduleWakeup`, `SendMessage`, `Workflow`,
|
|
14
|
+
// `ReportFindings`, …) an agent in a per-run container can act on none of.
|
|
15
|
+
//
|
|
16
|
+
// Declaring a set therefore has to be measured against the default it replaces, not just against
|
|
17
|
+
// the issue that asked for it: everything the default carried and a container CAN use has to be
|
|
18
|
+
// asked for by name, or the declaration is itself a capability loss. That is what `Monitor` and
|
|
19
|
+
// the web tools are doing in the list below.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every built-in tool a run asks the CLI for, and the ONE value that rides both `--tools` and the
|
|
24
|
+
* `--allowedTools` re-grant.
|
|
25
|
+
*
|
|
26
|
+
* One value rather than two derived ones, because the allow-list turned out to be ADDITIVE rather
|
|
27
|
+
* than inert: a name in it is UNLOCKED, not merely re-permitted (measured: `--allowedTools
|
|
28
|
+
* "Bash,Grep"` yields the default set PLUS `Glob` and `Grep`). Two independently-computed lists
|
|
29
|
+
* would therefore not merely disagree, they would silently re-grant what the other withheld.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately OVER-inclusive, and safe to be, because a name the build does not have is dropped
|
|
32
|
+
* silently rather than refused. The cost is one-directional: a name the CLI HAS and this list
|
|
33
|
+
* LACKS is a capability silently removed from every run. So the list is measured against the
|
|
34
|
+
* headless default it replaces, not only against what was wanted, and when the CLI gains a tool a
|
|
35
|
+
* container agent can use, it is added here.
|
|
36
|
+
*
|
|
37
|
+
* A name 2.1.246 does not serve is kept for one of two measured reasons, and they are different
|
|
38
|
+
* facts worth keeping apart (each probed alone, reading the `init` event's own `tools` array):
|
|
39
|
+
*
|
|
40
|
+
* - ALIASED onto a successor, so the old spelling still buys the capability: `BashOutput` grants
|
|
41
|
+
* `TaskOutput`, `KillBash` and `KillShell` both grant `TaskStop`, `Agent` grants `Task`.
|
|
42
|
+
* - DROPPED outright (`ListMcpResources`, `ReadMcpResource`, `MultiEdit`, `NotebookRead`,
|
|
43
|
+
* `TodoWrite`), and kept only because the harness image is pinned per workspace, so one build
|
|
44
|
+
* of this source faces several CLI versions and an older one still serves them.
|
|
45
|
+
*
|
|
46
|
+
* The second category is why the CURRENT spelling has to be listed beside the old one rather than
|
|
47
|
+
* instead of it: `ListMcpResources`/`ReadMcpResource` were carried alone, and since neither is an
|
|
48
|
+
* alias, every tool-server run reached its resources through nothing at all.
|
|
49
|
+
*
|
|
50
|
+
* `WebSearch`/`WebFetch` are unconditional, which is a deliberate reversal of the first cut of this
|
|
51
|
+
* module. They were gated on the job's `webSearch` flag, which states whether OUR PROXY can serve
|
|
52
|
+
* web research for the run's account (see `resolveWebSearchAvailability`, whose whole rationale is
|
|
53
|
+
* that Pi's proxy-backed tools "would just fail/return nothing" without a key). The CLI's web tools
|
|
54
|
+
* are not proxy-backed: the vendor the leased subscription already pays serves them, and they work
|
|
55
|
+
* on a deployment with no search provider wired at all. Gating them on that flag therefore withheld
|
|
56
|
+
* a WORKING capability on the strength of an unrelated fact, which is the opposite of the
|
|
57
|
+
* pass-through an unwired capability owes. The flag that would legitimately withhold them is a
|
|
58
|
+
* per-run "may this run reach the web" POLICY, which this platform does not have today; when it
|
|
59
|
+
* gains one, it gates here and on the Pi path together.
|
|
60
|
+
*/
|
|
61
|
+
export const CLAUDE_TOOL_SET: readonly string[] = [
|
|
62
|
+
'Agent',
|
|
63
|
+
'Bash',
|
|
64
|
+
'BashOutput',
|
|
65
|
+
'Edit',
|
|
66
|
+
'Glob',
|
|
67
|
+
'Grep',
|
|
68
|
+
'KillBash',
|
|
69
|
+
'KillShell',
|
|
70
|
+
'ListMcpResources',
|
|
71
|
+
'ListMcpResourcesTool',
|
|
72
|
+
// Waits on the background shells `Bash(run_in_background)` starts. A tool in its own right
|
|
73
|
+
// (measured: `Monitor` grants `Monitor`), not an alias of the retired kill/output pair, and it
|
|
74
|
+
// is in the headless default, so omitting it was this declaration's own capability loss.
|
|
75
|
+
'Monitor',
|
|
76
|
+
'MultiEdit',
|
|
77
|
+
'NotebookEdit',
|
|
78
|
+
'NotebookRead',
|
|
79
|
+
'Read',
|
|
80
|
+
'ReadMcpResource',
|
|
81
|
+
'ReadMcpResourceTool',
|
|
82
|
+
'Skill',
|
|
83
|
+
'Task',
|
|
84
|
+
'TaskCreate',
|
|
85
|
+
'TaskGet',
|
|
86
|
+
'TaskList',
|
|
87
|
+
'TaskOutput',
|
|
88
|
+
'TaskStop',
|
|
89
|
+
'TaskUpdate',
|
|
90
|
+
'TodoWrite',
|
|
91
|
+
// Loads the schemas of tools a build defers rather than declaring up front. Dropped by 2.1.246
|
|
92
|
+
// (measured, with and without a tool server wired), and asked for anyway under the
|
|
93
|
+
// over-inclusive rule: a run wiring several tool servers is exactly the shape a build that
|
|
94
|
+
// defers tool schemas would hand one to.
|
|
95
|
+
'ToolSearch',
|
|
96
|
+
'WebFetch',
|
|
97
|
+
'WebSearch',
|
|
98
|
+
'Write',
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One capability the run genuinely cannot do without, and every CLI spelling that satisfies it.
|
|
103
|
+
*
|
|
104
|
+
* The floor is expressed as CAPABILITIES rather than as names because {@link CLAUDE_TOOL_SET}
|
|
105
|
+
* is over-inclusive on purpose: a literal "warn on anything requested but absent" would fire on
|
|
106
|
+
* every single run for the alternate spellings this image carries for other CLI versions, and a
|
|
107
|
+
* warning that is always on is one nobody reads. A capability with no granted spelling is the
|
|
108
|
+
* fact worth a line: it means an upstream rename or removal took a tool out of every run of this
|
|
109
|
+
* image, which otherwise surfaces days later as an agent behaving oddly.
|
|
110
|
+
*/
|
|
111
|
+
interface ClaudeToolCapability {
|
|
112
|
+
capability: string
|
|
113
|
+
spellings: readonly string[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const CLAUDE_TOOL_FLOOR: readonly ClaudeToolCapability[] = [
|
|
117
|
+
{ capability: 'shell', spellings: ['Bash'] },
|
|
118
|
+
{ capability: 'read', spellings: ['Read'] },
|
|
119
|
+
{ capability: 'write', spellings: ['Write'] },
|
|
120
|
+
{ capability: 'edit', spellings: ['Edit', 'MultiEdit'] },
|
|
121
|
+
{ capability: 'search', spellings: ['Grep'] },
|
|
122
|
+
{ capability: 'glob', spellings: ['Glob'] },
|
|
123
|
+
{ capability: 'subagents', spellings: ['Task', 'Agent'] },
|
|
124
|
+
// The plan signal the harness lifts into `step.subtasks` / `step.progress`, in the two
|
|
125
|
+
// vocabularies the CLI has used for it (see `progress.ts`, which reads both).
|
|
126
|
+
{ capability: 'plan', spellings: ['TaskCreate', 'TodoWrite'] },
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
/** The floor, exported so a test can assert every spelling is one this harness actually asks for. */
|
|
130
|
+
export const CLAUDE_TOOL_CAPABILITIES: readonly ClaudeToolCapability[] = CLAUDE_TOOL_FLOOR
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The `claude` argv for one run, in the order the CLI's variadic flags require.
|
|
134
|
+
*
|
|
135
|
+
* `--tools` and `--allowedTools` are both declared `<tools...>`, so each swallows any trailing
|
|
136
|
+
* POSITIONAL argument as another tool name; only a following `--flag` terminates them. The prompt
|
|
137
|
+
* therefore stays on stdin (see `streamCli`) and every flag here is placed before the variadic
|
|
138
|
+
* pair or introduced by its own `--`, so a new flag cannot be eaten by the one in front of it.
|
|
139
|
+
*/
|
|
140
|
+
export function claudeCliArgs(opts: {
|
|
141
|
+
model: string
|
|
142
|
+
/** The built-in tools this run asks for; see {@link CLAUDE_TOOL_SET}. */
|
|
143
|
+
tools: readonly string[]
|
|
144
|
+
/** `--mcp-config` + `--strict-mcp-config` + any `--allowedTools`; empty when no server is wired. */
|
|
145
|
+
mcpArgs: readonly string[]
|
|
146
|
+
/** `--append-system-prompt <prompt>`, or empty when the prompt was folded into stdin. */
|
|
147
|
+
appendArgs: readonly string[]
|
|
148
|
+
}): string[] {
|
|
149
|
+
return [
|
|
150
|
+
'-p',
|
|
151
|
+
'--output-format',
|
|
152
|
+
'stream-json',
|
|
153
|
+
'--verbose',
|
|
154
|
+
// The per-run container IS the sandbox, and the run is fully headless (no one to approve a
|
|
155
|
+
// tool call) — so bypass permissions entirely. `acceptEdits` would auto-accept file edits but
|
|
156
|
+
// still gate Bash, which in `-p` mode is then denied, leaving the agent unable to run
|
|
157
|
+
// builds/tests/git to verify its work.
|
|
158
|
+
'--permission-mode',
|
|
159
|
+
'bypassPermissions',
|
|
160
|
+
'--model',
|
|
161
|
+
opts.model,
|
|
162
|
+
// Declared rather than defaulted: see this module's header for what the default set costs.
|
|
163
|
+
'--tools',
|
|
164
|
+
opts.tools.join(','),
|
|
165
|
+
...opts.mcpArgs,
|
|
166
|
+
...opts.appendArgs,
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read the CLI's startup report (`{"type":"system","subtype":"init"}`) back against what this run
|
|
172
|
+
* asked for, and say when a required capability is missing.
|
|
173
|
+
*
|
|
174
|
+
* The same pairing, and for the same reason, as `assertOnboardingKeysCurrent`: the CLI is the only
|
|
175
|
+
* one who knows what it granted, it says so exactly once before the first model call, and pairing
|
|
176
|
+
* that answer with the CLI version is what makes an upstream rename diffable instead of a mystery.
|
|
177
|
+
* The version comes off the event itself (`claude_code_version`) rather than an env var the image
|
|
178
|
+
* would have to remember to bake.
|
|
179
|
+
*
|
|
180
|
+
* Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
|
|
181
|
+
* disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
|
|
182
|
+
* job and not to stay silent (which reads exactly like a satisfied request).
|
|
183
|
+
*/
|
|
184
|
+
export function assertClaudeToolsCurrent(
|
|
185
|
+
event: Record<string, unknown>,
|
|
186
|
+
requested: readonly string[],
|
|
187
|
+
log: Logger | undefined,
|
|
188
|
+
): void {
|
|
189
|
+
if (!log || event.type !== 'system' || event.subtype !== 'init') return
|
|
190
|
+
const version =
|
|
191
|
+
typeof event.claude_code_version === 'string' ? event.claude_code_version : undefined
|
|
192
|
+
const cliVersion = version ? { cliVersion: version } : {}
|
|
193
|
+
if (!Array.isArray(event.tools)) {
|
|
194
|
+
log.warn('claude-code announced no tool list, so this run has an unverified tool surface', {
|
|
195
|
+
requestedTools: [...requested],
|
|
196
|
+
...cliVersion,
|
|
197
|
+
})
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const granted = new Set(event.tools.filter((t): t is string => typeof t === 'string'))
|
|
201
|
+
const missing = CLAUDE_TOOL_FLOOR.filter((c) => !c.spellings.some((s) => granted.has(s))).map(
|
|
202
|
+
(c) => c.capability,
|
|
203
|
+
)
|
|
204
|
+
const fields = {
|
|
205
|
+
requestedTools: [...requested],
|
|
206
|
+
grantedTools: [...granted].sort(),
|
|
207
|
+
...cliVersion,
|
|
208
|
+
}
|
|
209
|
+
if (missing.length > 0) {
|
|
210
|
+
log.warn('claude-code granted no tool for a capability this run requires', {
|
|
211
|
+
...fields,
|
|
212
|
+
missingCapabilities: missing,
|
|
213
|
+
})
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
log.info('claude-code tool set granted', fields)
|
|
217
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
claudeAllowedToolPatterns,
|
|
6
|
+
mcpServerSecretValues,
|
|
7
|
+
writeClaudeMcpConfig,
|
|
8
|
+
type McpServerSpec,
|
|
9
|
+
type SkillSpec,
|
|
10
|
+
} from './agent-capabilities.js'
|
|
11
|
+
import type { Logger } from './logger.js'
|
|
12
|
+
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
13
|
+
import { registerKnownSecrets } from './redact.js'
|
|
14
|
+
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// The PER-RUN Claude Code config home: everything written for one claude-code job and torn down
|
|
18
|
+
// with it — the isolated `CLAUDE_CONFIG_DIR`, its onboarding pre-seed, the run's native skills,
|
|
19
|
+
// its MCP config, and the child env that points the CLI at all of it.
|
|
20
|
+
//
|
|
21
|
+
// The sibling of `codex-home.ts`, extracted from `runClaudeCode` for the same reason: the run
|
|
22
|
+
// loop's own job is streaming and reducing the CLI's events, while this is a directory with a
|
|
23
|
+
// lifecycle that holds a credential.
|
|
24
|
+
//
|
|
25
|
+
// CRITICAL, and why it is a temp dir rather than anything under the checkout: several handlers
|
|
26
|
+
// finish with `git add -A` + push, so a `.claude/` directory inside `opts.cwd` would publish any
|
|
27
|
+
// cached credential to the PR branch.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** What one claude-code job needs written into its own home. */
|
|
31
|
+
export interface ClaudeHomeOptions {
|
|
32
|
+
/** The decrypted subscription OAuth token. Required unless `ambientAuth`. */
|
|
33
|
+
subscriptionToken?: string
|
|
34
|
+
/**
|
|
35
|
+
* Anthropic-compatible base URL for a non-Anthropic Claude-Code vendor (GLM/Kimi): present ⇒
|
|
36
|
+
* ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN, absent ⇒ CLAUDE_CODE_OAUTH_TOKEN.
|
|
37
|
+
*/
|
|
38
|
+
subscriptionBaseUrl?: string
|
|
39
|
+
/** Run the developer's own CLI login instead: no isolated home, nothing installed. */
|
|
40
|
+
ambientAuth?: boolean
|
|
41
|
+
/** Skills to install natively under `<configHome>/skills/<name>/`. */
|
|
42
|
+
skills?: SkillSpec[]
|
|
43
|
+
/** Tool servers to scope to this job's config. */
|
|
44
|
+
mcpServers?: McpServerSpec[]
|
|
45
|
+
/** Job-scoped child env (tester secrets, a private-registry npmrc pointer). */
|
|
46
|
+
extraEnv?: Record<string, string>
|
|
47
|
+
log?: Logger
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
|
|
52
|
+
* `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
|
|
53
|
+
* expects) plus every resource file at its path within the skill directory. Resource sub-paths
|
|
54
|
+
* were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
|
|
55
|
+
*
|
|
56
|
+
* The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
|
|
57
|
+
* scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
|
|
58
|
+
* or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
|
|
59
|
+
* would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
|
|
60
|
+
* valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
|
|
61
|
+
*/
|
|
62
|
+
async function writeNativeSkill(skillsRoot: string, skill: SkillSpec): Promise<void> {
|
|
63
|
+
const dir = join(skillsRoot, skill.name)
|
|
64
|
+
await mkdir(dir, { recursive: true })
|
|
65
|
+
const name = JSON.stringify(skill.name)
|
|
66
|
+
const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '))
|
|
67
|
+
const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`
|
|
68
|
+
await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8')
|
|
69
|
+
for (const resource of skill.resources) {
|
|
70
|
+
const dest = join(dir, resource.relPath)
|
|
71
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
72
|
+
await writeFile(dest, resource.content, 'utf8')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
|
|
78
|
+
* return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
|
|
79
|
+
*
|
|
80
|
+
* Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
|
|
81
|
+
* ambient run on a developer's own machine can never silently hand the agent their personal ones.
|
|
82
|
+
* And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
|
|
83
|
+
* whole-session, not MCP-scoped, so it carries `builtIns`, the SAME list this run declared with
|
|
84
|
+
* `--tools`, in the same entry; see `claudeAllowedToolPatterns` for why that list is threaded in
|
|
85
|
+
* rather than re-derived, and how the run's permission mode treats an allow-list.
|
|
86
|
+
*
|
|
87
|
+
* The config carries this job's resolved credentials, so it goes in the isolated config home when
|
|
88
|
+
* we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
|
|
89
|
+
* commit) and never a shared HOME path (a concurrent job would clobber it).
|
|
90
|
+
*/
|
|
91
|
+
async function setUpClaudeMcp(
|
|
92
|
+
servers: McpServerSpec[] | undefined,
|
|
93
|
+
configHome: string | undefined,
|
|
94
|
+
builtIns: readonly string[],
|
|
95
|
+
): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
|
|
96
|
+
const noop = { args: [], cleanup: async () => {} }
|
|
97
|
+
if (!servers?.length) return noop
|
|
98
|
+
// Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
|
|
99
|
+
// that tail is carried onto the step's diagnostics.
|
|
100
|
+
registerKnownSecrets(mcpServerSecretValues(servers))
|
|
101
|
+
const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')))
|
|
102
|
+
const owned = home === configHome ? undefined : home
|
|
103
|
+
const cleanup = async (): Promise<void> => {
|
|
104
|
+
if (owned) await rm(owned, { recursive: true, force: true }).catch(() => {})
|
|
105
|
+
}
|
|
106
|
+
const configPath = await writeClaudeMcpConfig(home, servers)
|
|
107
|
+
if (!configPath) return { args: [], cleanup }
|
|
108
|
+
const allowedTools = claudeAllowedToolPatterns(servers, builtIns)
|
|
109
|
+
return {
|
|
110
|
+
args: [
|
|
111
|
+
'--mcp-config',
|
|
112
|
+
configPath,
|
|
113
|
+
'--strict-mcp-config',
|
|
114
|
+
...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
|
|
115
|
+
],
|
|
116
|
+
cleanup,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The isolated, per-run home the `claude` CLI runs against: a temp config dir OUTSIDE the cloned
|
|
122
|
+
* checkout, pre-seeded past the first-launch prompts, carrying the run's native skills and MCP
|
|
123
|
+
* config, plus the child env pointing the CLI at it. {@link ClaudeRunHome.dispose} is the other
|
|
124
|
+
* half of the same concern — the leased credential must never outlive the run — so acquisition
|
|
125
|
+
* and teardown are defined together rather than split across a `finally` forty lines away.
|
|
126
|
+
*
|
|
127
|
+
* Ambient (native) mode has NO home: the developer's installed CLI uses its own `~/.claude`
|
|
128
|
+
* login, so nothing is created, nothing is pre-seeded, and `dispose` only clears the MCP config.
|
|
129
|
+
*/
|
|
130
|
+
export interface ClaudeRunHome {
|
|
131
|
+
/** The per-run config dir; `undefined` in ambient mode (the developer's own login is used). */
|
|
132
|
+
configHome: string | undefined
|
|
133
|
+
/** The CLI argv selecting the run's tool servers; empty when it has none. */
|
|
134
|
+
mcpArgs: string[]
|
|
135
|
+
/** The child-process env (see {@link buildClaudeEnv}). */
|
|
136
|
+
env: Record<string, string>
|
|
137
|
+
dispose: () => Promise<void>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function openClaudeRunHome(
|
|
141
|
+
opts: ClaudeHomeOptions,
|
|
142
|
+
tools: readonly string[],
|
|
143
|
+
): Promise<ClaudeRunHome> {
|
|
144
|
+
// Native (ambient) mode: run the developer's installed `claude` with its OWN login —
|
|
145
|
+
// no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
|
|
146
|
+
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
147
|
+
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
148
|
+
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
149
|
+
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
150
|
+
// Mirrors the Codex CODEX_HOME isolation (`codex-home.ts`); removed by `dispose`.
|
|
151
|
+
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
152
|
+
throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
|
|
153
|
+
}
|
|
154
|
+
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
|
|
155
|
+
|
|
156
|
+
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
157
|
+
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
158
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
159
|
+
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
160
|
+
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
161
|
+
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
162
|
+
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
163
|
+
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
164
|
+
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
165
|
+
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
166
|
+
if (configHome) {
|
|
167
|
+
await writeOnboardingPreseed(configHome)
|
|
168
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
172
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
173
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
174
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
175
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
176
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
177
|
+
// still names the skills.
|
|
178
|
+
if (configHome) {
|
|
179
|
+
for (const skill of opts.skills ?? []) {
|
|
180
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
185
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
186
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome, tools)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
configHome,
|
|
190
|
+
mcpArgs: mcp.args,
|
|
191
|
+
env: buildClaudeEnv(opts, configHome),
|
|
192
|
+
dispose: async () => {
|
|
193
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
194
|
+
await mcp.cleanup()
|
|
195
|
+
if (!configHome) return
|
|
196
|
+
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
197
|
+
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
198
|
+
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
199
|
+
await retainSessionTranscripts(configHome, ['projects'], {
|
|
200
|
+
label: 'claude-code',
|
|
201
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
202
|
+
})
|
|
203
|
+
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
204
|
+
await rm(configHome, { recursive: true, force: true }).catch(() => {})
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build the child-process env for the `claude` CLI: an isolated config home plus subscription
|
|
211
|
+
* auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
|
|
212
|
+
* non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
|
|
213
|
+
* (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
|
|
214
|
+
* keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
|
|
215
|
+
*/
|
|
216
|
+
function buildClaudeEnv(
|
|
217
|
+
opts: ClaudeHomeOptions,
|
|
218
|
+
configHome: string | undefined,
|
|
219
|
+
): Record<string, string> {
|
|
220
|
+
// The job-scoped env rides along in BOTH modes; the credential/config vars below are what
|
|
221
|
+
// ambient mode drops (the developer's own logged-in `~/.claude` is used instead).
|
|
222
|
+
if (opts.ambientAuth) return { ...opts.extraEnv }
|
|
223
|
+
return {
|
|
224
|
+
...opts.extraEnv,
|
|
225
|
+
CLAUDE_CONFIG_DIR: configHome!,
|
|
226
|
+
...(opts.subscriptionBaseUrl
|
|
227
|
+
? {
|
|
228
|
+
ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
|
|
229
|
+
ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
|
|
230
|
+
}
|
|
231
|
+
: { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/multi-repo-coding.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js'
|
|
|
23
23
|
import type { RunOptions } from './runner.js'
|
|
24
24
|
import { log, type Logger } from './logger.js'
|
|
25
25
|
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
|
|
26
|
+
import { agentCapabilities } from './agent-shared.js'
|
|
26
27
|
import {
|
|
27
28
|
resolvePrTemplateNote,
|
|
28
29
|
withPrTemplateNote,
|
|
@@ -201,16 +202,13 @@ export async function runMultiRepoCoding(
|
|
|
201
202
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
202
203
|
proxyPhasePath: job.proxyPhasePath,
|
|
203
204
|
sessionToken: job.sessionToken,
|
|
204
|
-
webToolsGuidance: job.webToolsGuidance,
|
|
205
|
-
webSearchProxy: job.webSearch,
|
|
206
205
|
guardLimits: job.guardLimits,
|
|
207
206
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
208
|
-
// Skills
|
|
209
|
-
// are properties of the AGENT KIND, not of the checkout layout.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
...(job
|
|
213
|
-
...(job.designImages ? { designImages: job.designImages } : {}),
|
|
207
|
+
// Skills, tool servers and web research apply to a multi-repo run exactly as to a
|
|
208
|
+
// single-repo one: they are properties of the AGENT KIND, not of the checkout layout.
|
|
209
|
+
// Through the shared helper rather than re-spread here, which is what let this flow
|
|
210
|
+
// drift from the single-repo one in the first place.
|
|
211
|
+
...agentCapabilities(job),
|
|
214
212
|
multiRepo: true,
|
|
215
213
|
// What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
|
|
216
214
|
repoDirs: probeDirsForLegs(legs),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -308,6 +308,90 @@ export async function checkoutHasBlueprints(dir: string, multiRepo: boolean): Pr
|
|
|
308
308
|
return checks.some(Boolean)
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Run one pass on a SUBSCRIPTION harness (Claude Code / Codex): the leased-credential path, which
|
|
313
|
+
* shares only the checkout preparation with the Pi one.
|
|
314
|
+
*
|
|
315
|
+
* Split out of {@link runAgentInWorkspace} for its cyclomatic budget. It is also the honest seam:
|
|
316
|
+
* everything here is a decision about what the vendor's own CLI is handed, while everything left
|
|
317
|
+
* behind is about the proxy-backed Pi run.
|
|
318
|
+
*/
|
|
319
|
+
async function runSubscriptionInWorkspace(
|
|
320
|
+
harness: 'claude-code' | 'codex',
|
|
321
|
+
spec: AgentRunSpec,
|
|
322
|
+
opts: RunOptions,
|
|
323
|
+
prepared: {
|
|
324
|
+
contextFiles: ContextFileInfo[]
|
|
325
|
+
imageGuidance: string
|
|
326
|
+
workspaceProbe: WorkspaceProbe
|
|
327
|
+
},
|
|
328
|
+
): Promise<PiRunOutcome> {
|
|
329
|
+
const { contextFiles, imageGuidance, workspaceProbe } = prepared
|
|
330
|
+
// Ambient (native) mode authenticates with the developer's own CLI login, so no
|
|
331
|
+
// leased token is required; otherwise the leased subscription token is mandatory.
|
|
332
|
+
if (!spec.ambientAuth && !spec.subscriptionToken) {
|
|
333
|
+
throw new Error(`The ${harness} harness requires a subscription token`)
|
|
334
|
+
}
|
|
335
|
+
const subOutcome = await runSubscriptionHarness(harness, {
|
|
336
|
+
cwd: spec.dir,
|
|
337
|
+
model: spec.model,
|
|
338
|
+
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
|
|
339
|
+
userPrompt: spec.userPrompt,
|
|
340
|
+
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
341
|
+
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
342
|
+
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
343
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
344
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
345
|
+
// Codex's own image tool. Passed for both subscription harnesses because the option lives on
|
|
346
|
+
// the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
|
|
347
|
+
// (unlike an MCP server) there is nothing to report as unservable — the backend never
|
|
348
|
+
// resolves a codex-served generator onto a claude-code step, because admission refuses it.
|
|
349
|
+
...(spec.generateImages ? { generateImages: true } : {}),
|
|
350
|
+
// `spec.webSearchProxy` is deliberately NOT forwarded. It states whether OUR PROXY serves web
|
|
351
|
+
// research for this run's account, which is what Pi's tools ride and what they would fail
|
|
352
|
+
// without; neither subscription CLI touches that proxy. Claude Code's `WebSearch`/`WebFetch`
|
|
353
|
+
// are served by the vendor the leased subscription already pays and are declared
|
|
354
|
+
// unconditionally (see `CLAUDE_TOOL_SET`), and Codex's surface is per-tool config rather than
|
|
355
|
+
// a list. Passing the proxy's availability here would withhold working tools on the strength
|
|
356
|
+
// of an unrelated deployment's wiring.
|
|
357
|
+
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
358
|
+
signal: opts.signal,
|
|
359
|
+
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
360
|
+
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
361
|
+
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
362
|
+
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
363
|
+
// ignores it for now (its stream isn't wired to the guard).
|
|
364
|
+
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
365
|
+
expectsEdits: spec.expectsEdits ?? true,
|
|
366
|
+
// What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
|
|
367
|
+
workspaceProbe,
|
|
368
|
+
onActivity: opts.onActivity,
|
|
369
|
+
onProgress: opts.onProgress,
|
|
370
|
+
// The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
|
|
371
|
+
// and a proxied one produce the same evidence rather than one of them producing none.
|
|
372
|
+
onSpan: opts.onSpan,
|
|
373
|
+
// The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
|
|
374
|
+
// Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
|
|
375
|
+
// each can beat the window it opens.
|
|
376
|
+
beginToolWindow: opts.beginToolWindow,
|
|
377
|
+
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
378
|
+
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
379
|
+
// across subagents, so this is the only path that can produce it.
|
|
380
|
+
onSliceReviews: opts.onSliceReviews,
|
|
381
|
+
// What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
|
|
382
|
+
// harnesses even though only claude-code's stream carries the report today: the hook is a
|
|
383
|
+
// pass-through, and a codex run that never calls it leaves the backend's record honestly
|
|
384
|
+
// absent rather than claiming every server it wired failed to start.
|
|
385
|
+
onToolServers: opts.onToolServers,
|
|
386
|
+
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
387
|
+
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
388
|
+
// proxy as they happen), so this is the only path that needs the hook.
|
|
389
|
+
onCallMetric: opts.onCallMetric,
|
|
390
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
391
|
+
})
|
|
392
|
+
return withEffortReport(spec.dir, subOutcome)
|
|
393
|
+
}
|
|
394
|
+
|
|
311
395
|
/**
|
|
312
396
|
* Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
|
|
313
397
|
* then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
|
|
@@ -362,67 +446,15 @@ export async function runAgentInWorkspace(
|
|
|
362
446
|
// the half that matters there anyway.
|
|
363
447
|
const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal)
|
|
364
448
|
|
|
365
|
-
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// push, watchdogs) is unchanged.
|
|
449
|
+
// Subscription harnesses (Claude Code / Codex) authenticate with the leased token and talk
|
|
450
|
+
// direct to the vendor: no proxy config, no AGENTS.md. The system prompt is passed straight to
|
|
451
|
+
// the CLI; everything around this (clone, push, watchdogs) is unchanged.
|
|
369
452
|
if (spec.harness === 'claude-code' || spec.harness === 'codex') {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
throw new Error(`The ${spec.harness} harness requires a subscription token`)
|
|
374
|
-
}
|
|
375
|
-
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
376
|
-
cwd: spec.dir,
|
|
377
|
-
model: spec.model,
|
|
378
|
-
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
|
|
379
|
-
userPrompt: spec.userPrompt,
|
|
380
|
-
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
381
|
-
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
382
|
-
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
383
|
-
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
384
|
-
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
385
|
-
// Codex's own image tool. Passed for both subscription harnesses because the option lives on
|
|
386
|
-
// the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
|
|
387
|
-
// (unlike an MCP server) there is nothing to report as unservable — the backend never
|
|
388
|
-
// resolves a codex-served generator onto a claude-code step, because admission refuses it.
|
|
389
|
-
...(spec.generateImages ? { generateImages: true } : {}),
|
|
390
|
-
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
391
|
-
signal: opts.signal,
|
|
392
|
-
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
393
|
-
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
394
|
-
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
395
|
-
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
396
|
-
// ignores it for now (its stream isn't wired to the guard).
|
|
397
|
-
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
398
|
-
expectsEdits: spec.expectsEdits ?? true,
|
|
399
|
-
// What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
|
|
453
|
+
return await runSubscriptionInWorkspace(spec.harness, spec, opts, {
|
|
454
|
+
contextFiles,
|
|
455
|
+
imageGuidance,
|
|
400
456
|
workspaceProbe,
|
|
401
|
-
onActivity: opts.onActivity,
|
|
402
|
-
onProgress: opts.onProgress,
|
|
403
|
-
// The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
|
|
404
|
-
// and a proxied one produce the same evidence rather than one of them producing none.
|
|
405
|
-
onSpan: opts.onSpan,
|
|
406
|
-
// The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
|
|
407
|
-
// Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
|
|
408
|
-
// each can beat the window it opens.
|
|
409
|
-
beginToolWindow: opts.beginToolWindow,
|
|
410
|
-
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
411
|
-
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
412
|
-
// across subagents, so this is the only path that can produce it.
|
|
413
|
-
onSliceReviews: opts.onSliceReviews,
|
|
414
|
-
// What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
|
|
415
|
-
// harnesses even though only claude-code's stream carries the report today: the hook is a
|
|
416
|
-
// pass-through, and a codex run that never calls it leaves the backend's record honestly
|
|
417
|
-
// absent rather than claiming every server it wired failed to start.
|
|
418
|
-
onToolServers: opts.onToolServers,
|
|
419
|
-
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
420
|
-
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
421
|
-
// proxy as they happen), so this is the only path that needs the hook.
|
|
422
|
-
onCallMetric: opts.onCallMetric,
|
|
423
|
-
...(opts.log ? { log: opts.log } : {}),
|
|
424
457
|
})
|
|
425
|
-
return withEffortReport(spec.dir, subOutcome)
|
|
426
458
|
}
|
|
427
459
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
428
460
|
throw new Error('The Pi harness requires proxyBaseUrl and sessionToken')
|