@cat-factory/executor-harness 1.64.4 → 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
@@ -747,7 +805,11 @@ function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number
747
805
  export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
748
806
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
749
807
  let summary = ''
750
- let usage: { inputTokens: number; outputTokens: number } | undefined
808
+ // The running CUMULATIVE total, kept in its reported (inclusive) form plus the cached share
809
+ // it contains. `PiRunOutcome.usage` needs the inclusive figure — it is the key-rotation
810
+ // weight — while the fallback call metric below needs the split, so both are derived from
811
+ // this one value rather than one being reconstructed from the other.
812
+ let cumulative: CodexCumulativeUsage | undefined
751
813
 
752
814
  // Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
753
815
  // storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
@@ -775,7 +837,20 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
775
837
  const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
776
838
  if (codexHome) {
777
839
  await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
778
- 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
+ )
779
854
  }
780
855
 
781
856
  // Codex has no system-prompt flag, so fold the composed role + best-practice
@@ -812,7 +887,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
812
887
  const progress = codexPlanProgress(event)
813
888
  if (progress && opts.onProgress) opts.onProgress(progress)
814
889
  const turnUsage = codexUsage(event)
815
- if (turnUsage) usage = turnUsage
890
+ if (turnUsage) cumulative = turnUsage
816
891
  // A `token_count` event closes a model turn: pair its per-turn usage with the
817
892
  // assistant text seen since the previous turn as one telemetry call.
818
893
  const perTurn = codexLastTurnUsage(event)
@@ -826,7 +901,8 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
826
901
  responseText: redactBody(pendingText, secrets),
827
902
  reasoningText: '',
828
903
  inputTokens: perTurn.inputTokens,
829
- cachedInputTokens: perTurn.cachedInputTokens,
904
+ cacheReadTokens: perTurn.cacheReadTokens,
905
+ cacheWriteTokens: perTurn.cacheWriteTokens,
830
906
  outputTokens: perTurn.outputTokens,
831
907
  finishReason: null,
832
908
  },
@@ -862,7 +938,11 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
862
938
 
863
939
  // Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
864
940
  // single call from the cumulative total + final text so the run is still observable.
