@cat-factory/executor-harness 1.92.2 → 1.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @cat-factory/executor-harness
2
2
 
3
3
  The payload that runs **inside** a per-run Cloudflare Container (or a
4
- [self-hosted runner](../../docs/runner-pool-integration.md)) to perform real
4
+ [self-hosted runner](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md)) to perform real
5
5
  repo work with the [Pi coding agent](https://github.com/earendil-works/pi).
6
6
 
7
7
  It is a thin TypeScript wrapper (a `node:http` server on `:8080`) that the
@@ -33,7 +33,7 @@ replayed `POST` **re-attaches** to the running job rather than starting a
33
33
  duplicate (the durable driver's retries/replays are safe). Pi's todo-tool counts
34
34
  are surfaced as `progress` while a job runs. The exact request/response shapes
35
35
  cat-factory sends are documented in
36
- [`docs/runner-pool-integration.md`](../../docs/runner-pool-integration.md).
36
+ [`docs/runner-pool-integration.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md).
37
37
 
38
38
  `GET /jobs/{id}` is also the harness's observability channel: `spans`, `followUps`
39
39
  and `callMetrics` are **drain-on-read**; each poll returns what accumulated since
@@ -62,7 +62,7 @@ The implementation job (`POST /run`) is the canonical sequence:
62
62
  body's `proxyPhasePath` says the backend serves it, which is how a repair round's model spend
63
63
  stays distinguishable from the first pass's in telemetry; without that flag the plain path is
64
64
  used and the calls are recorded as unattributed
65
- (see [token-burn instrumentation](../../../docs/initiatives/token-burn-instrumentation.md)),
65
+ (see [token-burn instrumentation](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/token-burn-instrumentation.md)),
66
66
  3. **prepopulate dependencies**, when the job body carries `dependencyInstall`: the
67
67
  service's install command is run with `sh -c` in the checkout BEFORE the agent starts, so
68
68
  it reads real installed packages instead of inferring a library's capabilities from a
@@ -71,7 +71,7 @@ The implementation job (`POST /run`) is the canonical sequence:
71
71
  steps 6 and 7, which start a fresh agent) and the run continues either way. Whatever the
72
72
  install materialises is excluded from git first, so no later `git add -A` can sweep a
73
73
  dependency tree into the pull request (see
74
- [dependency prepopulation](../../../docs/initiatives/agent-dependency-prepopulation.md)),
74
+ [dependency prepopulation](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/agent-dependency-prepopulation.md)),
75
75
  4. **resolve the repo's pull-request template**, when this dispatch opens a PR (`src/pr-template.ts`):
76
76
  `.github/PULL_REQUEST_TEMPLATE.md` and its root/`docs/`/multi-template-directory variants, or
77
77
  GitLab's `.gitlab/merge_request_templates/`, read straight off the checkout (a symlinked template
@@ -85,11 +85,11 @@ The implementation job (`POST /run`) is the canonical sequence:
85
85
  6. **validate** the checkout, when the job body carries `validationChecks`: the service's
86
86
  configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
87
87
  while they fail and the attempt budget remains the agent is re-run with the captured output
88
- as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
88
+ as its instruction (see [pre-PR validation](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/pre-pr-validation.md)),
89
89
  7. **prove the reproduction**, when the job body carries `reproduction`: the declared check is
90
90
  run against the pre-fix tree and the tree the PR will open from, in two freshly-created
91
91
  symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
92
- [bugfix reproduction proof](../../docs/adr/0033-bugfix-reproduction-proof.md)). Unlike
92
+ [bugfix reproduction proof](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0033-bugfix-reproduction-proof.md)). Unlike
93
93
  step 6 this NEVER gates the PR: a failed verification is fed back to the agent while budget
94
94
  remains, then recorded as `inconclusive`. It runs BEFORE step 6 so validation stays the last
95
95
  thing to touch the tree,
@@ -120,7 +120,7 @@ PR. Blueprint **commits onto a branch** (no history reset) and returns the tree.
120
120
  A job body may carry `skills[]` (procedural playbooks) and `mcpServers[]` (MCP tool servers): the
121
121
  harness MATERIALISES both and decides nothing about them; the backend has already resolved which
122
122
  apply and dropped what this harness cannot serve (see
123
- [`backend/docs/adr/0029-agent-kind-capabilities.md`](../../docs/adr/0029-agent-kind-capabilities.md)).
123
+ [`backend/docs/adr/0029-agent-kind-capabilities.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0029-agent-kind-capabilities.md)).
124
124
 
125
125
  - **Skills** install natively under `CLAUDE_CONFIG_DIR/skills/<name>/` for a leased-credential
126
126
  claude-code run (the CLI discovers and invokes them), and under
@@ -279,7 +279,7 @@ docker.io/<org>/cat-factory-executor:<version>
279
279
  Each is tagged with the package `version`, the commit `sha-…`, and `latest`.
280
280
 
281
281
  **CI** does this automatically:
282
- [`.github/workflows/docker-publish.yml`](../../../.github/workflows/docker-publish.yml)
282
+ [`.github/workflows/docker-publish.yml`](https://github.com/kibertoad/cat-factory/blob/main/.github/workflows/docker-publish.yml)
283
283
  republishes on every push to `main` that touches image content (`src/**`,
284
284
  `Dockerfile`, `tsconfig.json`, `package.json`). Docker Hub is gated on the
285
285
  `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets; without them it publishes
@@ -303,9 +303,9 @@ via env vars (`REGISTRIES`, `GHCR_OWNER`, `DOCKERHUB_ORG`, `TAG`, `PUSH_LATEST`,
303
303
 
304
304
  A backend deployment references the image from `wrangler.toml`
305
305
  (`[[containers]] image = "ghcr.io/<owner>/cat-factory-executor:<version>"`: see
306
- [`deploy/backend`](../../../deploy/backend)); a self-hosted runner pool pulls the
307
- same image (see [`docs/runner-pool-integration.md`](../../docs/runner-pool-integration.md)).
306
+ [`deploy/backend`](https://github.com/kibertoad/cat-factory/tree/main/deploy/backend)); a self-hosted runner pool pulls the
307
+ same image (see [`docs/runner-pool-integration.md`](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/runner-pool-integration.md)).
308
308
  The worker library's own test/dev `wrangler.toml` still references this
309
309
  `Dockerfile` by local path so the acceptance suite can build it. Because the
310
310
  version is the image tag, **bump this package via a changeset whenever you change
311
- image content** (see [`CONTRIBUTING.md`](../../../CONTRIBUTING.md)).
311
+ image content** (see [`CONTRIBUTING.md`](https://github.com/kibertoad/cat-factory/blob/main/CONTRIBUTING.md)).
@@ -42,6 +42,67 @@ export interface McpServerSpec {
42
42
  */
43
43
  secretKeys?: string[];
44
44
  }
45
+ /**
46
+ * What the agent's CLI reported about ONE wired tool server when it started up.
47
+ *
48
+ * This is the OBSERVED half of the run's tool-server record, and it answers a question the
49
+ * backend's own half structurally cannot: the dispatch record says why the platform WITHHELD a
50
+ * tool, while this says a server the platform wired failed to start anyway. A vendor endpoint
51
+ * that 500s, an `npx` package that no longer resolves, a credential the vendor has since revoked
52
+ * — every one of those leaves the prompt promising a tool the agent then cannot call, and before
53
+ * this the only evidence was the agent saying so in prose, if it noticed at all.
54
+ *
55
+ * OBSERVED, never decided: nothing here changes what the run does. The harness reports what the
56
+ * CLI said and the backend records it beside what it decided; no code path branches on it.
57
+ */
58
+ export interface ObservedMcpServer {
59
+ /** The server id the CLI named — the same id the backend declared (`--strict-mcp-config`). */
60
+ id: string;
61
+ status: ObservedMcpStatus;
62
+ /**
63
+ * How many of the CLI's exposed tools belong to this server (`mcp__<id>__…`).
64
+ *
65
+ * ABSENT and `0` are different facts and both are worth having: absent means this image counted
66
+ * nothing for the server, while `0` means it counted and the server contributed none: a server
67
+ * that connected and exposes nothing, which reads to the agent exactly like a server that was
68
+ * never wired. Never defaulted to 0.
69
+ *
70
+ * Two things leave it absent, and neither is "the server has no tools": the CLI listed no tools
71
+ * at all, or the tool namespace cannot say which of two declared servers a name belongs to (see
72
+ * {@link tallyToolsByServer}).
73
+ */
74
+ toolCount?: number;
75
+ }
76
+ /**
77
+ * The status vocabulary of {@link ObservedMcpServer}, normalised from the CLI's own word.
78
+ *
79
+ * A CLOSED list mapped from an OPEN one, which is why `unknown` is a member rather than a reason
80
+ * to drop the row. The CLI's status strings are a third party's vocabulary and it may add to them;
81
+ * a server whose status this image cannot name is still a server the CLI knows about, and the
82
+ * honest report is "it was there, this image could not read its state" rather than silence
83
+ * (which reads as a server the CLI never mentioned) or a guess at `ready` (which would report a
84
+ * dead tool as a live one, the precise failure the whole unavailability vocabulary exists to
85
+ * prevent).
86
+ *
87
+ * `unknown` covers two causes that share one remedy, which is why they share one member: a word
88
+ * this image cannot map, and a word the CLI uses for a state that is not resolved YET. Neither
89
+ * says anything about the server, and the surface paints neither as a fault.
90
+ */
91
+ export type ObservedMcpStatus = 'ready' | 'failed' | 'needs_auth' | 'unknown';
92
+ /**
93
+ * Read the claude-code CLI's startup report (`{"type":"system","subtype":"init"}`) into one
94
+ * {@link ObservedMcpServer} per server the CLI knows about.
95
+ *
96
+ * The CLI announces its resolved session ONCE, before the first model call: which MCP servers it
97
+ * loaded and with what status, and the flat list of tool names it will expose. Both halves are
98
+ * read here because neither answers the question alone — a `ready` server exposing no tools is as
99
+ * useless to the agent as a failed one, and a tool count with no status cannot say why.
100
+ *
101
+ * Returns `undefined` when the event names no servers at all, which keeps "this run wired none"
102
+ * and "this image observed none" from collapsing into an empty list on the backend's record.
103
+ * Pure, so the parsing is testable without a CLI: {@link runClaudeCode} feeds it the raw event.
104
+ */
105
+ export declare function observeClaudeMcpInit(event: Record<string, unknown>): ObservedMcpServer[] | undefined;
45
106
  /**
46
107
  * The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
47
108
  * server that fails to start routinely echoes its own argv or request headers into stderr, and
@@ -55,12 +116,30 @@ export declare function mcpServerSecretValues(servers: readonly McpServerSpec[])
55
116
  * last — so the first wins and the collision is dropped rather than silently mixing two playbooks.
56
117
  */
57
118
  export declare function parseSkillSpecs(value: unknown): SkillSpec[] | undefined;
119
+ /**
120
+ * The optional job-body CAPABILITY fields this image parses, reported on `/health` and on the
121
+ * `POST /jobs` acceptance so a backend can tell whether the body it just sent will be honoured.
122
+ *
123
+ * The gap it closes: an image older than a capability does not fail on it, it ignores the field.
124
+ * The backend composes the PROMPT, so a dropped `mcpServers` leaves the agent reading that it has
125
+ * tools it has no client for: a blind run rather than a failed one, and previously invisible to
126
+ * the backend, which has no way to know what image a self-hosted runner pool pins.
127
+ *
128
+ * Kept byte-identical to kernel's `HARNESS_BODY_CAPABILITIES` (the image is built from `src/` plus
129
+ * typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
130
+ * against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
131
+ * {@link MCP_SERVER_ID_PATTERN} uses.
132
+ *
133
+ * A member is added here in the SAME change that teaches the parser the field, never ahead of it:
134
+ * the whole value of the list is that it is the image's own honest answer.
135
+ */
136
+ export declare const HARNESS_BODY_CAPABILITIES: readonly string[];
58
137
  /**
59
138
  * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
60
139
  *
61
140
  * Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
62
141
  * plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
63
- * against it by `test/agent-capabilities.conformity.test.ts` the same copy-plus-pin arrangement
142
+ * against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
64
143
  * `src/host-markdown.ts` uses.
65
144
  */
66
145
  export declare const MCP_SERVER_ID_PATTERN: RegExp;
@@ -1,5 +1,118 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
+ /**
4
+ * Map one status word from the CLI onto {@link ObservedMcpStatus}.
5
+ *
6
+ * The synonyms are grouped rather than listed one-to-one because the CLI has spelled the same
7
+ * two states more than one way across versions (`connected`/`ready`, `failed`/`error`), and an
8
+ * image that pinned the exact spelling would silently start reporting `unknown` for every server
9
+ * on a CLI upgrade — a regression that looks identical to a genuine outage.
10
+ */
11
+ function normalizeMcpStatus(value) {
12
+ if (typeof value !== 'string')
13
+ return 'unknown';
14
+ const status = value.trim().toLowerCase();
15
+ if (status === 'connected' || status === 'ready' || status === 'ok')
16
+ return 'ready';
17
+ if (status === 'failed' || status === 'error')
18
+ return 'failed';
19
+ // The vendor spells the OAuth-required state with a hyphen; the underscore form costs nothing
20
+ // to accept and is what a JSON-ish vocabulary tends to drift toward.
21
+ if (status === 'needs-auth' || status === 'needs_auth')
22
+ return 'needs_auth';
23
+ // Everything else, INCLUDING the CLI's `pending`. A server still handshaking when the session
24
+ // was announced has no resolved state, which is exactly what `unknown` says, and `needs_auth` is
25
+ // the tempting wrong guess for it: the surface paints that one amber as "waiting for you to
26
+ // authorize it", sending an operator to re-issue a working credential for a server that was
27
+ // merely slow and came up a second later.
28
+ return 'unknown';
29
+ }
30
+ /**
31
+ * Read the claude-code CLI's startup report (`{"type":"system","subtype":"init"}`) into one
32
+ * {@link ObservedMcpServer} per server the CLI knows about.
33
+ *
34
+ * The CLI announces its resolved session ONCE, before the first model call: which MCP servers it
35
+ * loaded and with what status, and the flat list of tool names it will expose. Both halves are
36
+ * read here because neither answers the question alone — a `ready` server exposing no tools is as
37
+ * useless to the agent as a failed one, and a tool count with no status cannot say why.
38
+ *
39
+ * Returns `undefined` when the event names no servers at all, which keeps "this run wired none"
40
+ * and "this image observed none" from collapsing into an empty list on the backend's record.
41
+ * Pure, so the parsing is testable without a CLI: {@link runClaudeCode} feeds it the raw event.
42
+ */
43
+ export function observeClaudeMcpInit(event) {
44
+ if (event.type !== 'system' || event.subtype !== 'init')
45
+ return undefined;
46
+ const reported = event.mcp_servers;
47
+ if (!Array.isArray(reported) || reported.length === 0)
48
+ return undefined;
49
+ const rows = [];
50
+ const declared = new Set();
51
+ for (const entry of reported) {
52
+ if (typeof entry !== 'object' || entry === null)
53
+ continue;
54
+ const record = entry;
55
+ const id = sanitizeServerId(record.name);
56
+ // An id this image cannot hold is dropped rather than reported under a mangled name: the
57
+ // whole row is only useful if it JOINS the backend's declaration, and `--strict-mcp-config`
58
+ // means every server the CLI loaded came from the config this harness wrote.
59
+ if (!id || declared.has(id))
60
+ continue;
61
+ declared.add(id);
62
+ rows.push({ id, status: normalizeMcpStatus(record.status) });
63
+ }
64
+ if (rows.length === 0)
65
+ return undefined;
66
+ // Counted from the CLI's own tool list rather than from a per-server field, because there is no
67
+ // per-server field: the CLI flattens every server's tools into one array namespaced by server
68
+ // id. A missing/non-array list leaves every count ABSENT rather than 0 (see `toolCount`).
69
+ const tally = tallyToolsByServer(event.tools, declared);
70
+ return rows.map((row) => ({
71
+ ...row,
72
+ ...(tally && !tally.ambiguous.has(row.id) ? { toolCount: tally.counts.get(row.id) ?? 0 } : {}),
73
+ }));
74
+ }
75
+ /**
76
+ * Tally the CLI's flat tool list (`mcp__<id>__<tool>`) against the servers the SAME event
77
+ * declared, or `undefined` when it carried no list, which is the distinction
78
+ * {@link ObservedMcpServer.toolCount} preserves.
79
+ *
80
+ * Matched against the declared ids rather than split on the first `__`, because the id vocabulary
81
+ * ({@link MCP_SERVER_ID_PATTERN}) permits an underscore: a server named `code__search` owns
82
+ * `mcp__code__search__query`, which a first-separator split files under a server called `code`,
83
+ * leaving the real one reporting `toolCount: 0`. That is the single most diagnostic value on the
84
+ * field, so the mis-split renders a fully healthy server as one that started and exposes nothing.
85
+ *
86
+ * The same underscore makes genuine ambiguity representable: with both `code` and `code__search`
87
+ * declared, `mcp__code__search__query` is a name either could own and nothing in the report says
88
+ * which. Neither server is counted then, and both are named `ambiguous` so their count stays
89
+ * absent. Guessing an owner would move a real tool onto the wrong server and take the other's
90
+ * count to a `0` that reads as a fault.
91
+ */
92
+ function tallyToolsByServer(tools, declared) {
93
+ if (!Array.isArray(tools))
94
+ return undefined;
95
+ const counts = new Map();
96
+ const ambiguous = new Set();
97
+ for (const tool of tools) {
98
+ if (typeof tool !== 'string' || !tool.startsWith('mcp__'))
99
+ continue;
100
+ const owners = [];
101
+ for (const id of declared) {
102
+ const prefix = `mcp__${id}__`;
103
+ // The tool name after the prefix must be non-empty: `mcp__slack__` names no tool.
104
+ if (tool.length > prefix.length && tool.startsWith(prefix))
105
+ owners.push(id);
106
+ }
107
+ const [owner] = owners;
108
+ if (owners.length === 1 && owner)
109
+ counts.set(owner, (counts.get(owner) ?? 0) + 1);
110
+ else
111
+ for (const id of owners)
112
+ ambiguous.add(id);
113
+ }
114
+ return { counts, ambiguous };
115
+ }
3
116
  /**
4
117
  * The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
5
118
  * server that fails to start routinely echoes its own argv or request headers into stderr, and
@@ -111,12 +224,30 @@ export function parseSkillSpecs(value) {
111
224
  }
112
225
  return skills.length ? skills : undefined;
113
226
  }
227
+ /**
228
+ * The optional job-body CAPABILITY fields this image parses, reported on `/health` and on the
229
+ * `POST /jobs` acceptance so a backend can tell whether the body it just sent will be honoured.
230
+ *
231
+ * The gap it closes: an image older than a capability does not fail on it, it ignores the field.
232
+ * The backend composes the PROMPT, so a dropped `mcpServers` leaves the agent reading that it has
233
+ * tools it has no client for: a blind run rather than a failed one, and previously invisible to
234
+ * the backend, which has no way to know what image a self-hosted runner pool pins.
235
+ *
236
+ * Kept byte-identical to kernel's `HARNESS_BODY_CAPABILITIES` (the image is built from `src/` plus
237
+ * typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
238
+ * against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
239
+ * {@link MCP_SERVER_ID_PATTERN} uses.
240
+ *
241
+ * A member is added here in the SAME change that teaches the parser the field, never ahead of it:
242
+ * the whole value of the list is that it is the image's own honest answer.
243
+ */
244
+ export const HARNESS_BODY_CAPABILITIES = ['mcpServers', 'skills'];
114
245
  /**
115
246
  * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
116
247
  *
117
248
  * Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
118
249
  * plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
119
- * against it by `test/agent-capabilities.conformity.test.ts` the same copy-plus-pin arrangement
250
+ * against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
120
251
  * `src/host-markdown.ts` uses.
121
252
  */
122
253
  export const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
@@ -1,6 +1,6 @@
1
1
  import type { Logger } from './logger.js';
2
2
  import { type HarnessCallMetric, type PiRunOutcome, type TodoProgress, type ToolSpan } from './pi.js';
3
- import { type McpServerSpec, type SkillSpec } from './agent-capabilities.js';
3
+ import { type McpServerSpec, type ObservedMcpServer, type SkillSpec } from './agent-capabilities.js';
4
4
  import { type ProgressGuardLimits } from './progress-guard.js';
5
5
  import { type SliceReview } from './subagents.js';
6
6
  /** Which subscription harness to run (the Pi harness uses `runPi` directly). */
@@ -92,6 +92,21 @@ export interface SubscriptionRunOptions {
92
92
  * same row still rides the result, so a lost poll response costs nothing.
93
93
  */
94
94
  onCallMetric?: (call: HarnessCallMetric) => void;
95
+ /**
96
+ * Called once with what the CLI reported about the tool servers it loaded, the moment it
97
+ * announces its resolved session (see {@link observeClaudeMcpInit}).
98
+ *
99
+ * The one thing the backend's own dispatch record cannot answer: it knows why it WITHHELD a
100
+ * tool, and this says a server it wired failed to start anyway. Reported even when every server
101
+ * came up, because "observed, all healthy" and "this image observed nothing" are different
102
+ * facts about a run and only the first one clears a wired server of suspicion.
103
+ *
104
+ * Whole-value latest-wins, not a delta — the CLI announces its session once, so a second call
105
+ * would only ever be a re-announcement of the same set. A harness whose CLI reports nothing
106
+ * (codex today) never calls this, which is what leaves the backend's record honestly empty
107
+ * rather than claiming every server failed.
108
+ */
109
+ onToolServers?: (observed: ObservedMcpServer[]) => void;
95
110
  /**
96
111
  * The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
97
112
  * session-transcript path is logged for the run when the isolated config home is torn down.
@@ -6,7 +6,7 @@ import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude
6
6
  import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js';
7
7
  import { ToolCallTracker, recordClaudeToolResults, } from './tool-trajectory.js';
8
8
  import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
9
- import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
9
+ import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, observeClaudeMcpInit, writeClaudeMcpConfig, } from './agent-capabilities.js';
10
10
  import { ProgressGuard } from './progress-guard.js';
11
11
  import { killChildProcess, spawnDetached } from './process.js';
12
12
  import { describeProcessExit } from './process-exit.js';
@@ -327,6 +327,60 @@ async function setUpClaudeMcp(servers, configHome) {
327
327
  cleanup,
328
328
  };
329
329
  }
330
+ /**
331
+ * The LIVE publishers of a claude-code run: everything the stream has revealed so far that the
332
+ * backend should see before the run ends, rather than only in its terminal result.
333
+ *
334
+ * They are grouped because they share one rule and differ on everything else. The rule: each
335
+ * publishes a WHOLE current value (never a delta), so a dropped poll response costs nothing and
336
+ * the caller may fire them as often as it likes. What differs is what is at stake — progress is a
337
+ * disposable count the UI renders, while the slice reviews carry the slices' actual review WORK
338
+ * and are the only thing a resume of a wedged review can be rebuilt from, which is why they are
339
+ * published on the turn a slice lands rather than on the next progress tick.
340
+ *
341
+ * `lastTodo` is a GETTER because the event handler assigns it as the stream goes; taking the value
342
+ * would freeze the plan at construction time.
343
+ *
344
+ * Split out of {@link runClaudeCode} for the per-function line budget.
345
+ */
346
+ function createClaudeLivePublishers(deps) {
347
+ const { opts, planTracker, sliceTracker } = deps;
348
+ return {
349
+ emitProgress: () => {
350
+ if (!opts.onProgress)
351
+ return;
352
+ const progress = mergeProgress(pickProgress(deps.lastTodo(), planTracker.progress()), sliceTracker.progress());
353
+ if (progress)
354
+ opts.onProgress(progress);
355
+ },
356
+ emitSliceReviews: () => {
357
+ if (!opts.onSliceReviews)
358
+ return;
359
+ const reviews = sliceTracker.sliceReviews();
360
+ if (reviews.length > 0)
361
+ opts.onSliceReviews(reviews);
362
+ },
363
+ };
364
+ }
365
+ /**
366
+ * Publish the CLI's own startup report about the tool servers it loaded — the OBSERVED half of the
367
+ * run's tool-server record.
368
+ *
369
+ * Handed every event because it is the one thing `runClaudeCode` reads that is neither a turn nor
370
+ * a result: it arrives once, ahead of the first model call, and says whether the servers the
371
+ * backend wired actually came up. {@link observeClaudeMcpInit} answers `undefined` for every other
372
+ * event and for a run that wired none, so a server-less run reports nothing and the caller's
373
+ * record stays honestly absent rather than empty.
374
+ *
375
+ * Split out of {@link runClaudeCode} for the per-function line budget.
376
+ */
377
+ function reportToolServerStartup(event, onToolServers) {
378
+ if (!onToolServers)
379
+ return;
380
+ const observed = observeClaudeMcpInit(event);
381
+ if (observed)
382
+ onToolServers(observed);
383
+ }
330
384
  /**
331
385
  * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
332
386
  * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
@@ -455,24 +509,12 @@ export async function runClaudeCode(opts) {
455
509
  const sliceTracker = createSliceTracker(secrets);
456
510
  const planTracker = createTaskPlanTracker();
457
511
  let lastTodo;
458
- const emitProgress = () => {
459
- if (!opts.onProgress)
460
- return;
461
- const progress = mergeProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
462
- if (progress)
463
- opts.onProgress(progress);
464
- };
465
- // Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
466
- // the two answer different questions and have different lifetimes: progress is a disposable
467
- // count the UI renders, while these carry the slices' actual review WORK and are persisted so a
468
- // run that dies before its aggregation can be resumed from them.
469
- const emitSliceReviews = () => {
470
- if (!opts.onSliceReviews)
471
- return;
472
- const reviews = sliceTracker.sliceReviews();
473
- if (reviews.length > 0)
474
- opts.onSliceReviews(reviews);
475
- };
512
+ const { emitProgress, emitSliceReviews } = createClaudeLivePublishers({
513
+ opts,
514
+ planTracker,
515
+ sliceTracker,
516
+ lastTodo: () => lastTodo,
517
+ });
476
518
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
477
519
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
478
520
  // turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
@@ -484,6 +526,7 @@ export async function runClaudeCode(opts) {
484
526
  const trajectory = createClaudeToolTrajectory(opts, secrets);
485
527
  const onEvent = (event, meta) => {
486
528
  const type = event.type;
529
+ reportToolServerStartup(event, opts.onToolServers);
487
530
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
488
531
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
489
532
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -189,6 +189,11 @@ export async function runAgentInWorkspace(spec, opts = {}) {
189
189
  // land rather than only in the terminal output. Only the subscription runners fan work out
190
190
  // across subagents, so this is the only path that can produce it.
191
191
  onSliceReviews: opts.onSliceReviews,
192
+ // What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
193
+ // harnesses even though only claude-code's stream carries the report today: the hook is a
194
+ // pass-through, and a codex run that never calls it leaves the backend's record honestly
195
+ // absent rather than claiming every server it wired failed to start.
196
+ onToolServers: opts.onToolServers,
192
197
  // Stream this run's per-call telemetry to the job's live drain. The subscription
193
198
  // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
194
199
  // proxy as they happen), so this is the only path that needs the hook.
package/dist/runner.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { FollowUpLine } from './follow-ups.js';
2
2
  import type { ValidationReport } from './validation-checks.js';
3
3
  import type { ReproductionReport } from './reproduction-proof.js';
4
4
  import type { SliceReview } from './subagents.js';
5
+ import type { ObservedMcpServer } from './agent-capabilities.js';
5
6
  import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js';
6
7
  import { type Logger } from './logger.js';
7
8
  import { type FailureCause } from './failure.js';
@@ -40,6 +41,15 @@ export interface RunOptions {
40
41
  * already persisted. Absent for a job that dispatched no subagents.
41
42
  */
42
43
  onSliceReviews?: (reviews: SliceReview[]) => void;
44
+ /**
45
+ * Receives what the agent's CLI reported about the tool servers (MCP) it loaded, once it
46
+ * announces its resolved session. Latest-wins (NOT a drain buffer) for the same reason as
47
+ * {@link onValidationReport}, with an extra one of its own: the CLI announces the set ONCE,
48
+ * near the start of the run, so a drain buffer would hand it to whichever poll happened to
49
+ * land next and lose it entirely if that poll response were dropped — on the single fact this
50
+ * whole channel exists to carry. Absent for a job that wired no tool servers.
51
+ */
52
+ onToolServers?: (observed: ObservedMcpServer[]) => void;
43
53
  /**
44
54
  * Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
45
55
  * run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
@@ -208,6 +218,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
208
218
  * from. Absent for a job that dispatched no subagents.
209
219
  */
210
220
  sliceReviews?: SliceReview[];
221
+ /**
222
+ * What the agent's CLI reported about the tool servers (MCP) wired for this job when it started
223
+ * up: per server, the status the CLI gave it and how many tools it contributed. A whole-value
224
+ * latest publish like {@link validationReport}, not drain-on-read — the CLI announces this once
225
+ * and every later poll re-reports the same set, so no poll can be the one that loses it.
226
+ *
227
+ * The complement of what the BACKEND recorded at dispatch, and the only source for the half it
228
+ * cannot see: the dispatch record says why the platform withheld a tool, this says a wired
229
+ * server failed to start anyway. Absent for a job that wired none, and for a harness whose CLI
230
+ * reports nothing — which is why it is absent rather than empty (see `ObservedMcpServer`).
231
+ */
232
+ toolServers?: ObservedMcpServer[];
211
233
  }
212
234
  /** Watchdog windows that bound every job. Tunable via the container's env. */
213
235
  export interface RunnerLimits {
@@ -256,6 +278,24 @@ export declare class JobRegistry<TJob = unknown, TResult extends JobResultBase =
256
278
  * number of jobs aborted.
257
279
  */
258
280
  abortAll(reason: string): number;
281
+ /**
282
+ * Abort ONE job and answer with the state it actually reached.
283
+ *
284
+ * The caller is a backend that has decided this job must not run: it refused the dispatch as
285
+ * blind, and the harness starts work on acceptance, so without this the agent runs to completion
286
+ * and can push a branch and open a pull request for a step the engine already failed. Aborting
287
+ * every job ({@link abortAll}) is not an option: a pooled container serves other runs.
288
+ *
289
+ * Waits for the job to SETTLE rather than returning the moment the signal is fired, because a
290
+ * fired signal is not a stopped agent and the caller's whole problem is telling those apart: it
291
+ * reports "stopped" to a human only on the strength of this answer. The window matches the
292
+ * graceful-shutdown one for the same reason (the CLI usually honours SIGTERM in milliseconds; the
293
+ * cap covers one that had to be force-killed through the 5s escalation in `killChildProcess`),
294
+ * and a job still `running` when it expires is reported as such rather than assumed dead.
295
+ *
296
+ * Returns undefined when no job of that id exists here, which the caller must NOT read as a stop.
297
+ */
298
+ abort(id: string, reason: string): Promise<JobState | undefined>;
259
299
  /**
260
300
  * How many jobs are still RUNNING. Graceful shutdown polls this so it can exit the moment the
261
301
  * aborted jobs have actually settled (the common case: the CLI honours SIGTERM in ms) instead
package/dist/runner.js CHANGED
@@ -33,6 +33,13 @@ function toView(entry) {
33
33
  const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, callMetricBuffer: _callMetricBuffer, callMetricSeq: _callMetricSeq, abort: _abort, ...view } = entry;
34
34
  return { ...view };
35
35
  }
36
+ /**
37
+ * How long {@link JobRegistry.abort} waits for an aborted job to actually settle before answering
38
+ * with whatever state it is in. Sized like the graceful-shutdown window (and for the same reason):
39
+ * the agent CLI normally honours SIGTERM in milliseconds, and this covers one that had to be
40
+ * force-killed through the 5s SIGTERM→SIGKILL escalation, with a margin.
41
+ */
42
+ const ABORT_SETTLE_MS = 6_000;
36
43
  /**
37
44
  * Tracks background jobs by id. Keyed by the backend-supplied job id (the per-step
38
45
  * job id) so a re-dispatched start re-attaches to the running job rather than starting
@@ -124,6 +131,46 @@ export class JobRegistry {
124
131
  }
125
132
  return aborted;
126
133
  }
134
+ /**
135
+ * Abort ONE job and answer with the state it actually reached.
136
+ *
137
+ * The caller is a backend that has decided this job must not run: it refused the dispatch as
138
+ * blind, and the harness starts work on acceptance, so without this the agent runs to completion
139
+ * and can push a branch and open a pull request for a step the engine already failed. Aborting
140
+ * every job ({@link abortAll}) is not an option: a pooled container serves other runs.
141
+ *
142
+ * Waits for the job to SETTLE rather than returning the moment the signal is fired, because a
143
+ * fired signal is not a stopped agent and the caller's whole problem is telling those apart: it
144
+ * reports "stopped" to a human only on the strength of this answer. The window matches the
145
+ * graceful-shutdown one for the same reason (the CLI usually honours SIGTERM in milliseconds; the
146
+ * cap covers one that had to be force-killed through the 5s escalation in `killChildProcess`),
147
+ * and a job still `running` when it expires is reported as such rather than assumed dead.
148
+ *
149
+ * Returns undefined when no job of that id exists here, which the caller must NOT read as a stop.
150
+ */
151
+ async abort(id, reason) {
152
+ const entry = this.jobs.get(id);
153
+ if (!entry)
154
+ return undefined;
155
+ // Already terminal: nothing to stop, and re-firing a cleared abort would be a no-op anyway.
156
+ // This is what makes the call idempotent for a caller that retries.
157
+ if (entry.state !== 'running')
158
+ return entry.state;
159
+ entry.abort?.(reason);
160
+ let timer;
161
+ try {
162
+ await Promise.race([
163
+ entry.promise,
164
+ new Promise((resolve) => {
165
+ timer = setTimeout(resolve, ABORT_SETTLE_MS);
166
+ }),
167
+ ]);
168
+ }
169
+ finally {
170
+ clearTimeout(timer);
171
+ }
172
+ return entry.state;
173
+ }
127
174
  /**
128
175
  * How many jobs are still RUNNING. Graceful shutdown polls this so it can exit the moment the
129
176
  * aborted jobs have actually settled (the common case: the CLI honours SIGTERM in ms) instead
@@ -223,6 +270,9 @@ export class JobRegistry {
223
270
  onSliceReviews: (reviews) => {
224
271
  entry.sliceReviews = reviews;
225
272
  },
273
+ onToolServers: (observed) => {
274
+ entry.toolServers = observed;
275
+ },
226
276
  onReproductionProof: (report) => {
227
277
  entry.reproductionReport = report;
228
278
  },
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
+ import { HARNESS_BODY_CAPABILITIES } from './agent-capabilities.js';
3
4
  import { parseAgentJob, parseInlineJob } from './job.js';
4
5
  import { handleAgent } from './agent.js';
5
6
  import { handleInline } from './inline.js';
@@ -103,6 +104,7 @@ const server = createServer((req, res) => {
103
104
  return send(res, 200, {
104
105
  status: 'ok',
105
106
  ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
107
+ capabilities: HARNESS_BODY_CAPABILITIES,
106
108
  });
107
109
  }
108
110
  // All non-health endpoints are gated by the optional shared secret.
@@ -120,6 +122,28 @@ const server = createServer((req, res) => {
120
122
  }
121
123
  return send(res, 404, { error: 'job not found' });
122
124
  }
125
+ // Stop one job: DELETE /jobs/{id}. The counterpart of the capability handshake below. A
126
+ // backend that reads the acceptance and decides the body cannot be honoured has, by then,
127
+ // already started an agent, and the only thing that keeps it from running to completion (and
128
+ // opening a pull request for a step the engine has failed) is being told to stop.
129
+ //
130
+ // Scoped to ONE job on purpose: a pooled container serves other runs, so the shutdown-time
131
+ // `abortAll` is not an alternative. The response reports the state the job actually REACHED
132
+ // (the registry waits for it to settle), never merely that the signal was sent, because the
133
+ // caller turns this into a statement to a human about whether anything is still running.
134
+ if (req.method === 'DELETE' && req.url?.startsWith('/jobs/')) {
135
+ const id = decodeURIComponent(req.url.slice('/jobs/'.length));
136
+ // `abort` (not `get`) is the existence probe: `get` DRAINS the job's span / follow-up /
137
+ // call-metric buffers, so probing with it would swallow telemetry the backend never polled.
138
+ for (const { registry } of Object.values(KINDS)) {
139
+ const state = await registry.abort(id, 'stopped by the backend');
140
+ if (state)
141
+ return send(res, 200, { jobId: id, state });
142
+ }
143
+ // No such job here. A 404 is NOT "already stopped": it is also what a caller addressing the
144
+ // wrong runner sees, so it must stay distinguishable from the 200 above.
145
+ return send(res, 404, { error: 'job not found' });
146
+ }
123
147
  // Start (or re-attach to) a job: POST /jobs with the kind in the body. The body's
124
148
  // `kind` selects the validator + registry; the rest is that kind's job spec.
125
149
  // Returns immediately with the job id; the caller polls GET /jobs/{id} for live
@@ -137,7 +161,16 @@ const server = createServer((req, res) => {
137
161
  }
138
162
  const job = entry.parse(raw);
139
163
  const view = entry.registry.start(job.jobId, job);
140
- return send(res, 202, { jobId: view.id, state: view.state });
164
+ // The capability handshake rides the ACCEPTANCE, not the poll view. The dispatch site
165
+ // is the only place the body it just sent is still in scope, and it is the last moment
166
+ // a blind run can be refused before the agent starts working from a prompt the body
167
+ // cannot back up. It is also a static fact about the IMAGE, so repeating it on every
168
+ // poll of a job that may run for an hour would be noise.
169
+ return send(res, 202, {
170
+ jobId: view.id,
171
+ state: view.state,
172
+ capabilities: HARNESS_BODY_CAPABILITIES,
173
+ });
141
174
  }
142
175
  catch (error) {
143
176
  // Parse failures (incl. host-allowlist rejection) are client errors → 400.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.92.2",
3
+ "version": "1.96.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.0",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.242.0",
34
- "@cat-factory/server": "0.222.0",
35
- "@cat-factory/spend": "0.15.6"
33
+ "@cat-factory/kernel": "0.264.0",
34
+ "@cat-factory/server": "0.244.0",
35
+ "@cat-factory/spend": "0.15.32"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -59,6 +59,169 @@ export interface McpServerSpec {
59
59
  secretKeys?: string[]
60
60
  }
61
61
 
62
+ /**
63
+ * What the agent's CLI reported about ONE wired tool server when it started up.
64
+ *
65
+ * This is the OBSERVED half of the run's tool-server record, and it answers a question the
66
+ * backend's own half structurally cannot: the dispatch record says why the platform WITHHELD a
67
+ * tool, while this says a server the platform wired failed to start anyway. A vendor endpoint
68
+ * that 500s, an `npx` package that no longer resolves, a credential the vendor has since revoked
69
+ * — every one of those leaves the prompt promising a tool the agent then cannot call, and before
70
+ * this the only evidence was the agent saying so in prose, if it noticed at all.
71
+ *
72
+ * OBSERVED, never decided: nothing here changes what the run does. The harness reports what the
73
+ * CLI said and the backend records it beside what it decided; no code path branches on it.
74
+ */
75
+ export interface ObservedMcpServer {
76
+ /** The server id the CLI named — the same id the backend declared (`--strict-mcp-config`). */
77
+ id: string
78
+ status: ObservedMcpStatus
79
+ /**
80
+ * How many of the CLI's exposed tools belong to this server (`mcp__<id>__…`).
81
+ *
82
+ * ABSENT and `0` are different facts and both are worth having: absent means this image counted
83
+ * nothing for the server, while `0` means it counted and the server contributed none: a server
84
+ * that connected and exposes nothing, which reads to the agent exactly like a server that was
85
+ * never wired. Never defaulted to 0.
86
+ *
87
+ * Two things leave it absent, and neither is "the server has no tools": the CLI listed no tools
88
+ * at all, or the tool namespace cannot say which of two declared servers a name belongs to (see
89
+ * {@link tallyToolsByServer}).
90
+ */
91
+ toolCount?: number
92
+ }
93
+
94
+ /**
95
+ * The status vocabulary of {@link ObservedMcpServer}, normalised from the CLI's own word.
96
+ *
97
+ * A CLOSED list mapped from an OPEN one, which is why `unknown` is a member rather than a reason
98
+ * to drop the row. The CLI's status strings are a third party's vocabulary and it may add to them;
99
+ * a server whose status this image cannot name is still a server the CLI knows about, and the
100
+ * honest report is "it was there, this image could not read its state" rather than silence
101
+ * (which reads as a server the CLI never mentioned) or a guess at `ready` (which would report a
102
+ * dead tool as a live one, the precise failure the whole unavailability vocabulary exists to
103
+ * prevent).
104
+ *
105
+ * `unknown` covers two causes that share one remedy, which is why they share one member: a word
106
+ * this image cannot map, and a word the CLI uses for a state that is not resolved YET. Neither
107
+ * says anything about the server, and the surface paints neither as a fault.
108
+ */
109
+ export type ObservedMcpStatus = 'ready' | 'failed' | 'needs_auth' | 'unknown'
110
+
111
+ /**
112
+ * Map one status word from the CLI onto {@link ObservedMcpStatus}.
113
+ *
114
+ * The synonyms are grouped rather than listed one-to-one because the CLI has spelled the same
115
+ * two states more than one way across versions (`connected`/`ready`, `failed`/`error`), and an
116
+ * image that pinned the exact spelling would silently start reporting `unknown` for every server
117
+ * on a CLI upgrade — a regression that looks identical to a genuine outage.
118
+ */
119
+ function normalizeMcpStatus(value: unknown): ObservedMcpStatus {
120
+ if (typeof value !== 'string') return 'unknown'
121
+ const status = value.trim().toLowerCase()
122
+ if (status === 'connected' || status === 'ready' || status === 'ok') return 'ready'
123
+ if (status === 'failed' || status === 'error') return 'failed'
124
+ // The vendor spells the OAuth-required state with a hyphen; the underscore form costs nothing
125
+ // to accept and is what a JSON-ish vocabulary tends to drift toward.
126
+ if (status === 'needs-auth' || status === 'needs_auth') return 'needs_auth'
127
+ // Everything else, INCLUDING the CLI's `pending`. A server still handshaking when the session
128
+ // was announced has no resolved state, which is exactly what `unknown` says, and `needs_auth` is
129
+ // the tempting wrong guess for it: the surface paints that one amber as "waiting for you to
130
+ // authorize it", sending an operator to re-issue a working credential for a server that was
131
+ // merely slow and came up a second later.
132
+ return 'unknown'
133
+ }
134
+
135
+ /**
136
+ * Read the claude-code CLI's startup report (`{"type":"system","subtype":"init"}`) into one
137
+ * {@link ObservedMcpServer} per server the CLI knows about.
138
+ *
139
+ * The CLI announces its resolved session ONCE, before the first model call: which MCP servers it
140
+ * loaded and with what status, and the flat list of tool names it will expose. Both halves are
141
+ * read here because neither answers the question alone — a `ready` server exposing no tools is as
142
+ * useless to the agent as a failed one, and a tool count with no status cannot say why.
143
+ *
144
+ * Returns `undefined` when the event names no servers at all, which keeps "this run wired none"
145
+ * and "this image observed none" from collapsing into an empty list on the backend's record.
146
+ * Pure, so the parsing is testable without a CLI: {@link runClaudeCode} feeds it the raw event.
147
+ */
148
+ export function observeClaudeMcpInit(
149
+ event: Record<string, unknown>,
150
+ ): ObservedMcpServer[] | undefined {
151
+ if (event.type !== 'system' || event.subtype !== 'init') return undefined
152
+ const reported = event.mcp_servers
153
+ if (!Array.isArray(reported) || reported.length === 0) return undefined
154
+ const rows: { id: string; status: ObservedMcpStatus }[] = []
155
+ const declared = new Set<string>()
156
+ for (const entry of reported) {
157
+ if (typeof entry !== 'object' || entry === null) continue
158
+ const record = entry as Record<string, unknown>
159
+ const id = sanitizeServerId(record.name)
160
+ // An id this image cannot hold is dropped rather than reported under a mangled name: the
161
+ // whole row is only useful if it JOINS the backend's declaration, and `--strict-mcp-config`
162
+ // means every server the CLI loaded came from the config this harness wrote.
163
+ if (!id || declared.has(id)) continue
164
+ declared.add(id)
165
+ rows.push({ id, status: normalizeMcpStatus(record.status) })
166
+ }
167
+ if (rows.length === 0) return undefined
168
+ // Counted from the CLI's own tool list rather than from a per-server field, because there is no
169
+ // per-server field: the CLI flattens every server's tools into one array namespaced by server
170
+ // id. A missing/non-array list leaves every count ABSENT rather than 0 (see `toolCount`).
171
+ const tally = tallyToolsByServer(event.tools, declared)
172
+ return rows.map((row) => ({
173
+ ...row,
174
+ ...(tally && !tally.ambiguous.has(row.id) ? { toolCount: tally.counts.get(row.id) ?? 0 } : {}),
175
+ }))
176
+ }
177
+
178
+ /** What {@link tallyToolsByServer} read out of the CLI's flat tool list. */
179
+ interface ToolTally {
180
+ /** Tools attributed to each declared server. A server with none is simply absent from the map. */
181
+ counts: ReadonlyMap<string, number>
182
+ /**
183
+ * Servers whose count could not be established, because at least one tool name belongs to more
184
+ * than one of them. Their count is reported ABSENT rather than short.
185
+ */
186
+ ambiguous: ReadonlySet<string>
187
+ }
188
+
189
+ /**
190
+ * Tally the CLI's flat tool list (`mcp__<id>__<tool>`) against the servers the SAME event
191
+ * declared, or `undefined` when it carried no list, which is the distinction
192
+ * {@link ObservedMcpServer.toolCount} preserves.
193
+ *
194
+ * Matched against the declared ids rather than split on the first `__`, because the id vocabulary
195
+ * ({@link MCP_SERVER_ID_PATTERN}) permits an underscore: a server named `code__search` owns
196
+ * `mcp__code__search__query`, which a first-separator split files under a server called `code`,
197
+ * leaving the real one reporting `toolCount: 0`. That is the single most diagnostic value on the
198
+ * field, so the mis-split renders a fully healthy server as one that started and exposes nothing.
199
+ *
200
+ * The same underscore makes genuine ambiguity representable: with both `code` and `code__search`
201
+ * declared, `mcp__code__search__query` is a name either could own and nothing in the report says
202
+ * which. Neither server is counted then, and both are named `ambiguous` so their count stays
203
+ * absent. Guessing an owner would move a real tool onto the wrong server and take the other's
204
+ * count to a `0` that reads as a fault.
205
+ */
206
+ function tallyToolsByServer(tools: unknown, declared: ReadonlySet<string>): ToolTally | undefined {
207
+ if (!Array.isArray(tools)) return undefined
208
+ const counts = new Map<string, number>()
209
+ const ambiguous = new Set<string>()
210
+ for (const tool of tools) {
211
+ if (typeof tool !== 'string' || !tool.startsWith('mcp__')) continue
212
+ const owners: string[] = []
213
+ for (const id of declared) {
214
+ const prefix = `mcp__${id}__`
215
+ // The tool name after the prefix must be non-empty: `mcp__slack__` names no tool.
216
+ if (tool.length > prefix.length && tool.startsWith(prefix)) owners.push(id)
217
+ }
218
+ const [owner] = owners
219
+ if (owners.length === 1 && owner) counts.set(owner, (counts.get(owner) ?? 0) + 1)
220
+ else for (const id of owners) ambiguous.add(id)
221
+ }
222
+ return { counts, ambiguous }
223
+ }
224
+
62
225
  /**
63
226
  * The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
64
227
  * server that fails to start routinely echoes its own argv or request headers into stderr, and
@@ -163,12 +326,31 @@ export function parseSkillSpecs(value: unknown): SkillSpec[] | undefined {
163
326
  return skills.length ? skills : undefined
164
327
  }
165
328
 
329
+ /**
330
+ * The optional job-body CAPABILITY fields this image parses, reported on `/health` and on the
331
+ * `POST /jobs` acceptance so a backend can tell whether the body it just sent will be honoured.
332
+ *
333
+ * The gap it closes: an image older than a capability does not fail on it, it ignores the field.
334
+ * The backend composes the PROMPT, so a dropped `mcpServers` leaves the agent reading that it has
335
+ * tools it has no client for: a blind run rather than a failed one, and previously invisible to
336
+ * the backend, which has no way to know what image a self-hosted runner pool pins.
337
+ *
338
+ * Kept byte-identical to kernel's `HARNESS_BODY_CAPABILITIES` (the image is built from `src/` plus
339
+ * typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
340
+ * against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
341
+ * {@link MCP_SERVER_ID_PATTERN} uses.
342
+ *
343
+ * A member is added here in the SAME change that teaches the parser the field, never ahead of it:
344
+ * the whole value of the list is that it is the image's own honest answer.
345
+ */
346
+ export const HARNESS_BODY_CAPABILITIES: readonly string[] = ['mcpServers', 'skills']
347
+
166
348
  /**
167
349
  * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
168
350
  *
169
351
  * Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
170
352
  * plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
171
- * against it by `test/agent-capabilities.conformity.test.ts` the same copy-plus-pin arrangement
353
+ * against it by `test/agent-capabilities.conformity.test.ts`, the same copy-plus-pin arrangement
172
354
  * `src/host-markdown.ts` uses.
173
355
  */
174
356
  export const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -24,8 +24,10 @@ import {
24
24
  claudeAllowedToolPatterns,
25
25
  codexMcpConfigToml,
26
26
  mcpServerSecretValues,
27
+ observeClaudeMcpInit,
27
28
  writeClaudeMcpConfig,
28
29
  type McpServerSpec,
30
+ type ObservedMcpServer,
29
31
  type SkillSpec,
30
32
  } from './agent-capabilities.js'
31
33
  import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
@@ -149,6 +151,21 @@ export interface SubscriptionRunOptions {
149
151
  * same row still rides the result, so a lost poll response costs nothing.
150
152
  */
151
153
  onCallMetric?: (call: HarnessCallMetric) => void
154
+ /**
155
+ * Called once with what the CLI reported about the tool servers it loaded, the moment it
156
+ * announces its resolved session (see {@link observeClaudeMcpInit}).
157
+ *
158
+ * The one thing the backend's own dispatch record cannot answer: it knows why it WITHHELD a
159
+ * tool, and this says a server it wired failed to start anyway. Reported even when every server
160
+ * came up, because "observed, all healthy" and "this image observed nothing" are different
161
+ * facts about a run and only the first one clears a wired server of suspicion.
162
+ *
163
+ * Whole-value latest-wins, not a delta — the CLI announces its session once, so a second call
164
+ * would only ever be a re-announcement of the same set. A harness whose CLI reports nothing
165
+ * (codex today) never calls this, which is what leaves the backend's record honestly empty
166
+ * rather than claiming every server failed.
167
+ */
168
+ onToolServers?: (observed: ObservedMcpServer[]) => void
152
169
  /**
153
170
  * The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
154
171
  * session-transcript path is logged for the run when the isolated config home is torn down.
@@ -501,6 +518,67 @@ async function setUpClaudeMcp(
501
518
  }
502
519
  }
503
520
 
521
+ /**
522
+ * The LIVE publishers of a claude-code run: everything the stream has revealed so far that the
523
+ * backend should see before the run ends, rather than only in its terminal result.
524
+ *
525
+ * They are grouped because they share one rule and differ on everything else. The rule: each
526
+ * publishes a WHOLE current value (never a delta), so a dropped poll response costs nothing and
527
+ * the caller may fire them as often as it likes. What differs is what is at stake — progress is a
528
+ * disposable count the UI renders, while the slice reviews carry the slices' actual review WORK
529
+ * and are the only thing a resume of a wedged review can be rebuilt from, which is why they are
530
+ * published on the turn a slice lands rather than on the next progress tick.
531
+ *
532
+ * `lastTodo` is a GETTER because the event handler assigns it as the stream goes; taking the value
533
+ * would freeze the plan at construction time.
534
+ *
535
+ * Split out of {@link runClaudeCode} for the per-function line budget.
536
+ */
537
+ function createClaudeLivePublishers(deps: {
538
+ opts: SubscriptionRunOptions
539
+ planTracker: ReturnType<typeof createTaskPlanTracker>
540
+ sliceTracker: ReturnType<typeof createSliceTracker>
541
+ lastTodo: () => TodoProgress | undefined
542
+ }): { emitProgress: () => void; emitSliceReviews: () => void } {
543
+ const { opts, planTracker, sliceTracker } = deps
544
+ return {
545
+ emitProgress: () => {
546
+ if (!opts.onProgress) return
547
+ const progress = mergeProgress(
548
+ pickProgress(deps.lastTodo(), planTracker.progress()),
549
+ sliceTracker.progress(),
550
+ )
551
+ if (progress) opts.onProgress(progress)
552
+ },
553
+ emitSliceReviews: () => {
554
+ if (!opts.onSliceReviews) return
555
+ const reviews = sliceTracker.sliceReviews()
556
+ if (reviews.length > 0) opts.onSliceReviews(reviews)
557
+ },
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Publish the CLI's own startup report about the tool servers it loaded — the OBSERVED half of the
563
+ * run's tool-server record.
564
+ *
565
+ * Handed every event because it is the one thing `runClaudeCode` reads that is neither a turn nor
566
+ * a result: it arrives once, ahead of the first model call, and says whether the servers the
567
+ * backend wired actually came up. {@link observeClaudeMcpInit} answers `undefined` for every other
568
+ * event and for a run that wired none, so a server-less run reports nothing and the caller's
569
+ * record stays honestly absent rather than empty.
570
+ *
571
+ * Split out of {@link runClaudeCode} for the per-function line budget.
572
+ */
573
+ function reportToolServerStartup(
574
+ event: Record<string, unknown>,
575
+ onToolServers: ((observed: ObservedMcpServer[]) => void) | undefined,
576
+ ): void {
577
+ if (!onToolServers) return
578
+ const observed = observeClaudeMcpInit(event)
579
+ if (observed) onToolServers(observed)
580
+ }
581
+
504
582
  /**
505
583
  * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
506
584
  * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
@@ -645,23 +723,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
645
723
  const sliceTracker = createSliceTracker(secrets)
646
724
  const planTracker = createTaskPlanTracker()
647
725
  let lastTodo: TodoProgress | undefined
648
- const emitProgress = (): void => {
649
- if (!opts.onProgress) return
650
- const progress = mergeProgress(
651
- pickProgress(lastTodo, planTracker.progress()),
652
- sliceTracker.progress(),
653
- )
654
- if (progress) opts.onProgress(progress)
655
- }
656
- // Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
657
- // the two answer different questions and have different lifetimes: progress is a disposable
658
- // count the UI renders, while these carry the slices' actual review WORK and are persisted so a
659
- // run that dies before its aggregation can be resumed from them.
660
- const emitSliceReviews = (): void => {
661
- if (!opts.onSliceReviews) return
662
- const reviews = sliceTracker.sliceReviews()
663
- if (reviews.length > 0) opts.onSliceReviews(reviews)
664
- }
726
+ const { emitProgress, emitSliceReviews } = createClaudeLivePublishers({
727
+ opts,
728
+ planTracker,
729
+ sliceTracker,
730
+ lastTodo: () => lastTodo,
731
+ })
665
732
 
666
733
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
667
734
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
@@ -675,6 +742,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
675
742
 
676
743
  const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
677
744
  const type = event.type
745
+ reportToolServerStartup(event, opts.onToolServers)
678
746
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
679
747
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
680
748
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -332,6 +332,11 @@ export async function runAgentInWorkspace(
332
332
  // land rather than only in the terminal output. Only the subscription runners fan work out
333
333
  // across subagents, so this is the only path that can produce it.
334
334
  onSliceReviews: opts.onSliceReviews,
335
+ // What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
336
+ // harnesses even though only claude-code's stream carries the report today: the hook is a
337
+ // pass-through, and a codex run that never calls it leaves the backend's record honestly
338
+ // absent rather than claiming every server it wired failed to start.
339
+ onToolServers: opts.onToolServers,
335
340
  // Stream this run's per-call telemetry to the job's live drain. The subscription
336
341
  // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
337
342
  // proxy as they happen), so this is the only path that needs the hook.
package/src/runner.ts CHANGED
@@ -3,6 +3,7 @@ import type { FollowUpLine } from './follow-ups.js'
3
3
  import type { ValidationReport } from './validation-checks.js'
4
4
  import type { ReproductionReport } from './reproduction-proof.js'
5
5
  import type { SliceReview } from './subagents.js'
6
+ import type { ObservedMcpServer } from './agent-capabilities.js'
6
7
  import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
7
8
  import { log, type Logger } from './logger.js'
8
9
  import {
@@ -56,6 +57,15 @@ export interface RunOptions {
56
57
  * already persisted. Absent for a job that dispatched no subagents.
57
58
  */
58
59
  onSliceReviews?: (reviews: SliceReview[]) => void
60
+ /**
61
+ * Receives what the agent's CLI reported about the tool servers (MCP) it loaded, once it
62
+ * announces its resolved session. Latest-wins (NOT a drain buffer) for the same reason as
63
+ * {@link onValidationReport}, with an extra one of its own: the CLI announces the set ONCE,
64
+ * near the start of the run, so a drain buffer would hand it to whichever poll happened to
65
+ * land next and lose it entirely if that poll response were dropped — on the single fact this
66
+ * whole channel exists to carry. Absent for a job that wired no tool servers.
67
+ */
68
+ onToolServers?: (observed: ObservedMcpServer[]) => void
59
69
  /**
60
70
  * Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
61
71
  * run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
@@ -224,6 +234,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
224
234
  * from. Absent for a job that dispatched no subagents.
225
235
  */
226
236
  sliceReviews?: SliceReview[]
237
+ /**
238
+ * What the agent's CLI reported about the tool servers (MCP) wired for this job when it started
239
+ * up: per server, the status the CLI gave it and how many tools it contributed. A whole-value
240
+ * latest publish like {@link validationReport}, not drain-on-read — the CLI announces this once
241
+ * and every later poll re-reports the same set, so no poll can be the one that loses it.
242
+ *
243
+ * The complement of what the BACKEND recorded at dispatch, and the only source for the half it
244
+ * cannot see: the dispatch record says why the platform withheld a tool, this says a wired
245
+ * server failed to start anyway. Absent for a job that wired none, and for a harness whose CLI
246
+ * reports nothing — which is why it is absent rather than empty (see `ObservedMcpServer`).
247
+ */
248
+ toolServers?: ObservedMcpServer[]
227
249
  }
228
250
 
229
251
  interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
@@ -304,6 +326,14 @@ function toView<TResult extends JobResultBase>(entry: JobEntry<TResult>): JobVie
304
326
  return { ...view }
305
327
  }
306
328
 
329
+ /**
330
+ * How long {@link JobRegistry.abort} waits for an aborted job to actually settle before answering
331
+ * with whatever state it is in. Sized like the graceful-shutdown window (and for the same reason):
332
+ * the agent CLI normally honours SIGTERM in milliseconds, and this covers one that had to be
333
+ * force-killed through the 5s SIGTERM→SIGKILL escalation, with a margin.
334
+ */
335
+ const ABORT_SETTLE_MS = 6_000
336
+
307
337
  /**
308
338
  * Tracks background jobs by id. Keyed by the backend-supplied job id (the per-step
309
339
  * job id) so a re-dispatched start re-attaches to the running job rather than starting
@@ -394,6 +424,44 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
394
424
  return aborted
395
425
  }
396
426
 
427
+ /**
428
+ * Abort ONE job and answer with the state it actually reached.
429
+ *
430
+ * The caller is a backend that has decided this job must not run: it refused the dispatch as
431
+ * blind, and the harness starts work on acceptance, so without this the agent runs to completion
432
+ * and can push a branch and open a pull request for a step the engine already failed. Aborting
433
+ * every job ({@link abortAll}) is not an option: a pooled container serves other runs.
434
+ *
435
+ * Waits for the job to SETTLE rather than returning the moment the signal is fired, because a
436
+ * fired signal is not a stopped agent and the caller's whole problem is telling those apart: it
437
+ * reports "stopped" to a human only on the strength of this answer. The window matches the
438
+ * graceful-shutdown one for the same reason (the CLI usually honours SIGTERM in milliseconds; the
439
+ * cap covers one that had to be force-killed through the 5s escalation in `killChildProcess`),
440
+ * and a job still `running` when it expires is reported as such rather than assumed dead.
441
+ *
442
+ * Returns undefined when no job of that id exists here, which the caller must NOT read as a stop.
443
+ */
444
+ async abort(id: string, reason: string): Promise<JobState | undefined> {
445
+ const entry = this.jobs.get(id)
446
+ if (!entry) return undefined
447
+ // Already terminal: nothing to stop, and re-firing a cleared abort would be a no-op anyway.
448
+ // This is what makes the call idempotent for a caller that retries.
449
+ if (entry.state !== 'running') return entry.state
450
+ entry.abort?.(reason)
451
+ let timer: ReturnType<typeof setTimeout> | undefined
452
+ try {
453
+ await Promise.race([
454
+ entry.promise,
455
+ new Promise<void>((resolve) => {
456
+ timer = setTimeout(resolve, ABORT_SETTLE_MS)
457
+ }),
458
+ ])
459
+ } finally {
460
+ clearTimeout(timer)
461
+ }
462
+ return entry.state
463
+ }
464
+
397
465
  /**
398
466
  * How many jobs are still RUNNING. Graceful shutdown polls this so it can exit the moment the
399
467
  * aborted jobs have actually settled (the common case: the CLI honours SIGTERM in ms) instead
@@ -497,6 +565,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
497
565
  onSliceReviews: (reviews) => {
498
566
  entry.sliceReviews = reviews
499
567
  },
568
+ onToolServers: (observed) => {
569
+ entry.toolServers = observed
570
+ },
500
571
  onReproductionProof: (report) => {
501
572
  entry.reproductionReport = report
502
573
  },
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { timingSafeEqual } from 'node:crypto'
2
2
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
3
+ import { HARNESS_BODY_CAPABILITIES } from './agent-capabilities.js'
3
4
  import { parseAgentJob, parseInlineJob } from './job.js'
4
5
  import { handleAgent } from './agent.js'
5
6
  import { handleInline } from './inline.js'
@@ -122,6 +123,7 @@ const server = createServer((req, res) => {
122
123
  return send(res, 200, {
123
124
  status: 'ok',
124
125
  ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
126
+ capabilities: HARNESS_BODY_CAPABILITIES,
125
127
  })
126
128
  }
127
129
  // All non-health endpoints are gated by the optional shared secret.
@@ -138,6 +140,27 @@ const server = createServer((req, res) => {
138
140
  }
139
141
  return send(res, 404, { error: 'job not found' })
140
142
  }
143
+ // Stop one job: DELETE /jobs/{id}. The counterpart of the capability handshake below. A
144
+ // backend that reads the acceptance and decides the body cannot be honoured has, by then,
145
+ // already started an agent, and the only thing that keeps it from running to completion (and
146
+ // opening a pull request for a step the engine has failed) is being told to stop.
147
+ //
148
+ // Scoped to ONE job on purpose: a pooled container serves other runs, so the shutdown-time
149
+ // `abortAll` is not an alternative. The response reports the state the job actually REACHED
150
+ // (the registry waits for it to settle), never merely that the signal was sent, because the
151
+ // caller turns this into a statement to a human about whether anything is still running.
152
+ if (req.method === 'DELETE' && req.url?.startsWith('/jobs/')) {
153
+ const id = decodeURIComponent(req.url.slice('/jobs/'.length))
154
+ // `abort` (not `get`) is the existence probe: `get` DRAINS the job's span / follow-up /
155
+ // call-metric buffers, so probing with it would swallow telemetry the backend never polled.
156
+ for (const { registry } of Object.values(KINDS)) {
157
+ const state = await registry.abort(id, 'stopped by the backend')
158
+ if (state) return send(res, 200, { jobId: id, state })
159
+ }
160
+ // No such job here. A 404 is NOT "already stopped": it is also what a caller addressing the
161
+ // wrong runner sees, so it must stay distinguishable from the 200 above.
162
+ return send(res, 404, { error: 'job not found' })
163
+ }
141
164
  // Start (or re-attach to) a job: POST /jobs with the kind in the body. The body's
142
165
  // `kind` selects the validator + registry; the rest is that kind's job spec.
143
166
  // Returns immediately with the job id; the caller polls GET /jobs/{id} for live
@@ -155,7 +178,16 @@ const server = createServer((req, res) => {
155
178
  }
156
179
  const job = entry.parse(raw)
157
180
  const view = entry.registry.start(job.jobId, job as never)
158
- return send(res, 202, { jobId: view.id, state: view.state })
181
+ // The capability handshake rides the ACCEPTANCE, not the poll view. The dispatch site
182
+ // is the only place the body it just sent is still in scope, and it is the last moment
183
+ // a blind run can be refused before the agent starts working from a prompt the body
184
+ // cannot back up. It is also a static fact about the IMAGE, so repeating it on every
185
+ // poll of a job that may run for an hour would be noise.
186
+ return send(res, 202, {
187
+ jobId: view.id,
188
+ state: view.state,
189
+ capabilities: HARNESS_BODY_CAPABILITIES,
190
+ })
159
191
  } catch (error) {
160
192
  // Parse failures (incl. host-allowlist rejection) are client errors → 400.
161
193
  const message = redactSecrets(error instanceof Error ? error.message : String(error))