@cat-factory/executor-harness 1.72.0 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,21 +63,30 @@ The implementation job (`POST /run`) is the canonical sequence:
63
63
  stays distinguishable from the first pass's in telemetry; without that flag the plain path is
64
64
  used and the calls are recorded as unattributed
65
65
  (see [token-burn instrumentation](../../../docs/initiatives/token-burn-instrumentation.md)),
66
- 3. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
67
- 4. **validate** the checkout, when the job body carries `validationChecks` the service's
66
+ 3. **prepopulate dependencies**, when the job body carries `dependencyInstall` the
67
+ service's install command is run with `sh -c` in the checkout BEFORE the agent starts, so
68
+ it reads real installed packages instead of inferring a library's capabilities from a
69
+ manifest entry. Best-effort and never a gate: the outcome (success or the captured
70
+ failure) is folded into the agent's prompt — on EVERY pass, including the repair passes of
71
+ steps 5 and 6, which start a fresh agent — and the run continues either way. Whatever the
72
+ install materialises is excluded from git first, so no later `git add -A` can sweep a
73
+ dependency tree into the pull request (see
74
+ [dependency prepopulation](../../../docs/initiatives/agent-dependency-prepopulation.md)),
75
+ 4. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
76
+ 5. **validate** the checkout, when the job body carries `validationChecks` — the service's
68
77
  configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
69
78
  while they fail and the attempt budget remains the agent is re-run with the captured output
70
79
  as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
71
- 5. **prove the reproduction**, when the job body carries `reproduction` — the declared check is
80
+ 6. **prove the reproduction**, when the job body carries `reproduction` — the declared check is
72
81
  run against the pre-fix tree and the tree the PR will open from, in two freshly-created
73
82
  symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
74
83
  [bugfix reproduction proof](../../../docs/initiatives/bugfix-reproduction-proof.md)). Unlike
75
- step 4 this NEVER gates the PR: a failed verification is fed back to the agent while budget
76
- remains, then recorded as `inconclusive`. It runs BEFORE step 4 so validation stays the last
84
+ step 5 this NEVER gates the PR: a failed verification is fed back to the agent while budget
85
+ remains, then recorded as `inconclusive`. It runs BEFORE step 5 so validation stays the last
77
86
  thing to touch the tree,
78
- 6. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
79
- ONLY if step 4 ended green. A spent budget returns an error result with the validation report
80
- and opens no PR. Absent `validationChecks` / `reproduction`, steps 4 and 5 do not happen at
87
+ 7. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
88
+ ONLY if step 5 ended green. A spent budget returns an error result with the validation report
89
+ and opens no PR. Absent `validationChecks` / `reproduction`, steps 5 and 6 do not happen at
81
90
  all. The PR's description prefers the agent-authored reviewer briefing over the generic
82
91
  dispatch-time text the job body carries: a PR-opening agent is prompted to write one to the
83
92
  `.cat-pr-description.md` sentinel at the checkout root (one per sibling repo in a multi-repo
@@ -186,6 +195,7 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
186
195
  | `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
187
196
  | `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
188
197
  | `src/captured-command.ts` | The one way the harness runs a declared shell command on its own behalf: `sh -c` with a per-command watchdog, abort handling, conventional exit codes (124/127/130) and a scrub-then-bound output capture. Shared by both pre-PR verification phases so a fix to one cannot miss the other. |
198
+ | `src/dependency-install.ts` | Dependency prepopulation: `prepopulateDependencies` is the ONE seam every checkout-having mode calls — it runs the service's install command before the agent's first turn, excludes what the install materialised from git so no `git add -A` can sweep a dependency tree into the PR, and builds the prompt note describing the outcome. Best-effort — every failure shape becomes a note, never a failed job. Generic — keyed off the job body, never the agent kind. |
189
199
  | `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic — keyed off the job body, never the agent kind. |
190
200
  | `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic — keyed off the job body, never the agent kind. |
191
201
  | `src/agent-capabilities.ts` | The agent CAPABILITIES a job body carries — the run's `skills` (a `SKILL.md` payload + resources) and its `mcpServers` (tool servers) — with their defensive parsing and the per-CLI config writers (`--mcp-config` JSON for claude-code, `[mcp_servers.*]` TOML for Codex). Backend-authored data the harness only MATERIALISES: adding a skill or a tool server is a backend registration, never a harness change. |
@@ -203,6 +213,9 @@ runner):
203
213
  | `PORT` | `8080` | HTTP port the harness listens on. |
