@cat-factory/executor-harness 1.74.0 → 1.76.2

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.
@@ -24,6 +24,7 @@ import {
24
24
  } from './agent-capabilities.js'
25
25
  import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
26
26
  import { killChildProcess, spawnDetached } from './process.js'
27
+ import { describeProcessExit } from './process-exit.js'
27
28
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
28
29
  import { createSliceTracker, startSubagentWatcher } from './subagents.js'
29
30
  import {
@@ -246,7 +247,7 @@ function streamCli(
246
247
  opts.signal?.removeEventListener('abort', onAbort)
247
248
  reject(err)
248
249
  })
249
- child.on('close', (code) => {
250
+ child.on('close', (code, signal) => {
250
251
  opts.signal?.removeEventListener('abort', onAbort)
251
252
  const stderrTail = redact(stderr, secrets).slice(-700)
252
253
  if (lineBuffer.trim()) processLine(lineBuffer.trim(), true)
@@ -258,7 +259,7 @@ function streamCli(
258
259
  return
259
260
  }
260
261
  if (code !== 0) {
261
- reject(new Error(`${command} exited with code ${code}: ${stderrTail}`))
262
+ reject(new CliExitFailure({ command, exitCode: code, signal, stderrTail }))
262
263
  return
263
264
  }
264
265
  resolve({ stderrTail })
@@ -266,6 +267,100 @@ function streamCli(
266
267
  })
267
268
  }
268
269
 
270
+ /**
271
+ * A CLI subprocess that ended badly — it exited non-zero, or a signal killed it.
272
+ *
273
+ * Its own class (rather than a formatted string) because the message is not final at throw time:
274
+ * the caller folds in the CLI's terminal report before it surfaces (see {@link withAgentReport}),
275
+ * and rebuilding from parts beats patching a rendered sentence. Distinct from the watchdog-abort
276
+ * rejection above, which owns its own diagnostic and must keep it.
277
+ */
278
+ class CliExitFailure extends Error {
279
+ readonly parts: CliExit
280
+ /**
281
+ * Also exposed flat, matching the watchdog-abort rejection's shape: the guard-trip branch
282
+ * reads `stderrTail` off whatever it caught to append to its own replacement message.
283
+ */
284
+ readonly stderrTail: string
285
+ constructor(parts: CliExit, report = '') {
286
+ super(cliExitMessage(parts, report))
287
+ this.name = 'CliExitFailure'
288
+ this.parts = parts
289
+ this.stderrTail = parts.stderrTail
290
+ }
291
+ }
292
+
293
+ interface CliExit {
294
+ command: string
295
+ /** The exit code, or `null` when a signal killed the CLI instead. */
296
+ exitCode: number | null
297
+ signal: NodeJS.Signals | null
298
+ stderrTail: string
299
+ }
300
+
301
+ /**
302
+ * One message shape for a CLI that ended badly, with or without a report to add.
303
+ *
304
+ * How it ended is rendered through {@link describeProcessExit}, the shared vocabulary every
305
+ * process-reporting transport uses, so an externally-killed container job (an OOM kill, a
306
+ * `docker stop` racing teardown) reads differently from the CLI's own failure exit.
307
+ */
308
+ function cliExitMessage(exit: CliExit, report: string): string {
309
+ const how = describeProcessExit(exit.exitCode, exit.signal)
310
+ const suffix = report ? ` Agent's last report: ${report}` : ''
311
+ return `${exit.command} ${how}: ${exit.stderrTail || '(no stderr output)'}${suffix}`
312
+ }
313
+
314
+ /**
315
+ * Re-throw a bad CLI exit with the CLI's own account of how the run ended folded in.
316
+ *
317
+ * Both agent CLIs report a terminal failure on STDOUT, inside their event stream (Claude Code's
318
+ * `result` event, Codex's last agent message) — never on stderr. So a run the upstream API kept
319
+ * refusing exits non-zero with an EMPTY stderr tail, and the harness surfaces `claude exited with
320
+ * code 1:` and nothing else, while the reason it collected sits in a local variable only the
321
+ * SUCCESS path returns. That failure is indistinguishable from a crash, and the operator has no
322
+ * next step. Nothing else in the run records it: the CLI's session transcript dies with the
323
+ * per-run config home, and a local-mode container is removed the moment the job settles.
324
+ *
325
+ * Anything that is not a bad-exit rejection passes through untouched — a watchdog abort and a
326
+ * tripped progress guard carry more specific diagnostics already.
327
+ */
328
+ function withAgentReport(err: unknown, report: string, secrets: string[]): unknown {
329
+ if (!(err instanceof CliExitFailure)) return err
330
+ const folded = capReport(redact(report, secrets).trim())
331
+ return folded ? new CliExitFailure(err.parts, folded) : err
332
+ }
333
+
334
+ /** How much of the agent's terminal report the failure message carries. */
335
+ const MAX_AGENT_REPORT_CHARS = 700
336
+
337
+ /**
338
+ * Bound the agent's terminal report, keeping its HEAD — the opposite bias from the stderr tail
339
+ * beside it, and deliberately so. A stderr tail is a log: the cause is whatever it ended on. A
340
+ * report is a written statement, and its opening is where the answer lives — the failure
341
+ * `subtype` {@link claudeResultReport} prepends, or the first line of Codex's last agent message.
342
+ * Tail-slicing it drops exactly the classification the fold exists to surface.
343
+ *
344
+ * A cut is marked, because a report that merely stops reads like an agent that trailed off.
345
+ */
346
+ function capReport(report: string): string {
347
+ if (report.length <= MAX_AGENT_REPORT_CHARS) return report
348
+ return `${report.slice(0, MAX_AGENT_REPORT_CHARS)}… (report truncated)`
349
+ }
350
+
351
+ /**
352
+ * The CLI's own account of how the run ended, read off its terminal `result` event: the failure
353
+ * `subtype` it names (`error_during_execution`, `error_max_turns`, …) joined to whatever text it
354
+ * printed. A headless `-p` run reports an upstream API refusal HERE — on stdout, as JSON — and
355
+ * nowhere else, so this is what a bad exit has to carry. A clean result yields just its text.
356
+ */
357
+ function claudeResultReport(event: Record<string, unknown>): string {
358
+ const subtype = typeof event.subtype === 'string' ? event.subtype : ''
359
+ const text = typeof event.result === 'string' ? event.result.trim() : ''
360
+ const failed = event.is_error === true || (subtype !== '' && subtype !== 'success')
361
+ return failed ? [subtype || 'error', text].filter(Boolean).join(': ') : text
362
+ }
363
+
269
364
  /**
270
365
  * Fold a composed system prompt into the task prompt so the role + best-practice context
271
366
  * rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
@@ -389,6 +484,8 @@ async function setUpClaudeMcp(
389
484
  export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
390
485
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
391
486
  let summary = ''
487
+ /** The CLI's own account of how the run ended — see {@link claudeResultReport}. */
488
+ let terminalReport = ''
392
489
  let usage: { inputTokens: number; outputTokens: number } | undefined
393
490
 
394
491
  // Decide how the composed system prompt is carried up front, so the telemetry seed below
@@ -537,54 +634,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
537
634
  } else if (type === 'result') {
538
635
  if (typeof event.result === 'string') summary = event.result
539
636
  usage = claudeUsage(event.usage) ?? usage
637
+ terminalReport = claudeResultReport(event) || terminalReport
540
638
  }
541
639
  }
