@cat-factory/executor-harness 1.132.3 → 1.135.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.
Files changed (67) hide show
  1. package/README.md +49 -0
  2. package/dist/agent-capabilities.d.ts +21 -24
  3. package/dist/agent-capabilities.js +22 -50
  4. package/dist/agent-env.d.ts +17 -0
  5. package/dist/agent-env.js +47 -0
  6. package/dist/agent-runner.d.ts +18 -2
  7. package/dist/agent-runner.js +29 -231
  8. package/dist/agent-shared.d.ts +14 -5
  9. package/dist/agent-shared.js +14 -5
  10. package/dist/agent.d.ts +0 -11
  11. package/dist/agent.js +7 -138
  12. package/dist/captured-command.d.ts +1 -1
  13. package/dist/captured-command.js +3 -2
  14. package/dist/claude-cli.d.ts +90 -0
  15. package/dist/claude-cli.js +181 -0
  16. package/dist/claude-home.d.ts +41 -0
  17. package/dist/claude-home.js +159 -0
  18. package/dist/coding-agent.d.ts +35 -0
  19. package/dist/coding-agent.js +213 -41
  20. package/dist/docker-status.d.ts +89 -0
  21. package/dist/docker-status.js +147 -0
  22. package/dist/frontend-infra.js +4 -3
  23. package/dist/git.d.ts +48 -5
  24. package/dist/git.js +93 -26
  25. package/dist/guard-driver.d.ts +71 -0
  26. package/dist/guard-driver.js +171 -0
  27. package/dist/harness-server.js +13 -0
  28. package/dist/infra-standup.d.ts +69 -0
  29. package/dist/infra-standup.js +182 -0
  30. package/dist/job.d.ts +10 -0
  31. package/dist/multi-repo-coding.d.ts +17 -0
  32. package/dist/multi-repo-coding.js +61 -16
  33. package/dist/pi-workspace.d.ts +11 -0
  34. package/dist/pi-workspace.js +126 -57
  35. package/dist/pi.d.ts +8 -0
  36. package/dist/pi.js +16 -9
  37. package/dist/progress-guard.d.ts +56 -10
  38. package/dist/progress-guard.js +84 -22
  39. package/dist/runner.d.ts +1 -1
  40. package/dist/salvage.d.ts +180 -0
  41. package/dist/salvage.js +289 -0
  42. package/dist/workspace-probe.d.ts +85 -0
  43. package/dist/workspace-probe.js +124 -0
  44. package/package.json +4 -4
  45. package/src/agent-capabilities.ts +25 -51
  46. package/src/agent-env.ts +49 -0
  47. package/src/agent-runner.ts +40 -267
  48. package/src/agent-shared.ts +16 -5
  49. package/src/agent.ts +7 -164
  50. package/src/captured-command.ts +3 -2
  51. package/src/claude-cli.ts +217 -0
  52. package/src/claude-home.ts +233 -0
  53. package/src/coding-agent.ts +252 -44
  54. package/src/docker-status.ts +201 -0
  55. package/src/frontend-infra.ts +4 -3
  56. package/src/git.ts +104 -26
  57. package/src/guard-driver.ts +203 -0
  58. package/src/harness-server.ts +13 -0
  59. package/src/infra-standup.ts +218 -0
  60. package/src/job.ts +10 -0
  61. package/src/multi-repo-coding.ts +65 -16
  62. package/src/pi-workspace.ts +161 -57
  63. package/src/pi.ts +27 -12
  64. package/src/progress-guard.ts +110 -34
  65. package/src/runner.ts +1 -1
  66. package/src/salvage.ts +407 -0
  67. package/src/workspace-probe.ts +155 -0