204
214
  | `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
205
215
  | `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
216
+ | `JOB_COLD_START_MS` | `120000` (2m) | First-output window (ADR 0026 D4). A job that has produced nothing this long records a cold-start diagnostic — a likely onboarding/auth wedge — WITHOUT being killed: logged, exposed on `GET /jobs/{id}`, and folded into the failure `detail` if the job goes on to fail. `0` disables it. |
217
+ | `DEPENDENCY_INSTALL_TIMEOUT_MS` | a third of `JOB_MAX_DURATION_MS` (20m at its default) | Watchdog for the pre-agent dependency install; a timeout is reported as a failed install (exit 124), never a failed job. Derived from the job ceiling rather than fixed, and an explicit value is clamped by the same share — the agent is what waits on this, so setup can never consume the run it is preparing for. |
218
+ | `DEPENDENCY_INSTALL_HEARTBEAT_MS` | `30000` (30s) | How often the dependency install feeds the job inactivity watchdog. A cold install is activity-silent and `JOB_INACTIVITY_MS` is tighter than its own watchdog, so without this a healthy install aborts the run as "likely hung". |
206
219
  | `VALIDATION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a pre-PR validation check; a timeout counts as a failure (exit 124) so one hung command can't wedge the loop. |
207
220
  | `REPRODUCTION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a reproduction-proof setup or check command; a timeout counts as a failure (exit 124). |
208
221
  | `REPRODUCTION_HEARTBEAT_MS` | `30000` (30s) | How often the reproduction proof feeds the job inactivity watchdog while it runs commands the agent is not producing output for. |
@@ -8,6 +8,7 @@ import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
8
8
  import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
9
9
  import { ProgressGuard } from './progress-guard.js';
10
10
  import { killChildProcess, spawnDetached } from './process.js';
11
+ import { describeProcessExit } from './process-exit.js';
11
12
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js';
12
13
  import { createSliceTracker, startSubagentWatcher } from './subagents.js';
13
14
  import { createTaskPlanTracker, mergeProgress, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
@@ -112,7 +113,7 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
112
113
  opts.signal?.removeEventListener('abort', onAbort);
113
114
  reject(err);
114
115
  });
115
- child.on('close', (code) => {
116
+ child.on('close', (code, signal) => {
116
117
  opts.signal?.removeEventListener('abort', onAbort);
117
118
  const stderrTail = redact(stderr, secrets).slice(-700);
118
119
  if (lineBuffer.trim())
@@ -125,13 +126,95 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
125
126
  return;
126
127
  }
127
128
  if (code !== 0) {
128
- reject(new Error(`${command} exited with code ${code}: ${stderrTail}`));
129
+ reject(new CliExitFailure({ command, exitCode: code, signal, stderrTail }));
129
130
  return;
130
131
  }
131
132
  resolve({ stderrTail });
132
133
  });
133
134
  });
134
135
  }
