@cat-factory/executor-harness 1.66.0 → 1.70.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.
@@ -1,8 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { spawn } from 'node:child_process'
4
- import { killChildProcess, spawnDetached } from './process.js'
5
- import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
3
+ import { runCapturedCommand } from './captured-command.js'
6
4
  import type {
7
5
  AgentJob,
8
6
  AgentResult,
@@ -11,6 +9,7 @@ import type {
11
9
  ReferenceRepoSpec,
12
10
  RepoSpec,
13
11
  SkillSpec,
12
+ McpServerSpec,
14
13
  } from './job.js'
15
14
  import {
16
15
  branchAheadOfBase,
@@ -94,6 +93,8 @@ export interface CodingAgentSpec extends HarnessAuthFields {
94
93
  webToolsGuidance?: string
95
94
  /** Enable proxy-backed web search for this run (see {@link AgentRunSpec.webSearchProxy}). */
96
95
  webSearchProxy?: boolean
96
+ /** Backend serves the phase-tagged completions route (see {@link AgentRunSpec.proxyPhasePath}). */
97
+ proxyPhasePath?: boolean
97
98
  /** Per-knob progress-guard overrides (loosen-only), set per agent kind by the backend. */
98
99
  guardLimits?: Partial<ProgressGuardLimits>
99
100
  /**
@@ -140,12 +141,18 @@ export interface CodingAgentSpec extends HarnessAuthFields {
140
141
  */
141
142
  reproduction?: ReproductionSpec
142
143
  /**
143
- * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
144
- * into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
145
- * `CLAUDE_CONFIG_DIR` for a leased-credential claude-code run, `.cat-context/skill/` for everything
146
- * else (Pi, codex, and ambient claude-code, which has no isolated config dir). Absent ⇒ no skill.
144
+ * The skills to make available for this run a `skill` step's pick and/or the running kind's
145
+ * declared playbooks. Threaded into {@link runAgentInWorkspace}, which installs them
146
+ * harness-aware: natively under the ISOLATED `CLAUDE_CONFIG_DIR` for a leased-credential
147
+ * claude-code run, `.cat-context/skill/<name>/` for everything else (Pi, codex, and ambient
148
+ * claude-code, which has no isolated config dir). Absent ⇒ no skills.
147
149
  */
148
- skill?: SkillSpec
150
+ skills?: SkillSpec[]
151
+ /**
152
+ * Tool servers (MCP) to wire into the agent CLI for this run. Forwarded verbatim — the backend
153
+ * has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
154
+ */
155
+ mcpServers?: McpServerSpec[]
149
156
  }
150
157
 
151
158
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
@@ -179,6 +186,8 @@ export interface CodingAgentOutcome {
179
186
  exitCode: number
180
187
  validationOutputTail?: string
181
188
  iteration?: number
189
+ /** The work-branch HEAD the command was judged against (absent when it could not be read). */
190
+ headSha?: string
182
191
  }
183
192
  /**
184
193
  * The pre-PR validation loop's LAST attempt (present only when {@link CodingAgentSpec.validationChecks}
@@ -345,12 +354,14 @@ export async function runCodingAgent(
345
354
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
346
355
  ambientAuth: spec.ambientAuth,
347
356
  proxyBaseUrl: spec.proxyBaseUrl,
357
+ proxyPhasePath: spec.proxyPhasePath,
348
358
  sessionToken: spec.sessionToken,
349
359
  serviceDirectory,
350
360
  webToolsGuidance: spec.webToolsGuidance,
351
361
  webSearchProxy: spec.webSearchProxy,
352
362
  guardLimits: spec.guardLimits,
353
- ...(spec.skill ? { skill: spec.skill } : {}),
363
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
364
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
354
365
  },
355
366
  opts,
356
367
  )
@@ -718,7 +729,7 @@ async function finalizeCodingRun(args: {
718
729
  // Runs regardless of whether this pass pushed — a no-op iteration must still be able
719
730
  // to report that the criterion is (already) met. The harness runs it, never the model.
720
731
  if (spec.validation) {
721
- outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
732
+ outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts)
722
733
  }
723
734
  // Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
724
735
  // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
@@ -796,22 +807,57 @@ function mergeAgentPasses<T extends Awaited<ReturnType<typeof runAgentInWorkspac
796
807
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
797
808
  * Overridable via env for tests; defaults to 15 minutes.
798
809
  */
799
- function ralphValidationTimeoutMs(): number {
810
+ export function ralphValidationTimeoutMs(): number {
800
811
  const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
801
812
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
802
813
  }
803
814
 
815
+ /**
816
+ * How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
817
+ * The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
818
+ * — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
819
+ * events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
820
+ * watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
821
+ * validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
822
+ * a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
823
+ * settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
824
+ * always fed it; this one did not. Overridable via env for tests.
825
+ */
826
+ export function ralphHeartbeatMs(): number {
827
+ const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS)
828
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
829
+ }
830
+
831
+ /**
832
+ * Bound on the validation output tail that crosses the wire. Deliberately smaller than
833
+ * `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
834
+ * the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
835
+ * log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
836
+ */
837
+ export const RALPH_VALIDATION_TAIL_CHARS = 4_000
838
+
804
839
  /**
805
840
  * Ralph loop: run the programmatic completion command in the checkout and return its exit
806
- * code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
807
- * done signal (0 = the criterion is met) — computed here by the harness, never self-reported
808
- * by the model, which is the whole point of a programmatic exit condition. Runs
809
- * `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
810
- * command counts as a failure so the loop is never blocked), and an aborted run resolves to a
811
- * non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
812
- * boundary as the coding agent) there is no host/backend execution.
841
+ * code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
842
+ * The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
843
+ * here by the harness, never self-reported by the model, which is the whole point of a
844
+ * programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
845
+ * trust boundary as the coding agent) there is no host/backend execution.
846
+ *
847
+ * The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
848
+ * command, rather than the near-verbatim copy this used to be. That copy had drifted in two
849
+ * ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
850
+ * margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
851
+ * an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
852
+ * it published the full 16k capture where both siblings deliberately bound the wire tail.
853
+ *
854
+ * `headSha` is what lets the engine tell a loop that is iterating from one that is merely
855
+ * repeating: two consecutive failing iterations against an unchanged head means the agent
856
+ * committed nothing, and the loop is ended early instead of spending the rest of its budget.
857
+ * Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
813
858
  */
814
- async function runRalphValidation(
859
+ export async function runRalphValidation(
860
+ repoDir: string,
815
861
  cwd: string,
816
862
  validation: { command: string; iteration?: number },
817
863
  logger: Logger,
@@ -821,66 +867,50 @@ async function runRalphValidation(
821
867
  exitCode: number
822
868
  validationOutputTail?: string
823
869
  iteration?: number
870
+ headSha?: string
824
871
  }> {
825
- const timeoutMs = ralphValidationTimeoutMs()
826
872
  logger.info('coding-agent(ralph): running validation command', {
827
873
  iteration: validation.iteration,
828
874
  })
829
- return new Promise((resolve) => {
830
- let out = ''
831
- let settled = false
832
- const child = spawn('sh', ['-c', validation.command], {
875
+ // Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
876
+ const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs())
877
+ heartbeat.unref?.()
878
+ let captured
879
+ try {
880
+ captured = await runCapturedCommand({
833
881
  cwd,
834
- detached: spawnDetached,
835
- stdio: ['ignore', 'pipe', 'pipe'],
836
- // The job's own env (see `RunOptions.agentEnv`): a validation command typically installs
837
- // before it tests, and this is spawned by the HARNESS rather than the agent, so it does not
838
- // otherwise inherit the job's private-registry npmrc pointer on the native path.
839
- env: { ...process.env, ...opts.agentEnv },
882
+ command: validation.command,
883
+ timeoutMs: ralphValidationTimeoutMs(),
884
+ reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
885
+ logLabel: 'coding-agent(ralph): validation',
886
+ logFields: { iteration: validation.iteration },
887
+ logger,
888
+ opts,
840
889
  })
841
- // Keep only the tail; guard against unbounded buffering on a chatty command.
842
- const capture = (chunk: Buffer): void => {
843
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS)
844
- }
845
- child.stdout?.on('data', capture)
846
- child.stderr?.on('data', capture)
847
- const finish = (exitCode: number): void => {
848
- if (settled) return
849
- settled = true
850
- clearTimeout(timer)
851
- opts.signal?.removeEventListener('abort', onAbort)
852
- const trimmed = out.trim()
853
- const tail = trimmed ? redactSecrets(trimmed) : undefined
854
- logger.info('coding-agent(ralph): validation finished', {
855
- exitCode,
856
- iteration: validation.iteration,
857
- })
858
- resolve({
859
- validationPassed: exitCode === 0,
860
- exitCode,
861
- ...(tail ? { validationOutputTail: tail } : {}),
862
- ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
863
- })
864
- }
865
- const timer = setTimeout(() => {
866
- logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs })
867
- killChildProcess(child, undefined, logger)
868
- finish(124) // conventional timeout exit code (a non-zero fail)
869
- }, timeoutMs)
870
- timer.unref?.()
871
- const onAbort = (): void => {
872
- killChildProcess(child, undefined, logger)
873
- finish(130) // aborted (a non-zero fail)
874
- }
875
- opts.signal?.addEventListener('abort', onAbort, { once: true })
876
- child.on('error', (err) => {
877
- logger.warn('coding-agent(ralph): validation command failed to spawn', {
878
- error: err instanceof Error ? err.message : String(err),
879
- })
880
- finish(127) // spawn error / command not found (a non-zero fail)
890
+ } finally {
891
+ clearInterval(heartbeat)
892
+ }
893
+ // The commit the criterion was judged against. Read AFTER the command so a validation that
894
+ // itself commits (a formatter check that rewrites files, say) is attributed to what it left.
895
+ // Best-effort: an unreadable head only costs the engine's no-progress guard, never the
896
+ // verdict but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
897
+ const headSha = await headCommit(repoDir, opts.signal).catch((err: unknown) => {
898
+ logger.warn('coding-agent(ralph): could not read the work-branch head', {
899
+ error: err instanceof Error ? err.message : String(err),
881
900
  })
882
- child.on('close', (code) => finish(code ?? 1))
901
+ return ''
902
+ })
903
+ logger.info('coding-agent(ralph): validation finished', {
904
+ exitCode: captured.exitCode,
905
+ iteration: validation.iteration,
883
906
  })
907
+ return {
908
+ validationPassed: captured.passed,
909
+ exitCode: captured.exitCode,
910
+ ...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
911
+ ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
912
+ ...(headSha ? { headSha } : {}),
913
+ }
884
914
  }
885
915
 
886
916
  /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
@@ -1025,11 +1055,16 @@ export async function runMultiRepoCoding(
1025
1055
  subscriptionBaseUrl: job.subscriptionBaseUrl,
1026
1056
  ambientAuth: job.ambientAuth,
1027
1057
  proxyBaseUrl: job.proxyBaseUrl,
1058
+ proxyPhasePath: job.proxyPhasePath,
1028
1059
  sessionToken: job.sessionToken,
1029
1060
  webToolsGuidance: job.webToolsGuidance,
1030
1061
  webSearchProxy: job.webSearch,
1031
1062
  guardLimits: job.guardLimits,
1032
1063
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
1064
+ // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
1065
+ // are properties of the AGENT KIND, not of the checkout layout.
1066
+ ...(job.skills?.length ? { skills: job.skills } : {}),
1067
+ ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
1033
1068
  multiRepo: true,
1034
1069
  },
1035
1070
  opts,
package/src/job.ts CHANGED
@@ -12,6 +12,16 @@ import {
12
12
  type ReproductionReport,
13
13
  type ReproductionSpec,
14
14
  } from './reproduction-proof.js'
15
+ import {
16
+ parseMcpServerSpecs,
17
+ parseSkillSpecs,
18
+ type McpServerSpec,
19
+ type SkillResourceSpec,
20
+ type SkillSpec,
21
+ } from './agent-capabilities.js'
22
+
23
+ // Re-exported so the job body stays the one import site for a harness handler describing a job.
24
+ export type { McpServerSpec, SkillResourceSpec, SkillSpec }
15
25
 
16
26
  // The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
17
27
  // types with a hand-rolled validator so the image needs no schema dependency.
@@ -28,6 +38,20 @@ export interface HarnessAuthFields {
28
38
  harness?: HarnessKind
29
39
  /** Worker LLM proxy base URL, including /v1 (Pi harness only). */
30
40
  proxyBaseUrl?: string
41
+ /**
42
+ * The backend declaring that it serves the phase-tagged completions route
43
+ * (`${proxyBaseUrl}/phase/<phase>/chat/completions`), so this run may attribute each model
44
+ * call to the phase that spent it (`docs/initiatives/token-burn-instrumentation.md`). The
45
+ * same shape as {@link AgentJob.webSearch}: the backend states what IT serves, and the
46
+ * harness points Pi accordingly.
47
+ *
48
+ * Not a capability handshake — the harness never asks and never adapts to an answer. It
49
+ * exists because the harness image and the backend are only a matched set on the Cloudflare
50
+ * deployment: a runner pool pins its own image and `LOCAL_HARNESS_IMAGE` overrides the
51
+ * recommended pin, so an image ahead of its backend would otherwise 404 every model call.
52
+ * Absent ⇒ the plain path, and the run's calls are recorded as unattributed.
53
+ */
54
+ proxyPhasePath?: boolean
31
55
  /** Signed, model-locked proxy session token (Pi harness only). */
32
56
  sessionToken?: string
33
57
  /** Leased subscription credential (Claude Code OAuth token / Codex auth.json). */
@@ -212,6 +236,8 @@ function parseHarnessAuth(o: Record<string, unknown>): HarnessAuthFields {
212
236
  harness,
213
237
  proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
214
238
  sessionToken: str(o.sessionToken, 'sessionToken'),
239
+ // Opt-IN, so a backend that doesn't serve the phase route (or predates it) is the default.
240
+ ...(o.proxyPhasePath === true ? { proxyPhasePath: true } : {}),
215
241
  }
216
242
  }
217
243
 
@@ -632,26 +658,6 @@ export interface ContextFileSpec {
632
658
  content: string
633
659
  }
634
660
 
635
- /** One materialisable resource file of a skill (repo-sourced Claude Skills). */
636
- export interface SkillResourceSpec {
637
- /** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
638
- relPath: string
639
- content: string
640
- }
641
-
642
- /**
643
- * A repo-sourced Claude Skill to make available for a `skill` step. Materialised HARNESS-AWARE:
644
- * `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
645
- * natively, or `.cat-context/skill/<relPath>` for the Pi/codex checkout (their prompt carries the
646
- * instructions). A dedicated top-level body field (like `packageRegistries`), never a context file.
647
- */
648
- export interface SkillSpec {
649
- name: string
650
- description: string
651
- instructions: string
652
- resources: SkillResourceSpec[]
653
- }
654
-
655
661
  /** How an explore agent's reply is consumed. */
656
662
  export interface AgentOutputSpec {
657
663
  /** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
@@ -739,11 +745,20 @@ export interface AgentJob extends HarnessAuthFields {
739
745
  */
740
746
  packageRegistries?: PackageRegistrySpec[]
741
747
  /**
742
- * A repo-sourced Claude Skill to make available for a `skill` step (see {@link SkillSpec}).
743
- * Materialised harness-aware before the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/`
744
- * for claude-code, or `.cat-context/skill/<relPath>` for Pi/codex. Absent ⇒ no skill installed.
748
+ * The skills to make available for this run (see {@link SkillSpec}) — a `skill` step's picked
749
+ * skill and/or the playbooks the running agent kind declares. Materialised harness-aware before
750
+ * the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/` for claude-code, or
751
+ * `.cat-context/skill/<name>/<relPath>` for Pi/codex. Absent ⇒ no skills installed.
752
+ */
753
+ skills?: SkillSpec[]
754
+ /**
755
+ * Tool servers (MCP) to wire into the agent CLI for this run (see {@link McpServerSpec}). The
756
+ * backend has already dropped anything this harness cannot serve, so every entry here is
757
+ * expected to work. SECRET-BEARING (`env`/`headers` carry resolved credentials), so the config
758
+ * files written from it live outside the checkout and are never logged. Absent ⇒ the CLI's
759
+ * built-in tools only.
745
760
  */
746
- skill?: SkillSpec
761
+ mcpServers?: McpServerSpec[]
747
762
  /**
748
763
  * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
749
764
  * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
@@ -942,6 +957,12 @@ export interface AgentResult {
942
957
  exitCode: number
943
958
  validationOutputTail?: string
944
959
  iteration?: number
960
+ /**
961
+ * The work-branch HEAD the command was judged against. The engine compares it across
962
+ * consecutive failing iterations to end a loop that has stopped committing anything,
963
+ * instead of spending the rest of its budget re-learning that. Absent when unreadable.
964
+ */
965
+ headSha?: string
945
966
  }
946
967
  /**
947
968
  * Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
@@ -1029,71 +1050,6 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
1029
1050
  return files
1030
1051
  }
1031
1052
 
1032
- /**
1033
- * Sanitize a skill resource's relative path: keep the subdirectory structure (so
1034
- * `templates/report.md` materialises nested) but reject anything that could escape the skill
1035
- * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
1036
- * for an unsafe path (the resource is then dropped).
1037
- */
1038
- function sanitizeSkillRelPath(value: unknown): string | undefined {
1039
- if (typeof value !== 'string') return undefined
1040
- const segments = value.replace(/\\/g, '/').split('/')
1041
- const clean: string[] = []
1042
- for (const seg of segments) {
1043
- if (seg === '' || seg === '.') continue
1044
- if (seg === '..') return undefined
1045
- // Same character class as a context-file name, per segment.
1046
- const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
1047
- if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
1048
- clean.push(c)
1049
- }
1050
- return clean.length ? clean.join('/') : undefined
1051
- }
1052
-
1053
- /**
1054
- * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
1055
- * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
1056
- * default keeps the skill installable rather than dropping it — which, on the claude-code path,
1057
- * would leave the prompt pointing at a skill that was never installed (a blind run).
1058
- */
1059
- const FALLBACK_SKILL_NAME = 'skill'
1060
-
1061
- /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
1062
- function sanitizeSkillName(value: unknown): string | undefined {
1063
- if (typeof value !== 'string') return undefined
1064
- const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
1065
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
1066
- if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
1067
- return cleaned
1068
- }
1069
-
1070
- /** Validate the optional `skill` field, or undefined when absent/malformed. */
1071
- function parseSkillSpec(value: unknown): SkillSpec | undefined {
1072
- if (typeof value !== 'object' || value === null) return undefined
1073
- const o = value as Record<string, unknown>
1074
- const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
1075
- // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
1076
- // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
1077
- // directory, so fall back to a safe default rather than dropping the whole skill.
1078
- if (!instructions) return undefined
1079
- const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
1080
- const description = typeof o.description === 'string' ? o.description : ''
1081
- const resources: SkillResourceSpec[] = []
1082
- if (Array.isArray(o.resources)) {
1083
- const used = new Set<string>()
1084
- for (const entry of o.resources) {
1085
- if (typeof entry !== 'object' || entry === null) continue
1086
- const e = entry as Record<string, unknown>
1087
- const relPath = sanitizeSkillRelPath(e.relPath)
1088
- if (!relPath || used.has(relPath)) continue
1089
- if (typeof e.content !== 'string') continue
1090
- used.add(relPath)
1091
- resources.push({ relPath, content: e.content })
1092
- }
1093
- }
1094
- return { name, description, instructions, resources }
1095
- }
1096
-
1097
1053
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
1098
1054
  function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
1099
1055
  if (typeof value !== 'object' || value === null) return undefined
@@ -1335,7 +1291,8 @@ export function parseAgentJob(input: unknown): AgentJob {
1335
1291
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
1336
1292
  contextFiles: parseContextFiles(o.contextFiles),
1337
1293
  packageRegistries: parsePackageRegistries(o.packageRegistries),
1338
- skill: parseSkillSpec(o.skill),
1294
+ skills: parseSkillSpecs(o.skills),
1295
+ mcpServers: parseMcpServerSpecs(o.mcpServers),
1339
1296
  testSecrets: parseTestSecrets(o.testSecrets),
1340
1297
  guardLimits: parseGuardLimits(o.guardLimits),
1341
1298
  validation: parseValidationSpec(o.validation),
@@ -1374,7 +1331,8 @@ interface ParsedAgentJobParts {
1374
1331
  bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
1375
1332
  contextFiles: ReturnType<typeof parseContextFiles>
1376
1333
  packageRegistries: ReturnType<typeof parsePackageRegistries>
1377
- skill: ReturnType<typeof parseSkillSpec>
1334
+ skills: ReturnType<typeof parseSkillSpecs>
1335
+ mcpServers: ReturnType<typeof parseMcpServerSpecs>
1378
1336
  testSecrets: ReturnType<typeof parseTestSecrets>
1379
1337
  guardLimits: ReturnType<typeof parseGuardLimits>
1380
1338
  validation: ReturnType<typeof parseValidationSpec>
@@ -1428,7 +1386,8 @@ function assembleAgentJob(
1428
1386
  bootstrap,
1429
1387
  contextFiles,
1430
1388
  packageRegistries,
1431
- skill,
1389
+ skills,
1390
+ mcpServers,
1432
1391
  testSecrets,
1433
1392
  guardLimits,
1434
1393
  validation,
@@ -1452,7 +1411,8 @@ function assembleAgentJob(
1452
1411
  ...(output ? { output } : {}),
1453
1412
  ...(contextFiles.length ? { contextFiles } : {}),
1454
1413
  ...(packageRegistries.length ? { packageRegistries } : {}),
1455
- ...(skill ? { skill } : {}),
1414
+ ...(skills ? { skills } : {}),
1415
+ ...(mcpServers ? { mcpServers } : {}),
1456
1416
  ...(testSecrets.length ? { testSecrets } : {}),
1457
1417
  ...(infra ? { infra } : {}),
1458
1418
  ...(pr ? { pr } : {}),
@@ -1,7 +1,8 @@
1
1
  import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
- import type { RepoSpec, SkillSpec } from './job.js'
4
+ import type { RepoSpec } from './job.js'
5
+ import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
5
6
  import { readEffortReport } from './effort.js'
6
7
  import { log } from './logger.js'
7
8
  import {
@@ -12,6 +13,7 @@ import {
12
13
  CONTEXT_DIR,
13
14
  materializeContextFiles,
14
15
  materializeSkillResources,
16
+ phasedProxyBaseUrl,
15
17
  runPi,
16
18
  webSearchConfigFromEnv,
17
19
  webSearchProxyEnv,
@@ -172,6 +174,12 @@ export interface AgentRunSpec {
172
174
  ambientAuth?: boolean
173
175
  /** Pi proxy base URL (Pi harness only). */
174
176
  proxyBaseUrl?: string
177
+ /**
178
+ * The backend serves the phase-tagged completions route, so this pass may tag the URL it
179
+ * points Pi at with the phase it is running under (see {@link HarnessAuthFields.proxyPhasePath}
180
+ * and `phasedProxyBaseUrl`). Absent ⇒ the plain path.
181
+ */
182
+ proxyPhasePath?: boolean
175
183
  /** Pi proxy session token (Pi harness only). */
176
184
  sessionToken?: string
177
185
  /**
@@ -206,12 +214,20 @@ export interface AgentRunSpec {
206
214
  */
207
215
  contextFiles?: ContextFileInfo[]
208
216
  /**
209
- * A repo-sourced Claude Skill to make available for this run (slice 2). Installed HARNESS-AWARE:
210
- * the claude-code runner writes it natively into the config dir's `skills/`; for Pi/codex the
211
- * resource files are materialised under `.cat-context/skill/` (their prompt already carries the
212
- * folded-in instructions). Absent ⇒ no skill.
217
+ * The skills to make available for this run a `skill` step's picked skill and/or the playbooks
218
+ * the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
219
+ * natively into the config dir's `skills/`; for Pi/codex the resource files are materialised
220
+ * under `.cat-context/skill/<name>/` (their prompt already carries the folded-in instructions).
221
+ * Absent ⇒ no skills.
222
+ */
223
+ skills?: SkillSpec[]
224
+ /**
225
+ * Tool servers (MCP) to wire into the agent CLI. Served by the subscription harnesses only —
226
+ * Pi has no MCP client, and the BACKEND is what decides that (it drops an unservable server and
227
+ * tells the agent so), which is why this path simply forwards whatever it is given rather than
228
+ * re-deciding. Absent ⇒ the CLI's built-in tools only.
213
229
  */
214
- skill?: SkillSpec
230
+ mcpServers?: McpServerSpec[]
215
231
  /**
216
232
  * Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
217
233
  * backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
@@ -268,14 +284,14 @@ export async function runAgentInWorkspace(
268
284
  // harness paths; kept out of the agent's commits via a local git exclude entry.
269
285
  const contextFiles = spec.contextFiles ?? []
270
286
  await materializeContextFiles(spec.dir, contextFiles)
271
- // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
272
- // so it reads from there. Everything else reads the checkout, so materialise the skill's
273
- // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
274
- // backend) — Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
275
- // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
276
- // `runClaudeCode`). A resource-free skill is a no-op here.
277
- if (spec.skill && !installsSkillNatively(spec)) {
278
- await materializeSkillResources(spec.dir, spec.skill)
287
+ // Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
288
+ // there. Everything else reads the checkout, so materialise each skill's resources under
289
+ // `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
290
+ // Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
291
+ // runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
292
+ // Resource-free skills are a no-op here.
293
+ if (spec.skills?.length && !installsSkillNatively(spec)) {
294
+ await materializeSkillResources(spec.dir, spec.skills)
279
295
  }
280
296
 
281
297
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
@@ -296,7 +312,8 @@ export async function runAgentInWorkspace(
296
312
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
297
313
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
298
314
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
299
- ...(spec.skill ? { skill: spec.skill } : {}),
315
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
316
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
300
317
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
301
318
  signal: opts.signal,
302
319
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
@@ -346,7 +363,15 @@ export async function runAgentInWorkspace(
346
363
  hasBlueprints,
347
364
  ...(spec.multiRepo ? { multiRepo: true } : {}),
348
365
  })
349
- await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
366
+ // Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
367
+ // the phase this pass runs under is carried on the URL it is pointed at. Resolved per pass
368
+ // (this whole function re-runs for every repair round), which is what makes a repair round's
369
+ // spend distinguishable from the first pass's. Only when the BACKEND said it serves that
370
+ // route, since a runner pool or `LOCAL_HARNESS_IMAGE` can pair this image with an older one.
371
+ await writePiModelsConfig({
372
+ model: spec.model,
373
+ proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
374
+ })
350
375
  const { signal, onActivity, onProgress, onSpan } = opts
351
376
  const piOutcome = await runPi({
352
377
  cwd: spec.dir,
@@ -367,12 +392,12 @@ export async function runAgentInWorkspace(
367
392
  }
368
393
 
369
394
  /**
370
- * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
371
- * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
395
+ * Whether the claude-code runner will install this run's skills natively (into the CLI's config
396
+ * dir) rather than the caller materialising them into the checkout. True ONLY for a
372
397
  * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
373
- * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
374
- * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
375
- * skills from different repos would overwrite each other's.
398
+ * uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
399
+ * outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
400
+ * would overwrite each other's.
376
401
  */
377
402
  export function installsSkillNatively(
378
403
  spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,