@cat-factory/executor-harness 1.66.0 → 1.68.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 +33 -0
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +72 -11
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +9 -139
- package/dist/bootstrap-mode.js +141 -0
- package/dist/coding-agent.js +85 -67
- package/dist/job.js +6 -75
- package/dist/pi-workspace.js +15 -14
- package/dist/pi.js +24 -16
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +97 -26
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +8 -165
- package/src/bootstrap-mode.ts +174 -0
- package/src/coding-agent.ts +102 -71
- package/src/job.ts +37 -93
- package/src/pi-workspace.ts +30 -20
- package/src/pi.ts +24 -16
package/src/agent-runner.ts
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
type PiRunStats,
|
|
15
15
|
type TodoProgress,
|
|
16
16
|
} from './pi.js'
|
|
17
|
+
import {
|
|
18
|
+
claudeAllowedToolPatterns,
|
|
19
|
+
codexMcpConfigToml,
|
|
20
|
+
mcpServerSecretValues,
|
|
21
|
+
writeClaudeMcpConfig,
|
|
22
|
+
type McpServerSpec,
|
|
23
|
+
type SkillSpec,
|
|
24
|
+
} from './agent-capabilities.js'
|
|
17
25
|
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
18
26
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
19
|
-
import { redact, secretsToRedact } from './redact.js'
|
|
27
|
+
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
20
28
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
21
29
|
import {
|
|
22
30
|
createTaskPlanTracker,
|
|
@@ -76,18 +84,19 @@ export interface SubscriptionRunOptions {
|
|
|
76
84
|
*/
|
|
77
85
|
ambientAuth?: boolean
|
|
78
86
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
87
|
+
* The skills to install natively before launch. The claude-code runner writes each to
|
|
88
|
+
* `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resource files) so the CLI loads them — but ONLY
|
|
89
|
+
* when it owns an isolated config home, i.e. NOT under `ambientAuth`. The codex runner ignores
|
|
90
|
+
* them outright. Every case that skips the native install reads the checkout's
|
|
91
|
+
* `.cat-context/skill/<name>/`, materialised by the caller.
|
|
84
92
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
skills?: SkillSpec[]
|
|
94
|
+
/**
|
|
95
|
+
* Tool servers (MCP) to wire into the CLI for this run. Written to a PER-RUN config the CLI is
|
|
96
|
+
* pointed at — never a HOME-global one, which a second concurrent job would clobber and which
|
|
97
|
+
* carries this job's credentials. Absent ⇒ the CLI's built-in tools only.
|
|
98
|
+
*/
|
|
99
|
+
mcpServers?: McpServerSpec[]
|
|
91
100
|
/**
|
|
92
101
|
* Extra environment for the CLI child, scoped to this job (the tester's secrets, a
|
|
93
102
|
* private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
|
|
@@ -320,10 +329,7 @@ export function carryClaudeSystemPrompt(
|
|
|
320
329
|
* would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
|
|
321
330
|
* valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
|
|
322
331
|
*/
|
|
323
|
-
async function writeNativeSkill(
|
|
324
|
-
skillsRoot: string,
|
|
325
|
-
skill: NonNullable<SubscriptionRunOptions['skill']>,
|
|
326
|
-
): Promise<void> {
|
|
332
|
+
async function writeNativeSkill(skillsRoot: string, skill: SkillSpec): Promise<void> {
|
|
327
333
|
const dir = join(skillsRoot, skill.name)
|
|
328
334
|
await mkdir(dir, { recursive: true })
|
|
329
335
|
const name = JSON.stringify(skill.name)
|
|
@@ -337,6 +343,49 @@ async function writeNativeSkill(
|
|
|
337
343
|
}
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
|
|
348
|
+
* return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
|
|
349
|
+
*
|
|
350
|
+
* Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
|
|
351
|
+
* ambient run on a developer's own machine can never silently hand the agent their personal ones.
|
|
352
|
+
* And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
|
|
353
|
+
* whole-session, not MCP-scoped, so `claudeAllowedToolPatterns` re-grants the CLI's built-in
|
|
354
|
+
* file/bash tools in the same list; see it for why that holds whichever way the run's permission
|
|
355
|
+
* mode treats an allow-list.
|
|
356
|
+
*
|
|
357
|
+
* The config carries this job's resolved credentials, so it goes in the isolated config home when
|
|
358
|
+
* we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
|
|
359
|
+
* commit) and never a shared HOME path (a concurrent job would clobber it).
|
|
360
|
+
*/
|
|
361
|
+
async function setUpClaudeMcp(
|
|
362
|
+
servers: McpServerSpec[] | undefined,
|
|
363
|
+
configHome: string | undefined,
|
|
364
|
+
): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
|
|
365
|
+
const noop = { args: [], cleanup: async () => {} }
|
|
366
|
+
if (!servers?.length) return noop
|
|
367
|
+
// Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
|
|
368
|
+
// that tail is carried onto the step's diagnostics.
|
|
369
|
+
registerKnownSecrets(mcpServerSecretValues(servers))
|
|
370
|
+
const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')))
|
|
371
|
+
const owned = home === configHome ? undefined : home
|
|
372
|
+
const cleanup = async (): Promise<void> => {
|
|
373
|
+
if (owned) await rm(owned, { recursive: true, force: true }).catch(() => {})
|
|
374
|
+
}
|
|
375
|
+
const configPath = await writeClaudeMcpConfig(home, servers)
|
|
376
|
+
if (!configPath) return { args: [], cleanup }
|
|
377
|
+
const allowedTools = claudeAllowedToolPatterns(servers)
|
|
378
|
+
return {
|
|
379
|
+
args: [
|
|
380
|
+
'--mcp-config',
|
|
381
|
+
configPath,
|
|
382
|
+
'--strict-mcp-config',
|
|
383
|
+
...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
|
|
384
|
+
],
|
|
385
|
+
cleanup,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
340
389
|
export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
341
390
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
342
391
|
let summary = ''
|
|
@@ -518,17 +567,23 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
518
567
|
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
519
568
|
}
|
|
520
569
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
if (
|
|
529
|
-
|
|
570
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
571
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
572
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
573
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
574
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
575
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
576
|
+
// still names the skills.
|
|
577
|
+
if (configHome) {
|
|
578
|
+
for (const skill of opts.skills ?? []) {
|
|
579
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
580
|
+
}
|
|
530
581
|
}
|
|
531
582
|
|
|
583
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
584
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
585
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
|
|
586
|
+
|
|
532
587
|
const env = buildClaudeEnv(opts, configHome)
|
|
533
588
|
|
|
534
589
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
@@ -572,6 +627,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
572
627
|
'bypassPermissions',
|
|
573
628
|
'--model',
|
|
574
629
|
opts.model,
|
|
630
|
+
...mcp.args,
|
|
575
631
|
...appendArgs,
|
|
576
632
|
],
|
|
577
633
|
},
|
|
@@ -613,6 +669,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
613
669
|
throw err
|
|
614
670
|
} finally {
|
|
615
671
|
await subagents?.stop()
|
|
672
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
673
|
+
await mcp.cleanup()
|
|
616
674
|
if (configHome) {
|
|
617
675
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
618
676
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -779,7 +837,20 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
779
837
|
const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
|
|
780
838
|
if (codexHome) {
|
|
781
839
|
await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
|
|
782
|
-
|
|
840
|
+
// Tool servers (MCP) ride the SAME per-run config.toml, so they are scoped to this job and
|
|
841
|
+
// torn down with the home. Under AMBIENT auth there is no per-run home — and writing servers
|
|
842
|
+
// into the developer's own `~/.codex/config.toml` would outlive the run and race a concurrent
|
|
843
|
+
// job — so an ambient codex run gets no MCP servers; the backend states them as unavailable
|
|
844
|
+
// the same way it does for a harness with no MCP client at all.
|
|
845
|
+
// Registered before the CLI starts, for the same reason the claude path does it: a server that
|
|
846
|
+
// fails to launch puts its own command line into the stderr tail we keep.
|
|
847
|
+
if (opts.mcpServers?.length) registerKnownSecrets(mcpServerSecretValues(opts.mcpServers))
|
|
848
|
+
const mcpToml = opts.mcpServers?.length ? codexMcpConfigToml(opts.mcpServers) : ''
|
|
849
|
+
await writeFile(
|
|
850
|
+
join(codexHome, 'config.toml'),
|
|
851
|
+
`cli_auth_credentials_store = "file"\n${mcpToml ? `\n${mcpToml}` : ''}`,
|
|
852
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
853
|
+
)
|
|
783
854
|
}
|
|
784
855
|
|
|
785
856
|
// Codex has no system-prompt flag, so fold the composed role + best-practice
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentJob, AgentResult, McpServerSpec, SkillSpec } from './job.js'
|
|
2
|
+
import type { EffortReport } from './effort.js'
|
|
3
|
+
|
|
4
|
+
// Small helpers shared by every agent MODE (explore / coding / bootstrap / preview). They live
|
|
5
|
+
// apart from `agent.ts` so the bootstrap mode — a whole flow of its own — could move to its own
|
|
6
|
+
// module without either file importing the other.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
10
|
+
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
11
|
+
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function mergeEffort(
|
|
14
|
+
result: AgentResult,
|
|
15
|
+
effortReport: EffortReport | undefined,
|
|
16
|
+
): AgentResult {
|
|
17
|
+
return effortReport ? { ...result, effortReport } : result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The agent-capability fields (skills + tool servers) every agent-running flow forwards to
|
|
22
|
+
* {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow cannot silently
|
|
23
|
+
* be the one that drops a kind's declared playbook or tool server — the failure mode is invisible
|
|
24
|
+
* (the agent simply works without it) and would only show up as degraded output.
|
|
25
|
+
*/
|
|
26
|
+
export function agentCapabilities(job: AgentJob): {
|
|
27
|
+
skills?: SkillSpec[]
|
|
28
|
+
mcpServers?: McpServerSpec[]
|
|
29
|
+
} {
|
|
30
|
+
return {
|
|
31
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
32
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
import { mkdir, mkdtemp,
|
|
3
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
4
4
|
import { execFile } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import type {
|
|
@@ -20,17 +20,14 @@ import {
|
|
|
20
20
|
conflictDiff,
|
|
21
21
|
fetchPullRequestHead,
|
|
22
22
|
fetchReferenceBranches,
|
|
23
|
-
hasAgentChanges,
|
|
24
23
|
headCommit,
|
|
25
24
|
mergeBranch,
|
|
26
25
|
prepareExistingCheckout,
|
|
27
26
|
pushBranch,
|
|
28
|
-
reinitAndPush,
|
|
29
27
|
unmergedPaths,
|
|
30
28
|
} from './git.js'
|
|
31
29
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js'
|
|
32
30
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
33
|
-
import type { EffortReport } from './effort.js'
|
|
34
31
|
import { applyPrDescription } from './pr-description.js'
|
|
35
32
|
import {
|
|
36
33
|
makeDirClaimer,
|
|
@@ -39,6 +36,8 @@ import {
|
|
|
39
36
|
runMultiRepoCoding,
|
|
40
37
|
} from './coding-agent.js'
|
|
41
38
|
import { validationFailureMessage } from './validation-checks.js'
|
|
39
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
40
|
+
import { runBootstrap } from './bootstrap-mode.js'
|
|
42
41
|
import {
|
|
43
42
|
acquireRepoCheckout,
|
|
44
43
|
agentNeverActed,
|
|
@@ -313,15 +312,6 @@ async function cloneServiceCheckout(
|
|
|
313
312
|
return deriveWorkDir(dir, job.repo.serviceDirectory)
|
|
314
313
|
}
|
|
315
314
|
|
|
316
|
-
/**
|
|
317
|
-
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
318
|
-
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
319
|
-
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
320
|
-
*/
|
|
321
|
-
function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined): AgentResult {
|
|
322
|
-
return effortReport ? { ...result, effortReport } : result
|
|
323
|
-
}
|
|
324
|
-
|
|
325
315
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
326
316
|
export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
|
|
327
317
|
// An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
|
|
@@ -608,6 +598,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
|
|
|
608
598
|
webSearchProxy: job.webSearch,
|
|
609
599
|
contextFiles: job.contextFiles,
|
|
610
600
|
guardLimits: job.guardLimits,
|
|
601
|
+
...agentCapabilities(job),
|
|
611
602
|
},
|
|
612
603
|
agentOpts,
|
|
613
604
|
)
|
|
@@ -836,6 +827,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
836
827
|
webSearchProxy: job.webSearch,
|
|
837
828
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
838
829
|
guardLimits: job.guardLimits,
|
|
830
|
+
...agentCapabilities(job),
|
|
839
831
|
multiRepo: true,
|
|
840
832
|
},
|
|
841
833
|
opts,
|
|
@@ -954,8 +946,8 @@ function buildSingleRepoCodingSpec(
|
|
|
954
946
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
955
947
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
956
948
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
957
|
-
//
|
|
958
|
-
...(job
|
|
949
|
+
// Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
|
|
950
|
+
...agentCapabilities(job),
|
|
959
951
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
960
952
|
...(job.validation
|
|
961
953
|
? {
|
|
@@ -1228,6 +1220,7 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
|
|
|
1228
1220
|
sessionToken: job.sessionToken,
|
|
1229
1221
|
contextFiles: job.contextFiles,
|
|
1230
1222
|
guardLimits: job.guardLimits,
|
|
1223
|
+
...agentCapabilities(job),
|
|
1231
1224
|
},
|
|
1232
1225
|
opts,
|
|
1233
1226
|
)
|
|
@@ -1328,156 +1321,6 @@ function unresolvedReason(
|
|
|
1328
1321
|
)
|
|
1329
1322
|
}
|
|
1330
1323
|
|
|
1331
|
-
/**
|
|
1332
|
-
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
1333
|
-
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
1334
|
-
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
1335
|
-
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
1336
|
-
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
1337
|
-
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
1338
|
-
*/
|
|
1339
|
-
async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
1340
|
-
const { signal } = opts
|
|
1341
|
-
const boot = job.bootstrap!
|
|
1342
|
-
const fromScratch = boot.fromScratch === true
|
|
1343
|
-
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
|
|
1344
|
-
return withWorkspace('boot', async (dir) => {
|
|
1345
|
-
if (!fromScratch) {
|
|
1346
|
-
opts.onPhase?.('clone')
|
|
1347
|
-
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
1348
|
-
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
1349
|
-
})
|
|
1350
|
-
await cloneRepo({
|
|
1351
|
-
repo: { ...job.repo, baseBranch: job.branch },
|
|
1352
|
-
ghToken: job.ghToken,
|
|
1353
|
-
dir,
|
|
1354
|
-
signal,
|
|
1355
|
-
})
|
|
1356
|
-
} else {
|
|
1357
|
-
logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
opts.onPhase?.('agent')
|
|
1361
|
-
logger.info('agent(bootstrap): running agent')
|
|
1362
|
-
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1363
|
-
await runAgentInWorkspace(
|
|
1364
|
-
{
|
|
1365
|
-
dir,
|
|
1366
|
-
systemPrompt: job.systemPrompt,
|
|
1367
|
-
userPrompt: job.userPrompt,
|
|
1368
|
-
model: job.model,
|
|
1369
|
-
harness: job.harness,
|
|
1370
|
-
subscriptionToken: job.subscriptionToken,
|
|
1371
|
-
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1372
|
-
ambientAuth: job.ambientAuth,
|
|
1373
|
-
proxyBaseUrl: job.proxyBaseUrl,
|
|
1374
|
-
sessionToken: job.sessionToken,
|
|
1375
|
-
guardLimits: job.guardLimits,
|
|
1376
|
-
},
|
|
1377
|
-
opts,
|
|
1378
|
-
)
|
|
1379
|
-
|
|
1380
|
-
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1381
|
-
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
1382
|
-
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
1383
|
-
// agent did) instead of pushing nothing.
|
|
1384
|
-
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1385
|
-
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
1386
|
-
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
1387
|
-
return mergeEffort(
|
|
1388
|
-
{
|
|
1389
|
-
summary,
|
|
1390
|
-
stats,
|
|
1391
|
-
error,
|
|
1392
|
-
failureCause: 'agent',
|
|
1393
|
-
...(usage ? { usage } : {}),
|
|
1394
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1395
|
-
},
|
|
1396
|
-
effortReport,
|
|
1397
|
-
)
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
opts.onPhase?.('push')
|
|
1401
|
-
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
|
|
1402
|
-
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
1403
|
-
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
1404
|
-
await reinitAndPush({
|
|
1405
|
-
dir,
|
|
1406
|
-
target: boot.target,
|
|
1407
|
-
ghToken: job.ghToken,
|
|
1408
|
-
message: fromScratch
|
|
1409
|
-
? 'Bootstrap new repository'
|
|
1410
|
-
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1411
|
-
})
|
|
1412
|
-
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
1413
|
-
return mergeEffort(
|
|
1414
|
-
{
|
|
1415
|
-
defaultBranch: boot.target.defaultBranch,
|
|
1416
|
-
summary,
|
|
1417
|
-
stats,
|
|
1418
|
-
...(usage ? { usage } : {}),
|
|
1419
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1420
|
-
},
|
|
1421
|
-
effortReport,
|
|
1422
|
-
)
|
|
1423
|
-
})
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/**
|
|
1427
|
-
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
1428
|
-
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
1429
|
-
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
1430
|
-
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
1431
|
-
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
1432
|
-
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
1433
|
-
*/
|
|
1434
|
-
export async function producedRepoContent(
|
|
1435
|
-
dir: string,
|
|
1436
|
-
hasReference: boolean,
|
|
1437
|
-
signal?: AbortSignal,
|
|
1438
|
-
): Promise<boolean> {
|
|
1439
|
-
if (hasReference) return hasAgentChanges(dir, signal)
|
|
1440
|
-
return containsAnyFile(dir)
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
1445
|
-
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
1446
|
-
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
1447
|
-
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
1448
|
-
* entry before the check).
|
|
1449
|
-
*/
|
|
1450
|
-
async function containsAnyFile(dir: string): Promise<boolean> {
|
|
1451
|
-
const handle = await opendir(dir)
|
|
1452
|
-
try {
|
|
1453
|
-
for await (const entry of handle) {
|
|
1454
|
-
if (entry.isFile()) return true
|
|
1455
|
-
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
|
|
1456
|
-
}
|
|
1457
|
-
} catch {
|
|
1458
|
-
// A directory that vanished mid-walk has nothing to contribute.
|
|
1459
|
-
}
|
|
1460
|
-
return false
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
1464
|
-
function bootstrapNoOpReason(
|
|
1465
|
-
hasReference: boolean,
|
|
1466
|
-
stats: PiRunStats,
|
|
1467
|
-
summary: string,
|
|
1468
|
-
stderrTail: string | undefined,
|
|
1469
|
-
): string {
|
|
1470
|
-
const what = hasReference
|
|
1471
|
-
? 'made no changes to the reference architecture'
|
|
1472
|
-
: 'scaffolded no files'
|
|
1473
|
-
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
|
|
1474
|
-
return (
|
|
1475
|
-
`the bootstrapper agent ${what} ` +
|
|
1476
|
-
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
1477
|
-
agentOutputTail(stderrTail, summary)
|
|
1478
|
-
)
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1324
|
/** Human-readable reason a read-only run produced no usable output. */
|
|
1482
1325
|
function noOutputReason(stats: PiRunStats, stderrTail: string | undefined): string {
|
|
1483
1326
|
const cause = agentNeverActed(stats)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { opendir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { AgentJob, AgentResult } from './job.js'
|
|
4
|
+
import type { PiRunStats } from './pi.js'
|
|
5
|
+
import type { RunOptions } from './runner.js'
|
|
6
|
+
import {
|
|
7
|
+
NEVER_ACTED_CAUSE,
|
|
8
|
+
agentNeverActed,
|
|
9
|
+
agentOutputTail,
|
|
10
|
+
runAgentInWorkspace,
|
|
11
|
+
withWorkspace,
|
|
12
|
+
} from './pi-workspace.js'
|
|
13
|
+
import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js'
|
|
14
|
+
import { log } from './logger.js'
|
|
15
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
|
|
19
|
+
// pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
|
|
20
|
+
// cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
|
|
21
|
+
// and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
|
|
22
|
+
// helpers in `agent-shared.ts` with the coding/explore flows.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
27
|
+
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
28
|
+
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
29
|
+
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
30
|
+
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
31
|
+
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
32
|
+
*/
|
|
33
|
+
export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
34
|
+
const { signal } = opts
|
|
35
|
+
const boot = job.bootstrap!
|
|
36
|
+
const fromScratch = boot.fromScratch === true
|
|
37
|
+
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
|
|
38
|
+
return withWorkspace('boot', async (dir) => {
|
|
39
|
+
if (!fromScratch) {
|
|
40
|
+
opts.onPhase?.('clone')
|
|
41
|
+
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
42
|
+
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
43
|
+
})
|
|
44
|
+
await cloneRepo({
|
|
45
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
46
|
+
ghToken: job.ghToken,
|
|
47
|
+
dir,
|
|
48
|
+
signal,
|
|
49
|
+
})
|
|
50
|
+
} else {
|
|
51
|
+
logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
opts.onPhase?.('agent')
|
|
55
|
+
logger.info('agent(bootstrap): running agent')
|
|
56
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
57
|
+
await runAgentInWorkspace(
|
|
58
|
+
{
|
|
59
|
+
dir,
|
|
60
|
+
systemPrompt: job.systemPrompt,
|
|
61
|
+
userPrompt: job.userPrompt,
|
|
62
|
+
model: job.model,
|
|
63
|
+
harness: job.harness,
|
|
64
|
+
subscriptionToken: job.subscriptionToken,
|
|
65
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
66
|
+
ambientAuth: job.ambientAuth,
|
|
67
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
68
|
+
sessionToken: job.sessionToken,
|
|
69
|
+
guardLimits: job.guardLimits,
|
|
70
|
+
...agentCapabilities(job),
|
|
71
|
+
},
|
|
72
|
+
opts,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
76
|
+
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
77
|
+
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
78
|
+
// agent did) instead of pushing nothing.
|
|
79
|
+
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
80
|
+
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
81
|
+
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
82
|
+
return mergeEffort(
|
|
83
|
+
{
|
|
84
|
+
summary,
|
|
85
|
+
stats,
|
|
86
|
+
error,
|
|
87
|
+
failureCause: 'agent',
|
|
88
|
+
...(usage ? { usage } : {}),
|
|
89
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
90
|
+
},
|
|
91
|
+
effortReport,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
opts.onPhase?.('push')
|
|
96
|
+
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
|
|
97
|
+
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
98
|
+
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
99
|
+
await reinitAndPush({
|
|
100
|
+
dir,
|
|
101
|
+
target: boot.target,
|
|
102
|
+
ghToken: job.ghToken,
|
|
103
|
+
message: fromScratch
|
|
104
|
+
? 'Bootstrap new repository'
|
|
105
|
+
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
106
|
+
})
|
|
107
|
+
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
108
|
+
return mergeEffort(
|
|
109
|
+
{
|
|
110
|
+
defaultBranch: boot.target.defaultBranch,
|
|
111
|
+
summary,
|
|
112
|
+
stats,
|
|
113
|
+
...(usage ? { usage } : {}),
|
|
114
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
115
|
+
},
|
|
116
|
+
effortReport,
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
123
|
+
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
124
|
+
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
125
|
+
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
126
|
+
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
127
|
+
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
128
|
+
*/
|
|
129
|
+
export async function producedRepoContent(
|
|
130
|
+
dir: string,
|
|
131
|
+
hasReference: boolean,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
if (hasReference) return hasAgentChanges(dir, signal)
|
|
135
|
+
return containsAnyFile(dir)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
140
|
+
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
141
|
+
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
142
|
+
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
143
|
+
* entry before the check).
|
|
144
|
+
*/
|
|
145
|
+
async function containsAnyFile(dir: string): Promise<boolean> {
|
|
146
|
+
const handle = await opendir(dir)
|
|
147
|
+
try {
|
|
148
|
+
for await (const entry of handle) {
|
|
149
|
+
if (entry.isFile()) return true
|
|
150
|
+
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// A directory that vanished mid-walk has nothing to contribute.
|
|
154
|
+
}
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
159
|
+
function bootstrapNoOpReason(
|
|
160
|
+
hasReference: boolean,
|
|
161
|
+
stats: PiRunStats,
|
|
162
|
+
summary: string,
|
|
163
|
+
stderrTail: string | undefined,
|
|
164
|
+
): string {
|
|
165
|
+
const what = hasReference
|
|
166
|
+
? 'made no changes to the reference architecture'
|
|
167
|
+
: 'scaffolded no files'
|
|
168
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
|
|
169
|
+
return (
|
|
170
|
+
`the bootstrapper agent ${what} ` +
|
|
171
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
172
|
+
agentOutputTail(stderrTail, summary)
|
|
173
|
+
)
|
|
174
|
+
}
|