@@ -0,0 +1,218 @@
1
+ // The run's infra stand-up: the docker-compose dependencies a local-mode service declares, and
2
+ // the frontend build/serve + WireMock flow the UI-test runs use instead. Split out of agent.ts,
3
+ // which owns the agent MODES; this owns what surrounds a mode with the dependencies it needs and
4
+ // guarantees the matching teardown. `manageInfra` is the one entry point a mode calls.
5
+
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import type { AgentInfraSpec, InfraSetupRecord, ServiceInfraSpec } from './job.js'
9
+ import {
10
+ type DockerProbe,
11
+ probeDockerServing,
12
+ readDockerStatus,
13
+ resolveDockerVerdict,
14
+ } from './docker-status.js'
15
+ import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
16
+ import { captureRedactedOutput, redactSecrets } from './redact.js'
17
+ import type { RunOptions } from './runner.js'
18
+ import type { Logger } from './logger.js'
19
+
20
+ const exec = promisify(execFile)
21
+
22
+ /**
23
+ * Bring the service's docker-compose dependencies up (local infra only). Best-effort:
24
+ * runs `docker compose -f <path> up -d --wait` in the checkout. A compose failure is logged
25
+ * and surfaced to the agent (as a prompt note) rather than failing the job — the agent can
26
+ * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
27
+ * no-compose-path runs.
28
+ *
29
+ * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
30
+ * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
31
+ * talk to, so running compose against it would only turn a fact this container holds into a
32
+ * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
33
+ * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
34
+ * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
35
+ * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
36
+ * own value).
37
+ *
38
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
39
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not
40
+ * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
41
+ * state both answers on a machine that has its own daemon either way.
42
+ *
43
+ * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
44
+ * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
45
+ * the in-container dependency stand-up logs on the Tester step — the failure-class artifact
46
+ * the orchestrator-side provisioning logs can't see.
47
+ *
48
+ * Exported for the unit suite (like {@link buildInfraNotes}): the refusal branch is a decision
49
+ * this container makes about itself, and the acceptance suite can only exercise it on a machine
50
+ * where the daemon genuinely fails.
51
+ */
52
+ export async function standUpInfra(
53
+ dir: string,
54
+ infra: ServiceInfraSpec,
55
+ signal: AbortSignal | undefined,
56
+ logger: Logger,
57
+ probe: DockerProbe = probeDockerServing,
58
+ ): Promise<{ started: boolean; note?: string; record?: InfraSetupRecord }> {
59
+ if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
60
+ return { started: false }
61
+ }
62
+ const startedAt = Date.now()
63
+ const recorded = await readDockerStatus()
64
+ const docker = await resolveDockerVerdict(recorded, probe)
65
+ if (docker.refusal) {
66
+ const note = `the dependencies could not be started: ${docker.refusal}`
67
+ logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
68
+ composePath: infra.composePath,
69
+ dockerSource: recorded.source,
70
+ dockerReason: recorded.reason,
71
+ })
72
+ return {
73
+ started: false,
74
+ note,
75
+ record: {
76
+ started: false,
77
+ dockerAvailable: false,
78
+ composePath: infra.composePath,
79
+ at: Date.now(),
80
+ durationMs: Date.now() - startedAt,
81
+ error: redactSecrets(note),
82
+ },
83
+ }
84
+ }
85
+ try {
86
+ logger.info('agent(explore): standing up infra', { composePath: infra.composePath })
87
+ // Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
88
+ // (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
89
+ const { stdout, stderr } = await exec(
90
+ 'docker',
91
+ ['compose', '-f', infra.composePath, 'up', '-d', '--wait'],
92
+ { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 },
93
+ )
94
+ const logs = captureRedactedOutput(stdout, stderr)
95
+ return {
96
+ started: true,
97
+ record: {
98
+ started: true,
99
+ dockerAvailable: true,
100
+ composePath: infra.composePath,
101
+ at: Date.now(),
102
+ durationMs: Date.now() - startedAt,
103
+ ...(logs ? { logs } : {}),
104
+ },
105
+ }
106
+ } catch (err) {
107
+ const note = err instanceof Error ? err.message : String(err)
108
+ logger.warn('agent(explore): infra stand-up failed', { error: note })
109
+ // `execFile` rejections carry the partial stdout/stderr on the error object — capture them
110
+ // so the stored logs explain the failure (a port clash, a pull-auth error, an exited
111
+ // dependency), not just the one-line exit message.
112
+ const e = err as { stdout?: unknown; stderr?: unknown }
113
+ const logs = captureRedactedOutput(e.stdout, e.stderr)
114
+ return {
115
+ started: false,
116
+ note,
117
+ record: {
118
+ started: false,
119
+ // A compose failure with a REACHABLE daemon: the two `false`s above and here are
120
+ // different diagnoses (nothing to talk to vs the stack itself did not come up), and
121
+ // only stating both keeps the second from being read as the first. Read off the
122
+ // RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
123
+ // actually reached rather than the one its boot record still denies.
124
+ ...(docker.available === true ? { dockerAvailable: true } : {}),
125
+ composePath: infra.composePath,
126
+ at: Date.now(),
127
+ durationMs: Date.now() - startedAt,
128
+ error: redactSecrets(note),
129
+ ...(logs ? { logs } : {}),
130
+ },
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
137
+ * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
138
+ * processes (torn down by killing them); the default backend-service flow stands the
139
+ * docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
140
+ * `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
141
+ *
142
+ * `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
143
+ * monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
144
+ * repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
145
+ * `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
146
+ * building, serving and seeding WireMock from the root would target the wrong directory.
147
+ */
148
+ export async function manageInfra(
149
+ dir: string,
150
+ workDir: string,
151
+ infra: AgentInfraSpec,
152
+ opts: RunOptions,
153
+ logger: Logger,
154
+ ): Promise<{
155
+ note?: string
156
+ serveUrl?: string
157
+ record?: InfraSetupRecord
158
+ cleanup: () => Promise<void>
159
+ }> {
160
+ if (infra.kind === 'frontend') {
161
+ // `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
162
+ // which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
163
+ // Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
164
+ const fe = await standUpFrontend(workDir, infra, opts, logger)
165
+ return {
166
+ ...(fe.note ? { note: fe.note } : {}),
167
+ ...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
168
+ record: fe.record,
169
+ cleanup: () => tearDownFrontend(fe.processes, logger),
170
+ }
171
+ }
172
+ const standUp = await standUpInfra(dir, infra, opts.signal, logger)
173
+ return {
174
+ ...(standUp.note ? { note: standUp.note } : {}),
175
+ ...(standUp.record ? { record: standUp.record } : {}),
176
+ cleanup: () => tearDownInfra(dir, infra),
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
182
+ * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
183
+ * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
184
+ * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
185
+ * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
186
+ */
187
+ export function buildInfraNotes(managed: { note?: string; serveUrl?: string }): string[] {
188
+ const notes: string[] = []
189
+ if (managed.note) {
190
+ notes.push(
191
+ `standing the infra up reported a problem (${managed.note}). Test what you can and ` +
192
+ `flag any dependency-related gaps as concerns.`,
193
+ )
194
+ }
195
+ if (managed.serveUrl) {
196
+ notes.push(
197
+ `The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
198
+ `backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
199
+ `If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
200
+ `gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
201
+ `it as a concern rather than a failing test.`,
202
+ )
203
+ }
204
+ return notes
205
+ }
206
+
207
+ /** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
208
+ async function tearDownInfra(dir: string, infra: ServiceInfraSpec): Promise<void> {
209
+ if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) return
210
+ try {
211
+ await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
212
+ cwd: dir,
213
+ timeout: 2 * 60_000,
214
+ })
215
+ } catch {
216
+ // The container is ephemeral and torn down with the run anyway — ignore.
217
+ }
218
+ }
package/src/job.ts CHANGED
@@ -945,6 +945,16 @@ export interface GuardLimitsSpec {
945
945
  export interface InfraSetupRecord {
946
946
  /** Whether `docker compose up --wait` succeeded (the dependencies are up). */
947
947
  started: boolean
948
+ /**
949
+ * Whether this container had a Docker daemon to talk to at all, when it knows.
950
+ *
951
+ * The distinction `started` alone cannot make: a stack that failed to come up and a container
952
+ * with no daemon are the same `started: false` and opposite problems (one is the service's
953
+ * compose file, the other is the executor image or the sandbox it runs in). ABSENT means this
954
+ * container's probe reached no verdict — never assume `false` from absence, which is the exact
955
+ * mistake that let a daemon-less image read as an ordinary infra failure for months.
956
+ */
957
+ dockerAvailable?: boolean
948
958
  /** The repo-relative compose file that was stood up. */
949
959
  composePath?: string
950
960
  /** Epoch ms the stand-up attempt finished. */
@@ -12,17 +12,18 @@ import {
12
12
  excludeFromGit,
13
13
  fetchReferenceBranches,
14
14
  headCommit,
15
- listUntrackedFiles,
16
15
  pushBranch,
17
16
  refreshFromBaseIfClean,
18
17
  remoteBranchExists,
19
18
  } from './git.js'
19
+ import { salvageOnlyNotice, salvageUntrackedWork } from './salvage.js'
20
20
  import { openPullRequest } from './vcs-api.js'
21
21
  import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr-description.js'
22
22
  import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js'
23
23
  import type { RunOptions } from './runner.js'
24
24
  import { log, type Logger } from './logger.js'
25
25
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
26
+ import { agentCapabilities } from './agent-shared.js'
26
27
  import {
27
28
  resolvePrTemplateNote,
28
29
  withPrTemplateNote,
@@ -201,17 +202,16 @@ export async function runMultiRepoCoding(
201
202
  proxyBaseUrl: job.proxyBaseUrl,
202
203
  proxyPhasePath: job.proxyPhasePath,
203
204
  sessionToken: job.sessionToken,
204
- webToolsGuidance: job.webToolsGuidance,
205
- webSearchProxy: job.webSearch,
206
205
  guardLimits: job.guardLimits,
207
206
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
208
- // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
209
- // are properties of the AGENT KIND, not of the checkout layout.
210
- ...(job.skills?.length ? { skills: job.skills } : {}),
211
- ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
212
- ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
213
- ...(job.designImages ? { designImages: job.designImages } : {}),
207
+ // Skills, tool servers and web research apply to a multi-repo run exactly as to a
208
+ // single-repo one: they are properties of the AGENT KIND, not of the checkout layout.
209
+ // Through the shared helper rather than re-spread here, which is what let this flow
210
+ // drift from the single-repo one in the first place.
211
+ ...agentCapabilities(job),
214
212
  multiRepo: true,
213
+ // What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
214
+ repoDirs: probeDirsForLegs(legs),
215
215
  },
216
216
  opts,
217
217
  )
@@ -386,6 +386,37 @@ async function prepareMultiRepoCheckouts(
386
386
  }
387
387
  }
388
388
 
389
+ /**
390
+ * The checkouts the no-progress guard's working-tree bound may judge this run on.
391
+ *
392
+ * A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
393
+ * (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
394
+ * forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
395
+ * it replaced. The writable legs are the repositories this run may change, so they are what it
396
+ * has to show progress in.
397
+ *
398
+ * A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
399
+ * to write to it, so a change appearing there is not this run making progress and must never be
400
+ * what saves it from the bound.
401
+ */
402
+ export function probeDirsForLegs(legs: readonly { dir: string; readOnly?: boolean }[]): string[] {
403
+ return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir)
404
+ }
405
+
406
+ /**
407
+ * Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
408
+ *
409
+ * Only the BODY is marked. A title carrying it would follow the PR into every list and
410
+ * notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
411
+ * diff, and the salvage commit's own message elaborates on it there.
412
+ */
413
+ function withSalvageOnlyNote(
414
+ pr: { title: string; body: string },
415
+ salvageOnly: boolean,
416
+ ): { title: string; body: string } {
417
+ return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr
418
+ }
419
+
389
420
  /**
390
421
  * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
391
422
  * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
@@ -430,18 +461,36 @@ async function pushMultiRepoLegs(
430
461
  (await readPrDescription(leg.dir, readOptions)) ??
431
462
  (leg.primary ? await readPrDescription(root, readOptions) : undefined)
432
463
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
433
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
464
+ // Whether the leg carried COMMITTED work before the salvage, read here and not after it.
465
+ // Afterwards the salvage's own commit makes every leg it touched look advanced, and the two
466
+ // are not the same claim: work the agent committed to this repo is a change it chose to make,
467
+ // where a salvage-only leg is a branch built entirely out of files it left lying in that
468
+ // checkout. Both are worth keeping; only one is worth presenting as a proposed change without
469
+ // saying where it came from.
470
+ const committedOwnWork = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
471
+ // Recover this leg's new files, exactly as the single-repo settle path does and for the same
472
+ // reason: `commitTrackedEdits` captures edits to files git ALREADY tracks, so a new file the
473
+ // agent created and never added used to be listed, warned about and dropped. Runs BEFORE the
474
+ // advanced/no-op judgement below, so a leg whose only work is those files is pushed rather
475
+ // than read as untouched.
476
+ const salvage = await salvageUntrackedWork({
477
+ dir: leg.dir,
478
+ occasion: { kind: 'settled' },
479
+ logger: logger.child({ repo: leg.dirName }),
480
+ ...(signal ? { signal } : {}),
481
+ })
482
+ const salvageOnly = !committedOwnWork && salvage.status === 'committed'
483
+ const advanced = committedOwnWork || salvage.status === 'committed'
434
484
  let hasWork = advanced || leg.resumed
435
485
  if (leg.resumed && !advanced) {
436
486
  const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
437
487
  if (ahead === false) hasWork = false
438
488
  }
439
- const leftover = await listUntrackedFiles(leg.dir, signal)
440
- if (leftover.length > 0) {
441
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
489
+ if (salvage.status === 'refused' || salvage.status === 'failed') {
490
+ logger.warn('multi-repo: new files were left behind and are NOT in the push', {
442
491
  repo: leg.dirName,
443
- count: leftover.length,
444
- files: leftover.slice(0, 20),
492
+ count: salvage.fileCount,
493
+ reason: salvage.reason,
445
494
  })
446
495
  }
447
496
  if (!hasWork) {
@@ -457,7 +506,7 @@ async function pushMultiRepoLegs(
457
506
  ghToken: leg.ghToken,
458
507
  head: leg.workBranch,
459
508
  base: leg.repo.baseBranch,
460
- pr: applyPrDescription(leg.pr, agentPrDescription),
509
+ pr: withSalvageOnlyNote(applyPrDescription(leg.pr, agentPrDescription), salvageOnly),
461
510
  // See the single-repo call site: refresh a resumed leg's already-open PR, but only
462
511
  // when the text is the agent's own briefing rather than the dispatch-time fallback.
463
512
  ...(agentPrDescription ? { refreshExisting: true } : {}),
@@ -26,6 +26,12 @@ import {
26
26
  mergeGuardLimits,
27
27
  progressGuardLimitsFromEnv,
28
28
  } from './progress-guard.js'
29
+ import {
30
+ composeWorkspaceProbes,
31
+ createWorkspaceProbe,
32
+ readHeadOrEmpty,
33
+ type WorkspaceProbe,
34
+ } from './workspace-probe.js'
29
35
  import type { RunOptions } from './runner.js'
30
36
  import { type SubscriptionHarness, runSubscriptionHarness } from './agent-runner.js'
31
37
 
@@ -151,6 +157,17 @@ export async function acquireRepoCheckout<T>(
151
157
  export interface AgentRunSpec {
152
158
  /** The prepared working directory (cloned/scaffolded by the caller). */