865
- if (calls.length === 0 && (usage || summary)) {
941
+ // The cumulative total is inclusive of its cached share exactly as a per-turn one is, so
942
+ // it is split the same way rather than being filed wholesale as fresh — which would report
943
+ // a cache-heavy run as if nothing had been cached, the one reading this telemetry exists
944
+ // to rule out.
945
+ if (calls.length === 0 && (cumulative || summary)) {
866
946
  publishCallMetric(
867
947
  calls,
868
948
  {
@@ -871,14 +951,23 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
871
951
  messageCount: messages.length,
872
952
  responseText: redactBody(summary, secrets),
873
953
  reasoningText: '',
874
- inputTokens: usage?.inputTokens ?? 0,
875
- cachedInputTokens: 0,
876
- outputTokens: usage?.outputTokens ?? 0,
954
+ inputTokens: Math.max(
955
+ 0,
956
+ (cumulative?.inputTokens ?? 0) - (cumulative?.cachedInputTokens ?? 0),
957
+ ),
958
+ cacheReadTokens: cumulative?.cachedInputTokens ?? 0,
959
+ // Codex reports no separate cache-WRITE class; 0 rather than guessed.
960
+ cacheWriteTokens: 0,
961
+ outputTokens: cumulative?.outputTokens ?? 0,
877
962
  finishReason: null,
878
963
  },
879
964
  opts.onCallMetric,
880
965
  )
881
966
  }
967
+ // The outcome's usage is the key-rotation WEIGHT, so it keeps the inclusive input count.
968
+ const usage = cumulative
969
+ ? { inputTokens: cumulative.inputTokens, outputTokens: cumulative.outputTokens }
970
+ : undefined
882
971
  return {
883
972
  summary,
884
973
  stats,
@@ -949,6 +1038,19 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
949
1038
  return toProgress(items)
950
1039
  }
951
1040
 
1041
+ /**
1042
+ * Codex's running cumulative usage, kept in the form the CLI reports it: `inputTokens` is the
1043
+ * TOTAL prompt count (OpenAI semantics) with `cachedInputTokens` a SUBSET already inside it,
1044
+ * never a bucket to add on top. The cached share is carried rather than discarded so a
1045
+ * consumer that needs the fresh figure can subtract it at the point of use, instead of the
1046
+ * only two readings of this number being "inclusive" and "lost".
1047
+ */
1048
+ interface CodexCumulativeUsage {
1049
+ inputTokens: number
1050
+ cachedInputTokens: number
1051
+ outputTokens: number
1052
+ }
1053
+
952
1054
  /**
953
1055
  * Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
954
1056
  * reports a running CUMULATIVE total on `token_count` events under
@@ -956,12 +1058,8 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
956
1058
  * other shapes put it on `usage` / `info.usage` directly. We read the cumulative
957
1059
  * total when present so the caller can simply overwrite (not sum) — summing
958
1060
  * cumulative totals across events would multiply-count. Checked most-likely first.
959
- * `input_tokens` is the TOTAL prompt count (OpenAI semantics: `cached_input_tokens`
960
- * is a subset already inside it), so it is NOT summed with the cached share.
961
1061
  */
962
- function codexUsage(
963
- event: Record<string, unknown>,
964
- ): { inputTokens: number; outputTokens: number } | undefined {
1062
+ function codexUsage(event: Record<string, unknown>): CodexCumulativeUsage | undefined {
965
1063
  const info = isObject(event.info) ? (event.info as Record<string, unknown>) : undefined
966
1064
  const raw =
967
1065
  (info && isObject(info.total_token_usage) ? info.total_token_usage : undefined) ??
@@ -972,20 +1070,28 @@ function codexUsage(
972
1070
  const input = numberOf(raw.input_tokens)
973
1071
  const output = numberOf(raw.output_tokens)
974
1072
  if (input === 0 && output === 0) return undefined
975
- return { inputTokens: input, outputTokens: output }
1073
+ return {
1074
+ inputTokens: input,
1075
+ cachedInputTokens: numberOf(raw.cached_input_tokens),
1076
+ outputTokens: output,
1077
+ }
976
1078
  }
977
1079
 
978
1080
  /**
979
1081
  * Per-TURN Codex token usage off a `token_count` event's `info.last_token_usage` (the
980
1082
  * delta for the turn just completed, as opposed to `codexUsage`'s cumulative total).
981
- * `input_tokens` is the total prompt count for the turn and already INCLUDES the cached
982
- * share (OpenAI semantics), so `cachedInputTokens` is surfaced as the subset it is
983
- * NOT added on top (adding it would double-count every cached token).
1083
+ *
1084
+ * OpenAI semantics: `input_tokens` is the turn's WHOLE prompt count and already INCLUDES
1085
+ * the cached share, so the fresh figure is the difference. Clamped at 0 because the two
1086
+ * counts come off the same event and a vendor inconsistency must not mint a negative token
1087
+ * count. Codex reports no separate cache-WRITE class, so that class is 0 here rather than
1088
+ * guessed.
984
1089
  */
985
1090
  function codexLastTurnUsage(event: Record<string, unknown>):
986
1091
  | {
987
1092
  inputTokens: number
988
- cachedInputTokens: number
1093
+ cacheReadTokens: number
1094
+ cacheWriteTokens: number
989
1095
  outputTokens: number
990
1096
  }
991
1097
  | undefined {
@@ -996,7 +1102,12 @@ function codexLastTurnUsage(event: Record<string, unknown>):
996
1102
  const cached = numberOf(raw.cached_input_tokens)
997
1103
  const output = numberOf(raw.output_tokens)
998
1104
  if (input === 0 && output === 0) return undefined
999
- return { inputTokens: input, cachedInputTokens: cached, outputTokens: output }
1105
+ return {
1106
+ inputTokens: Math.max(0, input - cached),
1107
+ cacheReadTokens: cached,
1108
+ cacheWriteTokens: 0,
1109
+ outputTokens: output,
1110
+ }
1000
1111
  }
1001
1112
 
1002
1113
  /** Dispatch to the configured subscription harness runner. */
@@ -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)