542
640
 
543
- // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
544
- // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
545
- // Claude Code persists user config/credentials under its config dir; point that at an
546
- // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
547
- // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
548
- // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
549
- // Mirrors the Codex CODEX_HOME isolation below; removed in `finally`.
550
- if (!opts.ambientAuth && !opts.subscriptionToken) {
551
- throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
552
- }
553
- const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
554
-
555
- // The config dir is brand-new every run, so Claude Code would otherwise treat this
556
- // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
557
- // bypass-permissions acknowledgement prompts — which never get answered headlessly,
558
- // hanging the job until the watchdog kills it. Pre-seed the config that marks those
559
- // as already accepted so `-p` starts straight into the run. Best-effort: written
560
- // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
561
- // (Ambient mode skips this — the developer's own config is already onboarded.)
562
- // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
563
- // version, so a future first-run gate this set doesn't cover (which looks identical to
564
- // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
565
- if (configHome) {
566
- await writeOnboardingPreseed(configHome)
567
- await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
568
- }
569
-
570
- // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
571
- // discovers and can invoke it. ONLY into the isolated per-run config home — never the
572
- // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
573
- // setup after the run and two concurrent jobs carrying same-named skills would clobber each
574
- // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
575
- // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
576
- // still names the skills.
577
- if (configHome) {
578
- for (const skill of opts.skills ?? []) {
579
- await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
580
- }
581
- }
582
-
583
- // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
584
- // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
585
- const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
586
-
587
- const env = buildClaudeEnv(opts, configHome)
641
+ const home = await openClaudeRunHome(opts)
642
+ const { configHome } = home
588
643
 
