@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.
@@ -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
- * A repo-sourced Claude Skill to install natively before launch (repo-sourced Claude Skills,
80
- * slice 2). The claude-code runner writes it to `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md`
81
- * (+ resource files) so the CLI loads it — but ONLY when it owns an isolated config home, i.e.
82
- * NOT under `ambientAuth`. The codex runner ignores it outright. Every case that skips the
83
- * native install reads the checkout's `.cat-context/skill/`, materialised by the caller.
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
- skill?: {
86
- name: string
87
- description: string
88
- instructions: string
89
- resources: { relPath: string; content: string }[]
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
- // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
522
- // `skills/<name>/` so the CLI discovers and can invoke it. ONLY into the isolated per-run config
523
- // home — never the developer's own `~/.claude` (ambient/native mode), where it would persist in
524
- // their personal setup after the run and two concurrent jobs carrying same-named skills from
525
- // different repos would clobber each other. An ambient run reads the skill from the checkout
526
- // instead (`.cat-context/skill/`, materialised by the caller). Best-effort: a write failure must
527
- // not wedge the run — the prompt still names the skill.
528
- if (opts.skill && configHome) {
529
- await writeNativeSkill(join(configHome, 'skills'), opts.skill).catch(() => {})
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
- await writeFile(join(codexHome, 'config.toml'), 'cli_auth_credentials_store = "file"\n', 'utf8')
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, opendir, rm } from 'node:fs/promises'
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
- // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
958
- ...(job.skill ? { skill: job.skill } : {}),
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
+ }