136
+ /**
137
+ * A CLI subprocess that ended badly — it exited non-zero, or a signal killed it.
138
+ *
139
+ * Its own class (rather than a formatted string) because the message is not final at throw time:
140
+ * the caller folds in the CLI's terminal report before it surfaces (see {@link withAgentReport}),
141
+ * and rebuilding from parts beats patching a rendered sentence. Distinct from the watchdog-abort
142
+ * rejection above, which owns its own diagnostic and must keep it.
143
+ */
144
+ class CliExitFailure extends Error {
145
+ parts;
146
+ /**
147
+ * Also exposed flat, matching the watchdog-abort rejection's shape: the guard-trip branch
148
+ * reads `stderrTail` off whatever it caught to append to its own replacement message.
149
+ */
150
+ stderrTail;
151
+ constructor(parts, report = '') {
152
+ super(cliExitMessage(parts, report));
153
+ this.name = 'CliExitFailure';
154
+ this.parts = parts;
155
+ this.stderrTail = parts.stderrTail;
156
+ }
157
+ }
158
+ /**
159
+ * One message shape for a CLI that ended badly, with or without a report to add.
160
+ *
161
+ * How it ended is rendered through {@link describeProcessExit}, the shared vocabulary every
162
+ * process-reporting transport uses, so an externally-killed container job (an OOM kill, a
163
+ * `docker stop` racing teardown) reads differently from the CLI's own failure exit.
164
+ */
165
+ function cliExitMessage(exit, report) {
166
+ const how = describeProcessExit(exit.exitCode, exit.signal);
167
+ const suffix = report ? ` Agent's last report: ${report}` : '';
168
+ return `${exit.command} ${how}: ${exit.stderrTail || '(no stderr output)'}${suffix}`;
169
+ }
170
+ /**
171
+ * Re-throw a bad CLI exit with the CLI's own account of how the run ended folded in.
172
+ *
173
+ * Both agent CLIs report a terminal failure on STDOUT, inside their event stream (Claude Code's
174
+ * `result` event, Codex's last agent message) — never on stderr. So a run the upstream API kept
175
+ * refusing exits non-zero with an EMPTY stderr tail, and the harness surfaces `claude exited with
176
+ * code 1:` and nothing else, while the reason it collected sits in a local variable only the
177
+ * SUCCESS path returns. That failure is indistinguishable from a crash, and the operator has no
178
+ * next step. Nothing else in the run records it: the CLI's session transcript dies with the
179
+ * per-run config home, and a local-mode container is removed the moment the job settles.
180
+ *
181
+ * Anything that is not a bad-exit rejection passes through untouched — a watchdog abort and a
182
+ * tripped progress guard carry more specific diagnostics already.
183
+ */
184
+ function withAgentReport(err, report, secrets) {
185
+ if (!(err instanceof CliExitFailure))
186
+ return err;
187
+ const folded = capReport(redact(report, secrets).trim());
188
+ return folded ? new CliExitFailure(err.parts, folded) : err;
189
+ }
190
+ /** How much of the agent's terminal report the failure message carries. */
191
+ const MAX_AGENT_REPORT_CHARS = 700;
192
+ /**
193
+ * Bound the agent's terminal report, keeping its HEAD — the opposite bias from the stderr tail
194
+ * beside it, and deliberately so. A stderr tail is a log: the cause is whatever it ended on. A
195
+ * report is a written statement, and its opening is where the answer lives — the failure
196
+ * `subtype` {@link claudeResultReport} prepends, or the first line of Codex's last agent message.
197
+ * Tail-slicing it drops exactly the classification the fold exists to surface.
198
+ *
199
+ * A cut is marked, because a report that merely stops reads like an agent that trailed off.
200
+ */
201
+ function capReport(report) {
202
+ if (report.length <= MAX_AGENT_REPORT_CHARS)
203
+ return report;
204
+ return `${report.slice(0, MAX_AGENT_REPORT_CHARS)}… (report truncated)`;
205
+ }
206
+ /**
207
+ * The CLI's own account of how the run ended, read off its terminal `result` event: the failure
208
+ * `subtype` it names (`error_during_execution`, `error_max_turns`, …) joined to whatever text it
209
+ * printed. A headless `-p` run reports an upstream API refusal HERE — on stdout, as JSON — and
210
+ * nowhere else, so this is what a bad exit has to carry. A clean result yields just its text.
211
+ */
212
+ function claudeResultReport(event) {
213
+ const subtype = typeof event.subtype === 'string' ? event.subtype : '';
214
+ const text = typeof event.result === 'string' ? event.result.trim() : '';
215
+ const failed = event.is_error === true || (subtype !== '' && subtype !== 'success');
216
+ return failed ? [subtype || 'error', text].filter(Boolean).join(': ') : text;
217
+ }
135
218
  /**
136
219
  * Fold a composed system prompt into the task prompt so the role + best-practice context
137
220
  * rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
@@ -246,6 +329,8 @@ async function setUpClaudeMcp(servers, configHome) {
246
329
  export async function runClaudeCode(opts) {
247
330
  const stats = { toolCalls: 0, assistantChars: 0 };
248
331
  let summary = '';
332
+ /** The CLI's own account of how the run ended — see {@link claudeResultReport}. */
333
+ let terminalReport = '';
249
334
  let usage;