589
644
  // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
590
645
  // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
@@ -627,13 +682,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
627
682
  'bypassPermissions',
628
683
  '--model',
629
684
  opts.model,
630
- ...mcp.args,
685
+ ...home.mcpArgs,
631
686
  ...appendArgs,
632
687
  ],
633
688
  },
634
689
  prompt,
635
690
  { ...opts, signal: runSignal },
636
- env,
691
+ home.env,
637
692
  opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
638
693
  onEvent,
639
694
  )
@@ -659,19 +714,105 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
659
714
  telemetry.flush()
660
715
  publisher.flush()
661
716
  // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
662
- // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
663
- // it attached, since that is usually the only evidence of what the CLI was doing when it was
664
- // killed. Byte-for-byte the shape `runPi` fails with.
717
+ // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail it
718
+ // attached, since that is usually the only evidence of what the CLI was doing when it was
719
+ // killed. The leading clauses are byte-for-byte the shape `runPi` fails with; a terminal
720
+ // report is appended after them when the CLI managed to emit one before it was killed, which
721
+ // is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
722
+ // discard it.
665
723
  if (guardReason) {
666
724
  const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
667
- throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason)
725
+ const report = capReport(redact(terminalReport, secrets).trim())
726
+ throw new Error(
727
+ [
728
+ guardReason,
729
+ tail ? `Agent stderr: ${tail}` : '',
730
+ report ? `Agent's last report: ${report}` : '',
731
+ ]
732
+ .filter(Boolean)
733
+ .join(' '),
734
+ )
668
735
  }