153
159
  dir: string
160
+ /**
161
+ * The git checkouts this pass may change, for the no-progress guard's working-tree bound.
162
+ * Absent ⇒ `[dir]`, which is right whenever the agent's cwd is (or is inside) the one repo.
163
+ *
164
+ * A MULTI-REPO run is the exception the default cannot serve: its cwd is a workspace ROOT
165
+ * holding sibling checkouts and is no repository itself, so probing it asks git a question with
166
+ * no answer, every probe throws, and the bound goes permanently unenforced. Such a caller names
167
+ * its writable legs here instead. A read-only reference checkout is deliberately NOT named: the
168
+ * run is forbidden to write to it, so a change there is not this run making progress.
169
+ */
170
+ repoDirs?: readonly string[]
154
171
  /** Composed role + best-practice fragments; written to Pi's global AGENTS.md context. */
155
172
  systemPrompt: string
156
173
  /** The concrete task prompt handed to Pi. */
@@ -291,6 +308,90 @@ export async function checkoutHasBlueprints(dir: string, multiRepo: boolean): Pr
291
308
  return checks.some(Boolean)
292
309
  }
293
310
 
311
+ /**
312
+ * Run one pass on a SUBSCRIPTION harness (Claude Code / Codex): the leased-credential path, which
313
+ * shares only the checkout preparation with the Pi one.
314
+ *
315
+ * Split out of {@link runAgentInWorkspace} for its cyclomatic budget. It is also the honest seam:
316
+ * everything here is a decision about what the vendor's own CLI is handed, while everything left
317
+ * behind is about the proxy-backed Pi run.
318
+ */
319
+ async function runSubscriptionInWorkspace(
320
+ harness: 'claude-code' | 'codex',
321
+ spec: AgentRunSpec,
322
+ opts: RunOptions,
323
+ prepared: {
324
+ contextFiles: ContextFileInfo[]
325
+ imageGuidance: string
326
+ workspaceProbe: WorkspaceProbe
327
+ },
328
+ ): Promise<PiRunOutcome> {
329
+ const { contextFiles, imageGuidance, workspaceProbe } = prepared
330
+ // Ambient (native) mode authenticates with the developer's own CLI login, so no
331
+ // leased token is required; otherwise the leased subscription token is mandatory.
332
+ if (!spec.ambientAuth && !spec.subscriptionToken) {
333
+ throw new Error(`The ${harness} harness requires a subscription token`)
334
+ }
335
+ const subOutcome = await runSubscriptionHarness(harness, {
336
+ cwd: spec.dir,
337
+ model: spec.model,
338
+ systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
339
+ userPrompt: spec.userPrompt,
340
+ ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
341
+ subscriptionBaseUrl: spec.subscriptionBaseUrl,
342
+ ...(spec.ambientAuth ? { ambientAuth: true } : {}),
343
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
344
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
345
+ // Codex's own image tool. Passed for both subscription harnesses because the option lives on
346
+ // the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
347
+ // (unlike an MCP server) there is nothing to report as unservable — the backend never
348
+ // resolves a codex-served generator onto a claude-code step, because admission refuses it.
349
+ ...(spec.generateImages ? { generateImages: true } : {}),
350
+ // `spec.webSearchProxy` is deliberately NOT forwarded. It states whether OUR PROXY serves web
351
+ // research for this run's account, which is what Pi's tools ride and what they would fail
352
+ // without; neither subscription CLI touches that proxy. Claude Code's `WebSearch`/`WebFetch`
353
+ // are served by the vendor the leased subscription already pays and are declared
354
+ // unconditionally (see `CLAUDE_TOOL_SET`), and Codex's surface is per-tool config rather than
355
+ // a list. Passing the proxy's availability here would withhold working tools on the strength
356
+ // of an unrelated deployment's wiring.
357
+ ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
358
+ signal: opts.signal,
359
+ // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
360
+ // defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
361
+ // no-edit allowance, so a claude-code run that stops making progress is killed early
362
+ // instead of burning the full wall-clock budget. The claude runner consumes it; codex
363
+ // ignores it for now (its stream isn't wired to the guard).
364
+ guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
365
+ expectsEdits: spec.expectsEdits ?? true,
366
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
367
+ workspaceProbe,
368
+ onActivity: opts.onActivity,
369
+ onProgress: opts.onProgress,
370
+ // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
371
+ // and a proxied one produce the same evidence rather than one of them producing none.
372
+ onSpan: opts.onSpan,
373
+ // The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
374
+ // Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
375
+ // each can beat the window it opens.
376
+ beginToolWindow: opts.beginToolWindow,
377
+ // Per-slice review capture, so a parallel review's finished slices are persisted as they
378
+ // land rather than only in the terminal output. Only the subscription runners fan work out
379
+ // across subagents, so this is the only path that can produce it.
380
+ onSliceReviews: opts.onSliceReviews,
381
+ // What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
382
+ // harnesses even though only claude-code's stream carries the report today: the hook is a
383
+ // pass-through, and a codex run that never calls it leaves the backend's record honestly
384
+ // absent rather than claiming every server it wired failed to start.
385
+ onToolServers: opts.onToolServers,
386
+ // Stream this run's per-call telemetry to the job's live drain. The subscription
387
+ // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
388
+ // proxy as they happen), so this is the only path that needs the hook.
389
+ onCallMetric: opts.onCallMetric,
390
+ ...(opts.log ? { log: opts.log } : {}),
391
+ })
392
+ return withEffortReport(spec.dir, subOutcome)
393
+ }
394
+
294
395
  /**
295
396
  * Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
296
397
  * then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
@@ -331,65 +432,29 @@ export async function runAgentInWorkspace(
331
432
  await materializeSkillResources(spec.dir, spec.skills)
332
433
  }
333
434
 
334
- // Subscription harnesses (Claude Code / Codex) authenticate with the leased
335
- // token and talk direct to the vendor no proxy config, no AGENTS.md. The
336
- // system prompt is passed straight to the CLI; everything around this (clone,
337
- // push, watchdogs) is unchanged.
435
+ // The no-progress guard's no-edit bound asks "has this run changed the repository", and the
436
+ // tool names it can see are only a proxy for that: an agent writing every file through `bash`
437
+ // reads as making no edits at all, and the guard killed exactly such a run after it had built,
438
+ // tested and verified a whole service. The working tree is the honest answer, so wire the probe
439
+ // that reads it. Built HERE because this is the shared middle of both harness paths and the one
440
+ // place that knows the checkout: the guard itself stays pure and takes it injected.
441
+ //
442
+ // The baseline is HEAD as this PASS begins, not the clone's — a repair round is a fresh agent
443
+ // that must show its OWN progress, and judging it against the clone would let the previous
444
+ // round's commits satisfy its bound. A checkout with no commit yet (a scaffold-from-scratch
445
+ // bootstrap) has no HEAD to read; the probe then rides on the dirty-tree half alone, which is
446
+ // the half that matters there anyway.
447
+ const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal)
448
+
449
+ // Subscription harnesses (Claude Code / Codex) authenticate with the leased token and talk
450
+ // direct to the vendor: no proxy config, no AGENTS.md. The system prompt is passed straight to
451
+ // the CLI; everything around this (clone, push, watchdogs) is unchanged.
338
452
  if (spec.harness === 'claude-code' || spec.harness === 'codex') {
339
- // Ambient (native) mode authenticates with the developer's own CLI login, so no
340
- // leased token is required; otherwise the leased subscription token is mandatory.
341
- if (!spec.ambientAuth && !spec.subscriptionToken) {
342
- throw new Error(`The ${spec.harness} harness requires a subscription token`)
343
- }
344
- const subOutcome = await runSubscriptionHarness(spec.harness, {
345
- cwd: spec.dir,
346
- model: spec.model,
347
- systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
348
- userPrompt: spec.userPrompt,
349
- ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
350
- subscriptionBaseUrl: spec.subscriptionBaseUrl,
351
- ...(spec.ambientAuth ? { ambientAuth: true } : {}),
352
- ...(spec.skills?.length ? { skills: spec.skills } : {}),
353
- ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
354
- // Codex's own image tool. Passed for both subscription harnesses because the option lives on
355
- // the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
356
- // (unlike an MCP server) there is nothing to report as unservable — the backend never
357
- // resolves a codex-served generator onto a claude-code step, because admission refuses it.
358
- ...(spec.generateImages ? { generateImages: true } : {}),
359
- ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
360
- signal: opts.signal,
361
- // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
362
- // defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
363
- // no-edit allowance, so a claude-code run that stops making progress is killed early
364
- // instead of burning the full wall-clock budget. The claude runner consumes it; codex
365
- // ignores it for now (its stream isn't wired to the guard).
366
- guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
367
- expectsEdits: spec.expectsEdits ?? true,
368
- onActivity: opts.onActivity,
369
- onProgress: opts.onProgress,
370
- // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
371
- // and a proxied one produce the same evidence rather than one of them producing none.
372
- onSpan: opts.onSpan,
373
- // The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
374
- // Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
375
- // each can beat the window it opens.
376
- beginToolWindow: opts.beginToolWindow,
377
- // Per-slice review capture, so a parallel review's finished slices are persisted as they
378
- // land rather than only in the terminal output. Only the subscription runners fan work out
379
- // across subagents, so this is the only path that can produce it.
380
- onSliceReviews: opts.onSliceReviews,
381
- // What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
382
- // harnesses even though only claude-code's stream carries the report today: the hook is a
383
- // pass-through, and a codex run that never calls it leaves the backend's record honestly
384
- // absent rather than claiming every server it wired failed to start.
385
- onToolServers: opts.onToolServers,
386
- // Stream this run's per-call telemetry to the job's live drain. The subscription
387
- // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
388
- // proxy as they happen), so this is the only path that needs the hook.
389
- onCallMetric: opts.onCallMetric,
390
- ...(opts.log ? { log: opts.log } : {}),
453
+ return await runSubscriptionInWorkspace(spec.harness, spec, opts, {
454
+ contextFiles,
455
+ imageGuidance,
456
+ workspaceProbe,
391
457
  })
392
- return withEffortReport(spec.dir, subOutcome)
393
458
  }
394
459
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
395
460
  throw new Error('The Pi harness requires proxyBaseUrl and sessionToken')
@@ -446,11 +511,50 @@ export async function runAgentInWorkspace(
446
511
  // Start from the env/built-in defaults and apply only the per-knob overrides the
447
512
  // backend set for this kind (loosen-only), so an unspecified knob keeps its default.
448
513
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
514
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
515
+ workspaceProbe,
449
516
  extraEnv,
450
517
  })
451
518
  return withEffortReport(spec.dir, piOutcome)
452
519
  }
453
520
 
521
+ /**
522
+ * The workspace probe for one agent pass: each of the pass's working trees, baselined against its
523
+ * own HEAD as this pass begins.
524
+ *
525
+ * Reading HEAD is the one part that can fail benignly: a scaffold-from-scratch checkout has no
526
+ * commit yet, so `rev-parse HEAD` errors. That is no reason to leave the bound blind, since the
527
+ * dirty-tree half is exactly what answers a from-scratch build — so the pass baselines against
528
+ * the empty sha (`readHeadOrEmpty`, which the probe itself reads HEAD through for the same
529
+ * reason), and any commit the agent makes reads as HEAD having moved off it.
530
+ *
531
+ * A directory that is no git repository at all makes every probe THROW, which the driver treats
532
+ * as inconclusive: the bound re-arms and the run is neither killed nor left to the streak bounds
533
+ * alone. Deliberate, and the same disposition a transient git failure gets.
534
+ *
535
+ * WHICH trees is `spec.repoDirs`, defaulted HERE rather than at the call site so the rule that a
536
+ * pass with no declared checkouts is judged on its own directory lives with the builder that acts
537
+ * on it. Several of them compose into one probe over the whole workspace (see
538
+ * {@link composeWorkspaceProbes}); an empty list would silently disarm the bound, so it falls back
539
+ * to `dir` too.
540
+ */
541
+ async function buildWorkspaceProbe(
542
+ spec: Pick<AgentRunSpec, 'dir' | 'repoDirs'>,
543
+ signal: AbortSignal | undefined,
544
+ ): Promise<WorkspaceProbe> {
545
+ const dirs = spec.repoDirs?.length ? spec.repoDirs : [spec.dir]
546
+ const probes = await Promise.all(
547
+ dirs.map(async (dir) =>
548
+ createWorkspaceProbe({
549
+ dir,
550
+ baseSha: await readHeadOrEmpty(dir, signal),
551
+ ...(signal ? { signal } : {}),
552
+ }),
553
+ ),
554
+ )
555
+ return composeWorkspaceProbes(probes)
556
+ }
557
+
454
558
  /**
455
559
  * Whether the claude-code runner will install this run's skills natively (into the CLI's config
456
560
  * dir) rather than the caller materialising them into the checkout. True ONLY for a