250
335
  // Decide how the composed system prompt is carried up front, so the telemetry seed below
251
336
  // reflects what actually reaches the model: a small prompt rides `--append-system-prompt`
@@ -397,49 +482,11 @@ export async function runClaudeCode(opts) {
397
482
  if (typeof event.result === 'string')
398
483
  summary = event.result;
399
484
  usage = claudeUsage(event.usage) ?? usage;
485
+ terminalReport = claudeResultReport(event) || terminalReport;
400
486
  }
401
487
  };
402
- // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
403
- // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
404
- // Claude Code persists user config/credentials under its config dir; point that at an
405
- // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
406
- // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
407
- // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
408
- // Mirrors the Codex CODEX_HOME isolation below; removed in `finally`.
409
- if (!opts.ambientAuth && !opts.subscriptionToken) {
410
- throw new Error('claude-code harness requires a subscription token (or ambientAuth)');
411
- }
412
- const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'));
413
- // The config dir is brand-new every run, so Claude Code would otherwise treat this
414
- // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
415
- // bypass-permissions acknowledgement prompts — which never get answered headlessly,
416
- // hanging the job until the watchdog kills it. Pre-seed the config that marks those
417
- // as already accepted so `-p` starts straight into the run. Best-effort: written
418
- // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
419
- // (Ambient mode skips this — the developer's own config is already onboarded.)
420
- // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
421
- // version, so a future first-run gate this set doesn't cover (which looks identical to
422
- // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
423
- if (configHome) {
424
- await writeOnboardingPreseed(configHome);
425
- await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
426
- }
427
- // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
428
- // discovers and can invoke it. ONLY into the isolated per-run config home — never the
429
- // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
430
- // setup after the run and two concurrent jobs carrying same-named skills would clobber each
431
- // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
432
- // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
433
- // still names the skills.
434
- if (configHome) {
435
- for (const skill of opts.skills ?? []) {
436
- await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => { });
437
- }
438
- }
439
- // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
440
- // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
441
- const mcp = await setUpClaudeMcp(opts.mcpServers, configHome);
442
- const env = buildClaudeEnv(opts, configHome);
488
+ const home = await openClaudeRunHome(opts);
489
+ const { configHome } = home;
443
490
  // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
444
491
  // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
445
492
  // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
@@ -478,10 +525,10 @@ export async function runClaudeCode(opts) {
478
525
  'bypassPermissions',
479
526
  '--model',
480
527
  opts.model,
481
- ...mcp.args,
528
+ ...home.mcpArgs,
482
529
  ...appendArgs,
483
530
  ],
484
- }, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
531
+ }, prompt, { ...opts, signal: runSignal }, home.env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
485
532
  // The stream has ended, so the last call has no successor envelope to complete it.
486
533
  telemetry.flush();