669
- throw err
736
+ throw withAgentReport(err, terminalReport, secrets)
670
737
  } finally {
671
738
  await subagents?.stop()
672
- // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
673
- await mcp.cleanup()
674
- if (configHome) {
739
+ await home.dispose()
740
+ }
741
+ }
742
+
743
+ /**
744
+ * The isolated, per-run home the `claude` CLI runs against: a temp config dir OUTSIDE the cloned
745
+ * checkout, pre-seeded past the first-launch prompts, carrying the run's native skills and MCP
746
+ * config, plus the child env pointing the CLI at it. {@link ClaudeRunHome.dispose} is the other
747
+ * half of the same concern — the leased credential must never outlive the run — so acquisition
748
+ * and teardown are defined together rather than split across a `finally` forty lines away.
749
+ *
750
+ * Ambient (native) mode has NO home: the developer's installed CLI uses its own `~/.claude`
751
+ * login, so nothing is created, nothing is pre-seeded, and `dispose` only clears the MCP config.
752
+ */
753
+ interface ClaudeRunHome {
754
+ /** The per-run config dir; `undefined` in ambient mode (the developer's own login is used). */
755
+ configHome: string | undefined
756
+ /** The CLI argv selecting the run's tool servers; empty when it has none. */
757
+ mcpArgs: string[]
758
+ /** The child-process env (see {@link buildClaudeEnv}). */
759
+ env: Record<string, string>
760
+ dispose: () => Promise<void>
761
+ }
762
+
763
+ async function openClaudeRunHome(opts: SubscriptionRunOptions): Promise<ClaudeRunHome> {
764
+ // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
765
+ // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
766
+ // Claude Code persists user config/credentials under its config dir; point that at an
767
+ // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
768
+ // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
769
+ // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
770
+ // Mirrors the Codex CODEX_HOME isolation below; removed by `dispose`.
771
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
772
+ throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
773
+ }
774
+ const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
775
+
776
+ // The config dir is brand-new every run, so Claude Code would otherwise treat this
777
+ // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
778
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
779
+ // hanging the job until the watchdog kills it. Pre-seed the config that marks those
780
+ // as already accepted so `-p` starts straight into the run. Best-effort: written
781
+ // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
782
+ // (Ambient mode skips this — the developer's own config is already onboarded.)
783
+ // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
784
+ // version, so a future first-run gate this set doesn't cover (which looks identical to
785
+ // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
786
+ if (configHome) {
787
+ await writeOnboardingPreseed(configHome)
788
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
789
+ }
790
+
791
+ // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
792
+ // discovers and can invoke it. ONLY into the isolated per-run config home — never the
793
+ // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
794
+ // setup after the run and two concurrent jobs carrying same-named skills would clobber each
795
+ // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
796
+ // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
797
+ // still names the skills.
798
+ if (configHome) {
799
+ for (const skill of opts.skills ?? []) {
800
+ await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
801
+ }
802
+ }
803
+
804
+ // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
805
+ // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
806
+ const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
807
+
808
+ return {
809
+ configHome,
810
+ mcpArgs: mcp.args,
811
+ env: buildClaudeEnv(opts, configHome),
812
+ dispose: async () => {
813
+ // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
814
+ await mcp.cleanup()
815
+ if (!configHome) return
675
816
  // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
676
817
  // home is deleted — the credential lives at the home root, never in `projects/`, so this
677
818
  // keeps the debugging artifact without leaking the token. Best-effort; never throws.
@@ -681,7 +822,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
681
822
  })
682
823
  // Never leave the config dir (and any cached credential) on disk past the run.
683
824
  await rm(configHome, { recursive: true, force: true }).catch(() => {})
684
- }
825
+ },
685
826
  }
686
827
  }
687
828
 
@@ -975,6 +1116,10 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
975
1116
  ...(usage ? { usage } : {}),
976
1117
  ...(calls.length ? { callMetrics: calls } : {}),
977
1118
  }
1119
+ } catch (err) {
1120
+ // Codex surfaces its terminal failure the same way Claude Code does — in the stdout event
1121
+ // stream, not on stderr — so a bad exit carries the last thing the agent said.
1122
+ throw withAgentReport(err, summary, secrets)
978
1123
  } finally {
979
1124
  if (codexHome) {
980
1125
  // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
package/src/agent.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  runMultiRepoCoding,
37
37
  } from './coding-agent.js'
38
38
  import { validationFailureMessage } from './validation-checks.js'
39
+ import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
39
40
  import { agentCapabilities, mergeEffort } from './agent-shared.js'
40
41
  import { runBootstrap } from './bootstrap-mode.js'
41
42
  import {
@@ -541,6 +542,25 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
541
542
  logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched })
542
543
  }
543
544
 
