@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.
@@ -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,
@@ -140,12 +139,18 @@ export interface CodingAgentSpec extends HarnessAuthFields {
140
139
  */
141
140
  reproduction?: ReproductionSpec
142
141
  /**
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.
142
+ * The skills to make available for this run a `skill` step's pick and/or the running kind's
143
+ * declared playbooks. Threaded into {@link runAgentInWorkspace}, which installs them
144
+ * harness-aware: natively under the ISOLATED `CLAUDE_CONFIG_DIR` for a leased-credential
145
+ * claude-code run, `.cat-context/skill/<name>/` for everything else (Pi, codex, and ambient
146
+ * claude-code, which has no isolated config dir). Absent ⇒ no skills.
147
147
  */
148
- skill?: SkillSpec
148
+ skills?: SkillSpec[]
149
+ /**
150
+ * Tool servers (MCP) to wire into the agent CLI for this run. Forwarded verbatim — the backend
151
+ * has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
152
+ */
153
+ mcpServers?: McpServerSpec[]
149
154
  }
150
155
 
151
156
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
@@ -179,6 +184,8 @@ export interface CodingAgentOutcome {
179
184
  exitCode: number
180
185
  validationOutputTail?: string
181
186
  iteration?: number
187
+ /** The work-branch HEAD the command was judged against (absent when it could not be read). */
188
+ headSha?: string
182
189
  }
183
190
  /**
184
191
  * The pre-PR validation loop's LAST attempt (present only when {@link CodingAgentSpec.validationChecks}
@@ -350,7 +357,8 @@ export async function runCodingAgent(
350
357
  webToolsGuidance: spec.webToolsGuidance,
351
358
  webSearchProxy: spec.webSearchProxy,
352
359
  guardLimits: spec.guardLimits,
353
- ...(spec.skill ? { skill: spec.skill } : {}),
360
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
361
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
354
362
  },
355
363
  opts,
356
364
  )
@@ -718,7 +726,7 @@ async function finalizeCodingRun(args: {
718
726
  // Runs regardless of whether this pass pushed — a no-op iteration must still be able
719
727
  // to report that the criterion is (already) met. The harness runs it, never the model.
720
728
  if (spec.validation) {
721
- outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
729
+ outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts)
722
730
  }
723
731
  // Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
724
732
  // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
@@ -796,22 +804,57 @@ function mergeAgentPasses<T extends Awaited<ReturnType<typeof runAgentInWorkspac
796
804
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
797
805
  * Overridable via env for tests; defaults to 15 minutes.
798
806
  */
799
- function ralphValidationTimeoutMs(): number {
807
+ export function ralphValidationTimeoutMs(): number {
800
808
  const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
801
809
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
802
810
  }
803
811
 
812
+ /**
813
+ * How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
814
+ * The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
815
+ * — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
816
+ * events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
817
+ * watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
818
+ * validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
819
+ * a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
820
+ * settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
821
+ * always fed it; this one did not. Overridable via env for tests.
822
+ */
823
+ export function ralphHeartbeatMs(): number {
824
+ const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS)
825
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
826
+ }
827
+
828
+ /**
829
+ * Bound on the validation output tail that crosses the wire. Deliberately smaller than
830
+ * `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
831
+ * the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
832
+ * log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
833
+ */
834
+ export const RALPH_VALIDATION_TAIL_CHARS = 4_000
835
+
804
836
  /**
805
837
  * 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.
838
+ * code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
839
+ * The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
840
+ * here by the harness, never self-reported by the model, which is the whole point of a
841
+ * programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
842
+ * trust boundary as the coding agent) there is no host/backend execution.
843
+ *
844
+ * The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
845
+ * command, rather than the near-verbatim copy this used to be. That copy had drifted in two
846
+ * ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
847
+ * margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
848
+ * an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
849
+ * it published the full 16k capture where both siblings deliberately bound the wire tail.
850
+ *
851
+ * `headSha` is what lets the engine tell a loop that is iterating from one that is merely
852
+ * repeating: two consecutive failing iterations against an unchanged head means the agent
853
+ * committed nothing, and the loop is ended early instead of spending the rest of its budget.
854
+ * Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
813
855
  */