487
534
  return await assembleClaudeOutcome({
@@ -504,20 +551,80 @@ export async function runClaudeCode(opts) {
504
551
  telemetry.flush();
505
552
  publisher.flush();
506
553
  // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
507
- // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
508
- // it attached, since that is usually the only evidence of what the CLI was doing when it was
509
- // killed. Byte-for-byte the shape `runPi` fails with.
554
+ // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail it
555
+ // attached, since that is usually the only evidence of what the CLI was doing when it was
556
+ // killed. The leading clauses are byte-for-byte the shape `runPi` fails with; a terminal
557
+ // report is appended after them when the CLI managed to emit one before it was killed, which
558
+ // is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
559
+ // discard it.
510
560
  if (guardReason) {
511
561
  const tail = err?.stderrTail;
512
- throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason);
562
+ const report = capReport(redact(terminalReport, secrets).trim());
563
+ throw new Error([
564
+ guardReason,
565
+ tail ? `Agent stderr: ${tail}` : '',
566
+ report ? `Agent's last report: ${report}` : '',
567
+ ]
568
+ .filter(Boolean)
569
+ .join(' '));
513
570
  }
514
- throw err;
571
+ throw withAgentReport(err, terminalReport, secrets);
515
572
  }
516
573
  finally {
517
574
  await subagents?.stop();
518
- // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
519
- await mcp.cleanup();
520
- if (configHome) {
575
+ await home.dispose();
576
+ }
577
+ }
578
+ async function openClaudeRunHome(opts) {
579
+ // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
580
+ // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
581
+ // Claude Code persists user config/credentials under its config dir; point that at an
582
+ // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
583
+ // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
584
+ // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
585
+ // Mirrors the Codex CODEX_HOME isolation below; removed by `dispose`.
586
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
587
+ throw new Error('claude-code harness requires a subscription token (or ambientAuth)');
588
+ }
589
+ const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'));
590
+ // The config dir is brand-new every run, so Claude Code would otherwise treat this
591
+ // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
592
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
593
+ // hanging the job until the watchdog kills it. Pre-seed the config that marks those
594
+ // as already accepted so `-p` starts straight into the run. Best-effort: written
595
+ // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
596
+ // (Ambient mode skips this — the developer's own config is already onboarded.)
597
+ // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
598
+ // version, so a future first-run gate this set doesn't cover (which looks identical to
599
+ // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
600
+ if (configHome) {
601
+ await writeOnboardingPreseed(configHome);
602
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
603
+ }
604
+ // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
605
+ // discovers and can invoke it. ONLY into the isolated per-run config home — never the
606
+ // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
607
+ // setup after the run and two concurrent jobs carrying same-named skills would clobber each
608
+ // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
609
+ // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
610
+ // still names the skills.
611
+ if (configHome) {
612
+ for (const skill of opts.skills ?? []) {
613
+ await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => { });
614
+ }
615
+ }
616
+ // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
617
+ // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
618
+ const mcp = await setUpClaudeMcp(opts.mcpServers, configHome);
619
+ return {
620
+ configHome,
621
+ mcpArgs: mcp.args,
622
+ env: buildClaudeEnv(opts, configHome),
623
+ dispose: async () => {
624
+ // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
625
+ await mcp.cleanup();
626
+ if (!configHome)
627
+ return;
521
628
  // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
522
629
  // home is deleted — the credential lives at the home root, never in `projects/`, so this
523
630
  // keeps the debugging artifact without leaking the token. Best-effort; never throws.
@@ -527,8 +634,8 @@ export async function runClaudeCode(opts) {
527
634
  });
528
635
  // Never leave the config dir (and any cached credential) on disk past the run.
529
636
  await rm(configHome, { recursive: true, force: true }).catch(() => { });
530
- }
531
- }
637
+ },
638
+ };
532
639
  }
