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