545
+ // DEPENDENCY PREPOPULATION, before the agent's first turn. An EXPLORE run is the case this
546
+ // exists for: a reviewer or architect reading a fresh clone can see that a library is
547
+ // depended upon but not what it actually exposes, so it reasons about the manifest instead
548
+ // of the code. Best-effort — the outcome is stated in the prompt either way and never fails
549
+ // the run. Runs in `workDir` so a monorepo service installs from its own subtree.
550
+ //
551
+ // BEFORE the stand-up below, deliberately. The frontend stand-up runs the service's own
552
+ // install and then SERVES what it built: installing after it would pay for a second install
553
+ // and, worse, rewrite the `node_modules` the running app resolves out of. Prepopulation is
554
+ // setup for everything that follows, so it goes first.
555
+ const dependencyNote = await prepopulateDependencies({
556
+ spec: job.dependencyInstall,
557
+ installDir: workDir,
558
+ repoDir: dir,
559
+ agentDir: workDir,
560
+ logger,
561
+ opts,
562
+ })
563
+
544
564
  // Optional infra stand-up (the tester): bring the service's docker-compose
545
565
  // dependencies up at the repo root for the duration of the run, tearing them down in
546
566
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
@@ -553,9 +573,10 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
553
573
  // failure) is flagged as a concern; a frontend serve URL points the UI tester at the
554
574
  // app it just built + served (the backend env resolution already reached the harness).
555
575
  const infraNotes = managed ? buildInfraNotes(managed) : []
556
- const userPrompt = infraNotes.length
557
- ? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}`
558
- : job.userPrompt
576
+ const userPrompt = withDependencyNote(
577
+ infraNotes.length ? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}` : job.userPrompt,
578
+ dependencyNote,
579
+ )
559
580
  // The stand-up record (success or failure, with its captured logs) rides back on EVERY
560
581
  // result branch — the backend surfaces it on the Tester step regardless of whether the
561
582
  // agent then produced a usable report.
@@ -809,13 +830,34 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
809
830
  }
810
831
  }
811
832
 
833
+ // DEPENDENCY PREPOPULATION for the PRIMARY leg. The install is declared on ONE service frame
834
+ // (the primary repo's), so it is run in that leg's checkout — never fanned out across the
835
+ // peers, whose services declare their own configs the dispatch never resolved. The agent runs
836
+ // at the workspace ROOT and reads across every sibling, which is exactly why this matters
837
+ // here: a cross-repo investigator reasoning about a manifest instead of the packages is the
838
+ // complaint that motivated the feature. Same treatment as the reference branches above.
839
+ //
840
+ // The note names the sibling directory rather than saying "this checkout": the agent's cwd is
841
+ // the workspace root, which has no dependency tree of its own.
842
+ const primaryLeg = legs[0]
843
+ const dependencyNote = primaryLeg
844
+ ? await prepopulateDependencies({
845
+ spec: job.dependencyInstall,
846
+ installDir: join(root, primaryLeg.dirName),
847
+ repoDir: join(root, primaryLeg.dirName),
848
+ agentDir: root,
849
+ logger,
850
+ opts,
851
+ })
852
+ : undefined
853
+
812
854
  opts.onPhase?.('agent')
813
855
  logger.info('multi-repo-explore: running agent', { repos: legs.map((l) => l.dirName) })