814
- async function runRalphValidation(
856
+ export async function runRalphValidation(
857
+ repoDir: string,
815
858
  cwd: string,
816
859
  validation: { command: string; iteration?: number },
817
860
  logger: Logger,
@@ -821,66 +864,50 @@ async function runRalphValidation(
821
864
  exitCode: number
822
865
  validationOutputTail?: string
823
866
  iteration?: number
867
+ headSha?: string
824
868
  }> {
825
- const timeoutMs = ralphValidationTimeoutMs()
826
869
  logger.info('coding-agent(ralph): running validation command', {
827
870
  iteration: validation.iteration,
828
871
  })
829
- return new Promise((resolve) => {
830
- let out = ''
831
- let settled = false
832
- const child = spawn('sh', ['-c', validation.command], {
872
+ // Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
873
+ const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs())
874
+ heartbeat.unref?.()
875
+ let captured
876
+ try {
877
+ captured = await runCapturedCommand({
833
878
  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 },
879
+ command: validation.command,
880
+ timeoutMs: ralphValidationTimeoutMs(),
881
+ reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
882
+ logLabel: 'coding-agent(ralph): validation',
883
+ logFields: { iteration: validation.iteration },
884
+ logger,
885
+ opts,
840
886
  })
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)
887
+ } finally {
888
+ clearInterval(heartbeat)
889
+ }
890
+ // The commit the criterion was judged against. Read AFTER the command so a validation that
891
+ // itself commits (a formatter check that rewrites files, say) is attributed to what it left.
892
+ // Best-effort: an unreadable head only costs the engine's no-progress guard, never the
893
+ // verdict but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
894
+ const headSha = await headCommit(repoDir, opts.signal).catch((err: unknown) => {
895
+ logger.warn('coding-agent(ralph): could not read the work-branch head', {
896
+ error: err instanceof Error ? err.message : String(err),
881
897
  })
882
- child.on('close', (code) => finish(code ?? 1))
898
+ return ''
899
+ })
900
+ logger.info('coding-agent(ralph): validation finished', {
901
+ exitCode: captured.exitCode,
902
+ iteration: validation.iteration,
883
903
  })
904
+ return {
905
+ validationPassed: captured.passed,
906
+ exitCode: captured.exitCode,
907
+ ...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
908
+ ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
909
+ ...(headSha ? { headSha } : {}),
910
+ }
884
911
  }
885
912
 
886
913
  /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
@@ -1030,6 +1057,10 @@ export async function runMultiRepoCoding(
1030
1057
  webSearchProxy: job.webSearch,
1031
1058
  guardLimits: job.guardLimits,
1032
1059
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
1060
+ // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
1061
+ // are properties of the AGENT KIND, not of the checkout layout.
1062
+ ...(job.skills?.length ? { skills: job.skills } : {}),
1063
+ ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
1033
1064
  multiRepo: true,
1034
1065
  },
1035
1066
  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.
@@ -632,26 +642,6 @@ export interface ContextFileSpec {
632
642
  content: string
633
643
  }
634
644
 
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
645
  /** How an explore agent's reply is consumed. */
656
646
  export interface AgentOutputSpec {
657
647
  /** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
@@ -739,11 +729,20 @@ export interface AgentJob extends HarnessAuthFields {
739
729
  */
740
730
  packageRegistries?: PackageRegistrySpec[]
741
731
  /**
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.
732
+ * The skills to make available for this run (see {@link SkillSpec}) — a `skill` step's picked
733
+ * skill and/or the playbooks the running agent kind declares. Materialised harness-aware before
734
+ * the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/` for claude-code, or
735
+ * `.cat-context/skill/<name>/<relPath>` for Pi/codex. Absent ⇒ no skills installed.
736
+ */
737
+ skills?: SkillSpec[]
738
+ /**
739
+ * Tool servers (MCP) to wire into the agent CLI for this run (see {@link McpServerSpec}). The
740
+ * backend has already dropped anything this harness cannot serve, so every entry here is
741
+ * expected to work. SECRET-BEARING (`env`/`headers` carry resolved credentials), so the config
742
+ * files written from it live outside the checkout and are never logged. Absent ⇒ the CLI's
743
+ * built-in tools only.
745
744
  */
746
- skill?: SkillSpec
745
+ mcpServers?: McpServerSpec[]
747
746
  /**
748
747
  * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
749
748
  * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
@@ -942,6 +941,12 @@ export interface AgentResult {
942
941
  exitCode: number
943
942
  validationOutputTail?: string
944
943
  iteration?: number
944
+ /**
945
+ * The work-branch HEAD the command was judged against. The engine compares it across
946
+ * consecutive failing iterations to end a loop that has stopped committing anything,
947
+ * instead of spending the rest of its budget re-learning that. Absent when unreadable.
948
+ */
949
+ headSha?: string
945
950
  }
946
951
  /**
947
952
  * Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
@@ -1029,71 +1034,6 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
1029
1034
  return files
1030
1035
  }
1031
1036
 
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
1037
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
1098
1038
  function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
1099
1039
  if (typeof value !== 'object' || value === null) return undefined
@@ -1335,7 +1275,8 @@ export function parseAgentJob(input: unknown): AgentJob {
1335
1275
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
1336
1276
  contextFiles: parseContextFiles(o.contextFiles),
1337
1277
  packageRegistries: parsePackageRegistries(o.packageRegistries),
1338
- skill: parseSkillSpec(o.skill),
1278
+ skills: parseSkillSpecs(o.skills),
1279
+ mcpServers: parseMcpServerSpecs(o.mcpServers),
1339
1280
  testSecrets: parseTestSecrets(o.testSecrets),
1340
1281
  guardLimits: parseGuardLimits(o.guardLimits),
1341
1282
  validation: parseValidationSpec(o.validation),
@@ -1374,7 +1315,8 @@ interface ParsedAgentJobParts {
1374
1315
  bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
1375
1316
  contextFiles: ReturnType<typeof parseContextFiles>
1376
1317
  packageRegistries: ReturnType<typeof parsePackageRegistries>
1377
- skill: ReturnType<typeof parseSkillSpec>
1318
+ skills: ReturnType<typeof parseSkillSpecs>
1319
+ mcpServers: ReturnType<typeof parseMcpServerSpecs>
1378
1320
  testSecrets: ReturnType<typeof parseTestSecrets>
1379
1321
  guardLimits: ReturnType<typeof parseGuardLimits>
1380
1322
  validation: ReturnType<typeof parseValidationSpec>
@@ -1428,7 +1370,8 @@ function assembleAgentJob(
1428
1370
  bootstrap,
1429
1371
  contextFiles,
1430
1372
  packageRegistries,
1431
- skill,
1373
+ skills,
1374
+ mcpServers,
1432
1375
  testSecrets,
1433
1376
  guardLimits,
1434
1377
  validation,
@@ -1452,7 +1395,8 @@ function assembleAgentJob(
1452
1395
  ...(output ? { output } : {}),
1453
1396
  ...(contextFiles.length ? { contextFiles } : {}),
1454
1397
  ...(packageRegistries.length ? { packageRegistries } : {}),
1455
- ...(skill ? { skill } : {}),
1398
+ ...(skills ? { skills } : {}),
1399
+ ...(mcpServers ? { mcpServers } : {}),
1456
1400
  ...(testSecrets.length ? { testSecrets } : {}),
1457
1401
  ...(infra ? { infra } : {}),
1458
1402
  ...(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 {
@@ -206,12 +207,20 @@ export interface AgentRunSpec {
206
207
  */
207
208
  contextFiles?: ContextFileInfo[]
208
209
  /**
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.
210
+ * The skills to make available for this run a `skill` step's picked skill and/or the playbooks
211
+ * the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
212
+ * natively into the config dir's `skills/`; for Pi/codex the resource files are materialised
213
+ * under `.cat-context/skill/<name>/` (their prompt already carries the folded-in instructions).
214
+ * Absent ⇒ no skills.
213
215
  */
214
- skill?: SkillSpec
216
+ skills?: SkillSpec[]
217
+ /**
218
+ * Tool servers (MCP) to wire into the agent CLI. Served by the subscription harnesses only —
219
+ * Pi has no MCP client, and the BACKEND is what decides that (it drops an unservable server and
220
+ * tells the agent so), which is why this path simply forwards whatever it is given rather than
221
+ * re-deciding. Absent ⇒ the CLI's built-in tools only.
222
+ */
223
+ mcpServers?: McpServerSpec[]
215
224
  /**
216
225
  * Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
217
226
  * backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
@@ -268,14 +277,14 @@ export async function runAgentInWorkspace(
268
277
  // harness paths; kept out of the agent's commits via a local git exclude entry.
269
278
  const contextFiles = spec.contextFiles ?? []
270
279
  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)
280
+ // Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
281
+ // there. Everything else reads the checkout, so materialise each skill's resources under
282
+ // `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
283
+ // Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
284
+ // runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
285
+ // Resource-free skills are a no-op here.
286
+ if (spec.skills?.length && !installsSkillNatively(spec)) {
287
+ await materializeSkillResources(spec.dir, spec.skills)
279
288
  }
280
289
 
281
290
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
@@ -296,7 +305,8 @@ export async function runAgentInWorkspace(
296
305
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
297
306
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
298
307
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
299
- ...(spec.skill ? { skill: spec.skill } : {}),
308
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
309
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
300
310
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
301
311
  signal: opts.signal,
302
312
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
@@ -367,12 +377,12 @@ export async function runAgentInWorkspace(
367
377
  }
368
378
 
369
379
  /**
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
380
+ * Whether the claude-code runner will install this run's skills natively (into the CLI's config
381
+ * dir) rather than the caller materialising them into the checkout. True ONLY for a
372
382
  * 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.
383
+ * uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
384
+ * outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
385
+ * would overwrite each other's.
376
386
  */
377
387
  export function installsSkillNatively(
378
388
  spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,
package/src/pi.ts CHANGED
@@ -253,29 +253,37 @@ export async function materializeContextFiles(
253
253
  }
254
254
  }
255
255
 
256
- /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
256
+ /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
257
257
  export const SKILL_CONTEXT_SUBDIR = 'skill'
258
258
 
259
259
  /**
260
- * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
261
- * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
262
- * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
263
- * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
264
- * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
265
- * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
266
- * exclude entry. A skill with no resource bodies is a no-op.
260
+ * Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
261
+ * — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
262
+ * (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
263
+ * skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
264
+ * which keys off ambient auth as well as the harness).
265
+ *
266
+ * Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
267
+ * the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
268
+ * overwrite each other — silently handing the agent the wrong template. The names were sanitized
269
+ * to a single safe path segment at the job boundary, as were the resource sub-paths (no
270
+ * traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
271
+ * `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
267
272
  */
268
273
  export async function materializeSkillResources(
269
274
  cwd: string,
270
- skill: { resources: { relPath: string; content: string }[] },
275
+ skills: { name: string; resources: { relPath: string; content: string }[] }[],
271
276
  ): Promise<void> {
272
- if (!skill.resources.length) return
273
- const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR)
274
- await mkdir(dir, { recursive: true })
275
- for (const r of skill.resources) {
276
- const dest = join(dir, r.relPath)
277
- await mkdir(dirname(dest), { recursive: true })
278
- await writeFile(dest, r.content, 'utf8')
277
+ const withResources = skills.filter((s) => s.resources.length)
278
+ if (!withResources.length) return
279
+ for (const skill of withResources) {
280
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name)
281
+ await mkdir(dir, { recursive: true })
282
+ for (const r of skill.resources) {
283
+ const dest = join(dir, r.relPath)
284
+ await mkdir(dirname(dest), { recursive: true })
285
+ await writeFile(dest, r.content, 'utf8')
286
+ }
279
287
  }
280
288
  const gitRoot = await findGitRoot(cwd)
281
289
  if (!gitRoot) return