533
640
  /**
534
641
  * Build the child-process env for the `claude` CLI: an isolated config home plus subscription
@@ -770,6 +877,11 @@ export async function runCodex(opts) {
770
877
  ...(calls.length ? { callMetrics: calls } : {}),
771
878
  };
772
879
  }
880
+ catch (err) {
881
+ // Codex surfaces its terminal failure the same way Claude Code does — in the stdout event
882
+ // stream, not on stderr — so a bad exit carries the last thing the agent said.
883
+ throw withAgentReport(err, summary, secrets);
884
+ }
773
885
  finally {
774
886
  if (codexHome) {
775
887
  // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
package/dist/agent.js CHANGED
@@ -11,6 +11,7 @@ import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
11
  import { applyPrDescription } from './pr-description.js';
12
12
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
13
13
  import { validationFailureMessage } from './validation-checks.js';
14
+ import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
14
15
  import { agentCapabilities, mergeEffort } from './agent-shared.js';
15
16
  import { runBootstrap } from './bootstrap-mode.js';
16
17
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
@@ -451,6 +452,24 @@ async function runExploreMode(job, opts) {
451
452
  });
452
453
  logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched });
453
454
  }
455
+ // DEPENDENCY PREPOPULATION, before the agent's first turn. An EXPLORE run is the case this
456
+ // exists for: a reviewer or architect reading a fresh clone can see that a library is
457
+ // depended upon but not what it actually exposes, so it reasons about the manifest instead
458
+ // of the code. Best-effort — the outcome is stated in the prompt either way and never fails
459
+ // the run. Runs in `workDir` so a monorepo service installs from its own subtree.
460
+ //
461
+ // BEFORE the stand-up below, deliberately. The frontend stand-up runs the service's own
462
+ // install and then SERVES what it built: installing after it would pay for a second install
463
+ // and, worse, rewrite the `node_modules` the running app resolves out of. Prepopulation is
464
+ // setup for everything that follows, so it goes first.
465
+ const dependencyNote = await prepopulateDependencies({
466
+ spec: job.dependencyInstall,
467
+ installDir: workDir,
468
+ repoDir: dir,
469
+ agentDir: workDir,
470
+ logger,
471
+ opts,
472
+ });
454
473
  // Optional infra stand-up (the tester): bring the service's docker-compose
455
474
  // dependencies up at the repo root for the duration of the run, tearing them down in
456
475
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
@@ -463,9 +482,7 @@ async function runExploreMode(job, opts) {
463
482
  // failure) is flagged as a concern; a frontend serve URL points the UI tester at the
464
483
  // app it just built + served (the backend env resolution already reached the harness).
465
484
  const infraNotes = managed ? buildInfraNotes(managed) : [];
466
- const userPrompt = infraNotes.length
467
- ? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}`
468
- : job.userPrompt;
485
+ const userPrompt = withDependencyNote(infraNotes.length ? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}` : job.userPrompt, dependencyNote);
469
486
  // The stand-up record (success or failure, with its captured logs) rides back on EVERY
470
487
  // result branch — the backend surfaces it on the Tester step regardless of whether the
471
488
  // agent then produced a usable report.
@@ -668,12 +685,32 @@ async function runMultiRepoExplore(job, opts) {
668
685
  });
669
686
  }
670
687
  }
688
+ // DEPENDENCY PREPOPULATION for the PRIMARY leg. The install is declared on ONE service frame
689
+ // (the primary repo's), so it is run in that leg's checkout — never fanned out across the
690
+ // peers, whose services declare their own configs the dispatch never resolved. The agent runs
691
+ // at the workspace ROOT and reads across every sibling, which is exactly why this matters
692
+ // here: a cross-repo investigator reasoning about a manifest instead of the packages is the
693
+ // complaint that motivated the feature. Same treatment as the reference branches above.
694
+ //
695
+ // The note names the sibling directory rather than saying "this checkout": the agent's cwd is
696
+ // the workspace root, which has no dependency tree of its own.
697
+ const primaryLeg = legs[0];
698
+ const dependencyNote = primaryLeg
699
+ ? await prepopulateDependencies({
700
+ spec: job.dependencyInstall,
701
+ installDir: join(root, primaryLeg.dirName),
702
+ repoDir: join(root, primaryLeg.dirName),
703
+ agentDir: root,
704
+ logger,
705
+ opts,
706
+ })
707
+ : undefined;
671
708
  opts.onPhase?.('agent');
672
709
  logger.info('multi-repo-explore: running agent', { repos: legs.map((l) => l.dirName) });
673
710
  const run = await runAgentInWorkspace({
674
711
  dir: root,
675
712
  systemPrompt: job.systemPrompt,
676
- userPrompt: job.userPrompt,
713
+ userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
677
714
  model: job.model,
678
715
  harness: job.harness,
679
716
  subscriptionToken: job.subscriptionToken,
@@ -814,6 +851,10 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
814
851
  // (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
815
852
  // like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
816
853
  ...(job.reproduction ? { reproduction: job.reproduction } : {}),
854
+ // Dependency prepopulation: the service's install, run against the checkout BEFORE the
855
+ // agent's first turn (see docs/initiatives/agent-dependency-prepopulation.md). Forwarded
856
+ // straight off the job body like the two phases above — generic machinery keyed on the data.
857
+ ...(job.dependencyInstall ? { dependencyInstall: job.dependencyInstall } : {}),
817
858
  };
818
859
  }
819
860
  /**
@@ -1022,10 +1063,27 @@ async function runConflictResolution(job, opts) {
1022
1063
  // there were conflicts), so it would drift onto the original feature task. Lead with the
1023
1064
  // conflict; keep the task only as trailing reference.
1024
1065
  const conflicted = await unmergedPaths(dir, signal);
1066
+ // DEPENDENCY PREPOPULATION, before this mode's agent turn. Resolving a conflict is a READING
1067
+ // task before it is a writing one — the agent has to understand what both sides do — so it
1068
+ // needs the dependency tree as much as any other kind. Placed AFTER the clean-merge branches
1069
+ // above so a conflict-free run (the common case) never pays for an install it has no agent to
1070
+ // hand the tree to; and the artifact exclusion inside matters here more than anywhere, because
1071
+ // this flow finishes its merge commit with a whole-tree `git add -A`.
1072
+ const workDir = await deriveWorkDir(dir, job.repo.serviceDirectory);
1073
+ const dependencyNote = await prepopulateDependencies({
1074
+ spec: job.dependencyInstall,
1075
+ installDir: workDir,
1076
+ repoDir: dir,
1077
+ // The agent resolves at the repo ROOT (git's conflict state is repo-wide), so a monorepo
1078
+ // service's install ran somewhere the agent is not standing and the note has to say where.
1079
+ agentDir: dir,
1080
+ logger,
1081
+ opts,
1082
+ });
1025
1083
  opts.onPhase?.('agent');
1026
1084
  logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
1027
1085
  const diff = await conflictDiff(dir, conflicted, signal);
1028
- const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
1086
+ const userPrompt = withDependencyNote(buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt), dependencyNote);
1029
1087
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
1030
1088
  dir,
1031
1089
  systemPrompt: job.systemPrompt,
@@ -103,6 +103,22 @@ export async function runCapturedCommand(args) {
103
103
  child.on('close', (code) => finish(code ?? 1));
104
104
  });
105
105
  }
106
+ /**
107
+ * Wrap captured command output in a fenced block that the output itself cannot break out of.
108
+ *
109
+ * Every consumer of a captured tail embeds it in markdown a MODEL then reads — a repair prompt,
110
+ * the dependency-install note — and a package manager legitimately prints backticks (a linter
111
+ * quoting a template literal, a test echoing a fenced snippet from a fixture). A fixed three-tick
112
+ * fence closes on the first such run, and everything after it reads as prose: the remaining
113
+ * output, and worse, the INSTRUCTIONS that follow the block. Sizing the fence one tick longer than
114
+ * the longest run in the body is what CommonMark specifies for exactly this, so the block always
115
+ * spans the whole tail.
116
+ */
117
+ export function fencedOutput(text) {
118
+ const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
119
+ const fence = '`'.repeat(Math.max(3, longestRun + 1));
120
+ return `${fence}\n${text}\n${fence}`;
121
+ }
106
122
  /** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
107
123
  export function boundTail(scrubbed, maxChars) {
108
124
  if (scrubbed.length <= maxChars)
@@ -10,6 +10,7 @@ import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorksp
10
10
  import { log } from './logger.js';
11
11
  import { runValidationLoop, } from './validation-checks.js';
12
12
  import { runReproductionLoop, } from './reproduction-proof.js';
13
+ import { prepopulateDependencies, withDependencyNote, } from './dependency-install.js';
13
14
  /**
14
15
  * How often the harness checkpoints the agent's work mid-run by pushing the branch.
15
16
  * A per-run container can be evicted at any moment; pushing the agent's commits
@@ -133,13 +134,32 @@ export async function runCodingAgent(spec, opts = {}) {
133
134
  }, followUpPollIntervalMs());
134
135
  followUpTick.unref?.();
135
136
  }
137
+ // DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
138
+ // agent's first turn, so it reads real packages instead of inferring capabilities from a
139
+ // manifest. Runs in `workDir` (a monorepo service installs from its own subtree, exactly
140
+ // where its manifest and lockfile live), and its outcome is STATED to the agent either way —
141
+ // a silent absence of dependencies reads to an agent as "this environment is offline".
142
+ // Best-effort by construction: a failed install never fails the run. Keyed purely off the
143
+ // job body (no agent-kind switch); absent ⇒ this is a no-op.
144
+ const dependencyNote = await prepopulateDependencies({
145
+ spec: spec.dependencyInstall,
146
+ installDir: workDir,
147
+ repoDir: dir,
148
+ agentDir: workDir,
149
+ logger,
150
+ opts,
151
+ });
136
152
  // One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
137
153
  // validation loop below can re-run the agent with a repair instruction without
138
154
  // re-deriving (or drifting from) the dispatch's own settings.
155
+ //
156
+ // The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
157
+ // agent, and one that is not told the tree is already installed spends the round it was
158
+ // given to fix something reinstalling it instead.
139
159
  const runAgentPass = (userPrompt) => runAgentInWorkspace({
140
160
  dir: workDir,
141
161
  systemPrompt: spec.systemPrompt,
142
- userPrompt,
162
+ userPrompt: withDependencyNote(userPrompt, dependencyNote),
143
163
  model: spec.model,
144
164
  harness: spec.harness,
145
165
  subscriptionToken: spec.subscriptionToken,
@@ -702,6 +722,28 @@ export async function runMultiRepoCoding(job, opts = {}) {
702
722
  // Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
703
723
  // reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
704
724
  await prepareMultiRepoCheckouts(root, legs, job, logger, opts);
725
+ // DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
726
+ // does it. The install is declared on ONE service frame (the primary repo's), so it runs in
727
+ // that leg's checkout and is never fanned out across peers, whose own frames declare configs
728
+ // this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
729
+ // degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
730
+ // the same reason a cross-repo investigator does; the note names the sibling directory
731
+ // because the agent itself stands at the workspace root.
732
+ //
733
+ // At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
734
+ // service-directory scoping anywhere (the agent runs at the root and the prompt explains the
735
+ // sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
736
+ const primaryLeg = legs.find((leg) => leg.primary);
737
+ const dependencyNote = primaryLeg
738
+ ? await prepopulateDependencies({
739
+ spec: job.dependencyInstall,
740
+ installDir: primaryLeg.dir,
741
+ repoDir: primaryLeg.dir,
742
+ agentDir: root,
743
+ logger,
744
+ opts,
745
+ })
746
+ : undefined;
705
747
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
706
748
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
707
749
  // note + the backend system-prompt section explain the layout.
@@ -710,7 +752,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
710
752
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
711
753
  dir: root,
712
754
  systemPrompt: job.systemPrompt,
713
- userPrompt: job.userPrompt,
755
+ userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
714
756
  model: job.model,
715
757
  harness: job.harness,
716
758
  subscriptionToken: job.subscriptionToken,