814
856
  const run = await runAgentInWorkspace(
815
857
  {
816
858
  dir: root,
817
859
  systemPrompt: job.systemPrompt,
818
- userPrompt: job.userPrompt,
860
+ userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
819
861
  model: job.model,
820
862
  harness: job.harness,
821
863
  subscriptionToken: job.subscriptionToken,
@@ -972,6 +1014,10 @@ function buildSingleRepoCodingSpec(
972
1014
  // (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
973
1015
  // like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
974
1016
  ...(job.reproduction ? { reproduction: job.reproduction } : {}),
1017
+ // Dependency prepopulation: the service's install, run against the checkout BEFORE the
1018
+ // agent's first turn (see docs/initiatives/agent-dependency-prepopulation.md). Forwarded
1019
+ // straight off the job body like the two phases above — generic machinery keyed on the data.
1020
+ ...(job.dependencyInstall ? { dependencyInstall: job.dependencyInstall } : {}),
975
1021
  }
976
1022
  }
977
1023
 
@@ -1204,10 +1250,32 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
1204
1250
  // there were conflicts), so it would drift onto the original feature task. Lead with the
1205
1251
  // conflict; keep the task only as trailing reference.
1206
1252
  const conflicted = await unmergedPaths(dir, signal)
1253
+
1254
+ // DEPENDENCY PREPOPULATION, before this mode's agent turn. Resolving a conflict is a READING
1255
+ // task before it is a writing one — the agent has to understand what both sides do — so it
1256
+ // needs the dependency tree as much as any other kind. Placed AFTER the clean-merge branches
1257
+ // above so a conflict-free run (the common case) never pays for an install it has no agent to
1258
+ // hand the tree to; and the artifact exclusion inside matters here more than anywhere, because
1259
+ // this flow finishes its merge commit with a whole-tree `git add -A`.
1260
+ const workDir = await deriveWorkDir(dir, job.repo.serviceDirectory)
1261
+ const dependencyNote = await prepopulateDependencies({
1262
+ spec: job.dependencyInstall,
1263
+ installDir: workDir,
1264
+ repoDir: dir,
1265
+ // The agent resolves at the repo ROOT (git's conflict state is repo-wide), so a monorepo
1266
+ // service's install ran somewhere the agent is not standing and the note has to say where.
1267
+ agentDir: dir,
1268
+ logger,
1269
+ opts,
1270
+ })
1271
+
1207
1272
  opts.onPhase?.('agent')
1208
1273
  logger.info('agent(conflict): resolving conflicts with agent', { conflicted })
1209
1274
  const diff = await conflictDiff(dir, conflicted, signal)
1210
- const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt)
1275
+ const userPrompt = withDependencyNote(
1276
+ buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt),
1277
+ dependencyNote,
1278
+ )
1211
1279
 
1212
1280
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
1213
1281
  await runAgentInWorkspace(
@@ -136,6 +136,23 @@ export async function runCapturedCommand(args: {
136
136
  })
137
137
  }
138
138
 
139
+ /**
140
+ * Wrap captured command output in a fenced block that the output itself cannot break out of.
141
+ *
142
+ * Every consumer of a captured tail embeds it in markdown a MODEL then reads — a repair prompt,
143
+ * the dependency-install note — and a package manager legitimately prints backticks (a linter
144
+ * quoting a template literal, a test echoing a fenced snippet from a fixture). A fixed three-tick
145
+ * fence closes on the first such run, and everything after it reads as prose: the remaining
146
+ * output, and worse, the INSTRUCTIONS that follow the block. Sizing the fence one tick longer than
147
+ * the longest run in the body is what CommonMark specifies for exactly this, so the block always
148
+ * spans the whole tail.
149
+ */
150
+ export function fencedOutput(text: string): string {
151
+ const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length))
152
+ const fence = '`'.repeat(Math.max(3, longestRun + 1))
153
+ return `${fence}\n${text}\n${fence}`
154
+ }
155
+
139
156
  /** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
140
157
  export function boundTail(scrubbed: string, maxChars: number): string {
141
158
  if (scrubbed.length <= maxChars) return scrubbed
@@ -58,6 +58,11 @@ import {
58
58
  type ReproductionReport,
59
59
  type ReproductionSpec,
60
60
  } from './reproduction-proof.js'
61
+ import {
62
+ prepopulateDependencies,
63
+ withDependencyNote,
64
+ type DependencyInstallSpec,
65
+ } from './dependency-install.js'
61
66
 
62
67
  // The shared skeleton for the container coding agents that clone a repo, run Pi
63
68
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -130,6 +135,13 @@ export interface CodingAgentSpec extends HarnessAuthFields {
130
135
  * `docs/initiatives/pre-pr-validation.md`.
131
136
  */
132
137
  validationChecks?: ValidationChecksSpec
138
+ /**
139
+ * DEPENDENCY PREPOPULATION: the service's install command, run against the checkout BEFORE the
140
+ * agent's first turn so it works against a tree whose dependencies are present. Best-effort —
141
+ * a failure becomes a note in the agent's prompt, never a failed run. Absent ⇒ no install
142
+ * phase. See `docs/initiatives/agent-dependency-prepopulation.md`.
143
+ */
144
+ dependencyInstall?: DependencyInstallSpec
133
145
  /**
134
146
  * BUGFIX REPRODUCTION PROOF: the run's declared reproduction command + test files. When set, the
135
147
  * harness runs that command against the pre-fix tree AND the tree the PR will open from, feeding
@@ -337,9 +349,29 @@ export async function runCodingAgent(
337
349
  followUpTick.unref?.()
338
350
  }
339
351
 
352
+ // DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
353
+ // agent's first turn, so it reads real packages instead of inferring capabilities from a
354
+ // manifest. Runs in `workDir` (a monorepo service installs from its own subtree, exactly
355
+ // where its manifest and lockfile live), and its outcome is STATED to the agent either way —
356
+ // a silent absence of dependencies reads to an agent as "this environment is offline".
357
+ // Best-effort by construction: a failed install never fails the run. Keyed purely off the
358
+ // job body (no agent-kind switch); absent ⇒ this is a no-op.
359
+ const dependencyNote = await prepopulateDependencies({
360
+ spec: spec.dependencyInstall,
361
+ installDir: workDir,
362
+ repoDir: dir,
363
+ agentDir: workDir,
364
+ logger,
365
+ opts,
366
+ })
367
+
340
368
  // One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
341
369
  // validation loop below can re-run the agent with a repair instruction without
342
370
  // re-deriving (or drifting from) the dispatch's own settings.
371
+ //
372
+ // The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
373
+ // agent, and one that is not told the tree is already installed spends the round it was
374
+ // given to fix something reinstalling it instead.
343
375
  const runAgentPass = (
344
376
  userPrompt: string,
345
377
  ): Promise<Awaited<ReturnType<typeof runAgentInWorkspace>>> =>
@@ -347,7 +379,7 @@ export async function runCodingAgent(
347
379
  {
348
380
  dir: workDir,
349
381
  systemPrompt: spec.systemPrompt,
350
- userPrompt,
382
+ userPrompt: withDependencyNote(userPrompt, dependencyNote),
351
383
  model: spec.model,
352
384
  harness: spec.harness,
353
385
  subscriptionToken: spec.subscriptionToken,
@@ -1038,6 +1070,29 @@ export async function runMultiRepoCoding(
1038
1070
  // reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
1039
1071
  await prepareMultiRepoCheckouts(root, legs, job, logger, opts)
1040
1072
 
1073
+ // DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
1074
+ // does it. The install is declared on ONE service frame (the primary repo's), so it runs in
1075
+ // that leg's checkout and is never fanned out across peers, whose own frames declare configs
1076
+ // this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
1077
+ // degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
1078
+ // the same reason a cross-repo investigator does; the note names the sibling directory
1079
+ // because the agent itself stands at the workspace root.
1080
+ //
1081
+ // At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
1082
+ // service-directory scoping anywhere (the agent runs at the root and the prompt explains the
1083
+ // sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
1084
+ const primaryLeg = legs.find((leg) => leg.primary)
1085
+ const dependencyNote = primaryLeg
1086
+ ? await prepopulateDependencies({
1087
+ spec: job.dependencyInstall,
1088
+ installDir: primaryLeg.dir,
1089
+ repoDir: primaryLeg.dir,
1090
+ agentDir: root,
1091
+ logger,
1092
+ opts,
1093
+ })
1094
+ : undefined
1095
+
1041
1096
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
1042
1097
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
1043
1098
  // note + the backend system-prompt section explain the layout.
@@ -1048,7 +1103,7 @@ export async function runMultiRepoCoding(
1048
1103
  {
1049
1104
  dir: root,
1050
1105
  systemPrompt: job.systemPrompt,
1051
- userPrompt: job.userPrompt,
1106
+ userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
1052
1107
  model: job.model,
1053
1108
  harness: job.harness,
1054
1109
  subscriptionToken: job.subscriptionToken,