@prohost/cli 0.2.2 → 0.3.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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Keeps long runs alive, and is how a laptop hears "stop".
3
+ *
4
+ * The server holds a lease on every `running` agent-run row and reaps the ones
5
+ * that go quiet, because the alternative — trusting a laptop to always report —
6
+ * leaves rows spinning forever whenever someone closes a lid. A run that
7
+ * legitimately takes four hours therefore has to say so on a cadence.
8
+ *
9
+ * The response is the other half: this CLI dials *out* over a websocket and
10
+ * accepts no inbound connections, so the heartbeat's `cancel_requested` flag is
11
+ * the only channel by which "somebody pressed stop in the UI" can reach a
12
+ * machine behind NAT.
13
+ *
14
+ * Two properties this module is written around:
15
+ *
16
+ * * **Every accepted run beats, not just the executing one.** Runs are
17
+ * serialized, so a run queued behind a four-hour job sits `running`
18
+ * server-side for four hours without a single line of work being done on it.
19
+ * Beating only for the executing run would have the server reap the queue.
20
+ * * **It never takes the daemon down.** A heartbeat is bookkeeping. Every
21
+ * failure mode — an old server that 404s, a dropped network, a handler that
22
+ * throws — costs at most the beat it happened on.
23
+ */
24
+ import type { ApiOptions } from './api.js';
25
+ /**
26
+ * How often each open run is reported.
27
+ *
28
+ * Well inside any sane server-side lease so a run survives losing a beat or
29
+ * two to a flaky link, and long enough that a queue of runs is a trickle of
30
+ * requests rather than a load source.
31
+ */
32
+ export declare const HEARTBEAT_INTERVAL_MS = 60000;
33
+ /** A cancelled timer. Returned by {@link Scheduler} implementations. */
34
+ export interface ScheduledTick {
35
+ cancel(): void;
36
+ }
37
+ /**
38
+ * Deferred execution, injectable so tests drive the loop rather than wait it
39
+ * out. The default unrefs its timer: a heartbeat loop must never be the reason
40
+ * a process refuses to exit.
41
+ */
42
+ export type Scheduler = (run: () => void, ms: number) => ScheduledTick;
43
+ /** One run the loop is beating for. */
44
+ export interface HeartbeatTarget {
45
+ /** Where the heartbeat POSTs — see `heartbeatPathFor`. */
46
+ path: string;
47
+ /**
48
+ * Latest one-line description of what the agent is doing, read fresh at each
49
+ * beat. Absent for a queued run, and for brains whose output we don't stream.
50
+ */
51
+ progress?: () => string | undefined;
52
+ /**
53
+ * The server wants this run stopped. Called at most once, and never again
54
+ * beaten for.
55
+ */
56
+ onCancelRequested: () => void | Promise<void>;
57
+ /**
58
+ * The server has already closed this run out (409) — reaped, or resolved by
59
+ * someone else. Called at most once. The work in flight is now pointless:
60
+ * whatever it produces has nowhere to go.
61
+ */
62
+ onResolved: () => void | Promise<void>;
63
+ }
64
+ export interface HeartbeatLoopOptions {
65
+ api: ApiOptions;
66
+ log: (line: string) => void;
67
+ /** Defaults to {@link HEARTBEAT_INTERVAL_MS}. */
68
+ intervalMs?: number;
69
+ /** Defaults to an unref'd `setTimeout`. */
70
+ scheduler?: Scheduler;
71
+ }
72
+ /**
73
+ * Beats for every open run on a fixed cadence.
74
+ *
75
+ * Idle when nothing is tracked — no timer is armed at all — so a harness that
76
+ * never receives a run makes no requests, and the first {@link track} starts
77
+ * the cadence.
78
+ */
79
+ export declare class HeartbeatLoop {
80
+ private readonly options;
81
+ private readonly targets;
82
+ private readonly intervalMs;
83
+ private readonly scheduler;
84
+ private pending;
85
+ private stopped;
86
+ /** A tick in flight. Ticks never overlap; a slow one delays the next. */
87
+ private ticking;
88
+ constructor(options: HeartbeatLoopOptions);
89
+ /** Begin beating for a run. Idempotent on ``runId``. */
90
+ track(runId: string, target: HeartbeatTarget): void;
91
+ /** Stop beating for a run — it reached a terminal state, one way or another. */
92
+ untrack(runId: string): void;
93
+ /** Runs currently being beaten for. Exposed for assertions and logging. */
94
+ tracked(): string[];
95
+ /** Stop the loop for good. Called once, when the harness is shutting down. */
96
+ stop(): void;
97
+ /**
98
+ * Beat once for every tracked run.
99
+ *
100
+ * Public so tests can drive the cadence directly instead of waiting on a
101
+ * timer. Beats are issued concurrently: they are independent requests, and
102
+ * doing them in series would make one slow response delay every run behind it.
103
+ */
104
+ tick(): Promise<void>;
105
+ private arm;
106
+ private runTick;
107
+ /**
108
+ * One run's heartbeat, and what its answer means.
109
+ *
110
+ * Wrapped so that a throwing handler — or a fetch implementation that rejects
111
+ * rather than resolving — costs this run's beat and nothing else.
112
+ */
113
+ private beat;
114
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Keeps long runs alive, and is how a laptop hears "stop".
3
+ *
4
+ * The server holds a lease on every `running` agent-run row and reaps the ones
5
+ * that go quiet, because the alternative — trusting a laptop to always report —
6
+ * leaves rows spinning forever whenever someone closes a lid. A run that
7
+ * legitimately takes four hours therefore has to say so on a cadence.
8
+ *
9
+ * The response is the other half: this CLI dials *out* over a websocket and
10
+ * accepts no inbound connections, so the heartbeat's `cancel_requested` flag is
11
+ * the only channel by which "somebody pressed stop in the UI" can reach a
12
+ * machine behind NAT.
13
+ *
14
+ * Two properties this module is written around:
15
+ *
16
+ * * **Every accepted run beats, not just the executing one.** Runs are
17
+ * serialized, so a run queued behind a four-hour job sits `running`
18
+ * server-side for four hours without a single line of work being done on it.
19
+ * Beating only for the executing run would have the server reap the queue.
20
+ * * **It never takes the daemon down.** A heartbeat is bookkeeping. Every
21
+ * failure mode — an old server that 404s, a dropped network, a handler that
22
+ * throws — costs at most the beat it happened on.
23
+ */
24
+ import { heartbeatRun } from './api.js';
25
+ /**
26
+ * How often each open run is reported.
27
+ *
28
+ * Well inside any sane server-side lease so a run survives losing a beat or
29
+ * two to a flaky link, and long enough that a queue of runs is a trickle of
30
+ * requests rather than a load source.
31
+ */
32
+ export const HEARTBEAT_INTERVAL_MS = 60_000;
33
+ const defaultScheduler = (run, ms) => {
34
+ const timer = setTimeout(run, ms);
35
+ // `unref` exists on Node/Bun timers and not in the DOM lib's typing.
36
+ timer.unref?.();
37
+ return { cancel: () => clearTimeout(timer) };
38
+ };
39
+ /**
40
+ * Beats for every open run on a fixed cadence.
41
+ *
42
+ * Idle when nothing is tracked — no timer is armed at all — so a harness that
43
+ * never receives a run makes no requests, and the first {@link track} starts
44
+ * the cadence.
45
+ */
46
+ export class HeartbeatLoop {
47
+ options;
48
+ targets = new Map();
49
+ intervalMs;
50
+ scheduler;
51
+ pending;
52
+ stopped = false;
53
+ /** A tick in flight. Ticks never overlap; a slow one delays the next. */
54
+ ticking = false;
55
+ constructor(options) {
56
+ this.options = options;
57
+ this.intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
58
+ this.scheduler = options.scheduler ?? defaultScheduler;
59
+ }
60
+ /** Begin beating for a run. Idempotent on ``runId``. */
61
+ track(runId, target) {
62
+ if (this.stopped)
63
+ return;
64
+ this.targets.set(runId, { ...target, runId });
65
+ this.arm();
66
+ }
67
+ /** Stop beating for a run — it reached a terminal state, one way or another. */
68
+ untrack(runId) {
69
+ this.targets.delete(runId);
70
+ }
71
+ /** Runs currently being beaten for. Exposed for assertions and logging. */
72
+ tracked() {
73
+ return [...this.targets.keys()];
74
+ }
75
+ /** Stop the loop for good. Called once, when the harness is shutting down. */
76
+ stop() {
77
+ this.stopped = true;
78
+ this.targets.clear();
79
+ this.pending?.cancel();
80
+ this.pending = undefined;
81
+ }
82
+ /**
83
+ * Beat once for every tracked run.
84
+ *
85
+ * Public so tests can drive the cadence directly instead of waiting on a
86
+ * timer. Beats are issued concurrently: they are independent requests, and
87
+ * doing them in series would make one slow response delay every run behind it.
88
+ */
89
+ async tick() {
90
+ const targets = [...this.targets.values()];
91
+ await Promise.all(targets.map((target) => this.beat(target)));
92
+ }
93
+ arm() {
94
+ if (this.stopped || this.pending || this.ticking || this.targets.size === 0)
95
+ return;
96
+ this.pending = this.scheduler(() => {
97
+ this.pending = undefined;
98
+ void this.runTick();
99
+ }, this.intervalMs);
100
+ }
101
+ async runTick() {
102
+ this.ticking = true;
103
+ try {
104
+ await this.tick();
105
+ }
106
+ catch (err) {
107
+ // `tick` already contains every beat's failure; reaching here means the
108
+ // bookkeeping around them broke. Say so and keep the cadence — a harness
109
+ // that stops beating because of a bug here silently loses every long run.
110
+ this.options.log(` ! heartbeat loop error: ${err instanceof Error ? err.message : String(err)}`);
111
+ }
112
+ finally {
113
+ this.ticking = false;
114
+ this.arm();
115
+ }
116
+ }
117
+ /**
118
+ * One run's heartbeat, and what its answer means.
119
+ *
120
+ * Wrapped so that a throwing handler — or a fetch implementation that rejects
121
+ * rather than resolving — costs this run's beat and nothing else.
122
+ */
123
+ async beat(target) {
124
+ try {
125
+ const outcome = await heartbeatRun(this.options.api, target.path, target.progress?.());
126
+ // The run may have finished while its own heartbeat was in flight; acting
127
+ // on a stale answer would cancel a run that is already reporting itself.
128
+ if (!this.targets.has(target.runId))
129
+ return;
130
+ if (outcome.kind === 'alive') {
131
+ if (!outcome.cancelRequested)
132
+ return;
133
+ this.untrack(target.runId);
134
+ this.options.log(` ⏹ run ${target.runId.slice(0, 12)} was cancelled from ProhostAI — stopping it`);
135
+ await target.onCancelRequested();
136
+ return;
137
+ }
138
+ if (outcome.kind === 'unknown') {
139
+ // Said once per run, then silence: on a server that predates
140
+ // heartbeats this is the answer for every run forever, and a line a
141
+ // minute per run would bury the log that operators actually read.
142
+ this.untrack(target.runId);
143
+ this.options.log(` ◦ run ${target.runId.slice(0, 12)} cannot be kept alive (HTTP 404) — this ProhostAI ` +
144
+ 'server has no heartbeat endpoint, or no longer knows this run. Long runs may be ' +
145
+ 'reaped while they are still working.');
146
+ return;
147
+ }
148
+ if (outcome.kind === 'resolved') {
149
+ this.untrack(target.runId);
150
+ this.options.log(` ⏹ run ${target.runId.slice(0, 12)} was already closed server-side — abandoning it`);
151
+ await target.onResolved();
152
+ return;
153
+ }
154
+ // Transient by assumption: keep the cadence rather than give up on the
155
+ // run. A run whose beats all fail is reaped server-side eventually, which
156
+ // is the correct end state for a machine that cannot reach the API.
157
+ this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed (HTTP ${outcome.status})` +
158
+ `${outcome.detail ? ` ${outcome.detail}` : ''}`);
159
+ }
160
+ catch (err) {
161
+ this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} threw: ` +
162
+ `${err instanceof Error ? err.message : String(err)}`);
163
+ }
164
+ }
165
+ }
@@ -12,12 +12,13 @@
12
12
  */
13
13
  export declare const MCP_SERVER_NAME = "prohost";
14
14
  /**
15
- * How Claude Code spells the wired-in tools.
15
+ * How both shaped brains spell the wired-in tools.
16
16
  *
17
- * Deliberately **not** used for Codex: that binary namespaces MCP tools its own
18
- * way, and this harness has not verified the spelling against it. Telling an
19
- * agent a tool is called something it isn't is the same class of failure as
20
- * naming an endpoint it cannot call — so the prompt names the server instead
21
- * and leaves the pattern out. See {@link ../prompt.ts}.
17
+ * Claude Code's spelling was known from its documentation. Codex's was an open
18
+ * question until 0.2.3 — the prompt named the server and left the pattern out
19
+ * rather than guess — and the answer, measured by registering a stub MCP server
20
+ * against a real `codex-cli 0.144.1` and asking the model what it had just
21
+ * called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
22
+ * both brains now get the pattern. See {@link ../prompt.ts}.
22
23
  */
23
24
  export declare const MCP_TOOL_PATTERN = "mcp__prohost__*";
package/dist/agent/mcp.js CHANGED
@@ -12,12 +12,13 @@
12
12
  */
13
13
  export const MCP_SERVER_NAME = 'prohost';
14
14
  /**
15
- * How Claude Code spells the wired-in tools.
15
+ * How both shaped brains spell the wired-in tools.
16
16
  *
17
- * Deliberately **not** used for Codex: that binary namespaces MCP tools its own
18
- * way, and this harness has not verified the spelling against it. Telling an
19
- * agent a tool is called something it isn't is the same class of failure as
20
- * naming an endpoint it cannot call — so the prompt names the server instead
21
- * and leaves the pattern out. See {@link ../prompt.ts}.
17
+ * Claude Code's spelling was known from its documentation. Codex's was an open
18
+ * question until 0.2.3 — the prompt named the server and left the pattern out
19
+ * rather than guess — and the answer, measured by registering a stub MCP server
20
+ * against a real `codex-cli 0.144.1` and asking the model what it had just
21
+ * called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
22
+ * both brains now get the pattern. See {@link ../prompt.ts}.
22
23
  */
23
24
  export const MCP_TOOL_PATTERN = `mcp__${MCP_SERVER_NAME}__*`;
@@ -22,11 +22,12 @@ export interface PromptContext {
22
22
  * How this particular brain spells the wired-in tools, e.g. `mcp__prohost__*`.
23
23
  *
24
24
  * Set only when the harness has actually verified the spelling for the brain
25
- * it is driving — Claude Code, today. Left unset for Codex, whose MCP tool
26
- * namespacing this harness has not confirmed: the prompt then names the
27
- * server without claiming a prefix, because a wrong tool name is the same
28
- * failure as a wrong endpoint. The agent can see its own tool list; it does
29
- * not need us to guess at it.
25
+ * it is driving — Claude Code and, since 0.2.3, Codex, which turned out to
26
+ * use the same convention. Left unset for every other `--exec` command,
27
+ * whose namespacing we have no way to know: the prompt then names the server
28
+ * without claiming a prefix, because a wrong tool name is the same failure as
29
+ * a wrong endpoint. The agent can see its own tool list; it does not need us
30
+ * to guess at it.
30
31
  */
31
32
  mcpToolPattern?: string;
32
33
  /**
@@ -38,6 +39,17 @@ export interface PromptContext {
38
39
  * decline work it is perfectly able to do.
39
40
  */
40
41
  toolsGated?: boolean;
42
+ /**
43
+ * How long this run may take before it is killed, in milliseconds. ``0`` is
44
+ * `--timeout 0`: no local cap at all.
45
+ *
46
+ * Threaded in from the harness rather than assumed, because the prompt used
47
+ * to say "this run has a hard time limit" without ever naming it — which an
48
+ * agent reads as "hurry", whatever the number happens to be. An agent given
49
+ * five minutes should triage; an agent given two hours should do the work.
50
+ * They cannot tell those apart from a sentence that says neither.
51
+ */
52
+ timeoutMs?: number;
41
53
  }
42
54
  /** Build the stdin prompt for one run. */
43
55
  export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
@@ -6,6 +6,50 @@
6
6
  * a shell script that greps it. The reply contract is stated explicitly so an
7
7
  * agent that would otherwise narrate its reasoning returns something sendable.
8
8
  */
9
+ /**
10
+ * Say the run's budget in a unit a reader thinks in.
11
+ *
12
+ * Rounded on purpose — the agent is being told roughly how much room it has,
13
+ * and "about 120 minutes" is the honest shape of a ceiling it will never be
14
+ * told the exact remainder of.
15
+ */
16
+ function renderBudget(timeoutMs) {
17
+ if (timeoutMs >= 90 * 60_000) {
18
+ const hours = timeoutMs / 3_600_000;
19
+ return `about ${Number.isInteger(hours) ? hours : hours.toFixed(1)} hours`;
20
+ }
21
+ if (timeoutMs >= 60_000)
22
+ return `about ${Math.round(timeoutMs / 60_000)} minutes`;
23
+ return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
24
+ }
25
+ /**
26
+ * The bullet that tells the agent how much time it actually has.
27
+ *
28
+ * Both branches are load-bearing. A named ceiling stops an agent from treating
29
+ * a two-hour budget like a five-minute one (the failure this replaced: a
30
+ * multi-minute coding task killed at 300s having been told only that a limit
31
+ * existed). And an *uncapped* run still needs a word about pace, because
32
+ * nothing is posted until the turn ends — a run that explores forever reads,
33
+ * from the thread, exactly like one that crashed.
34
+ */
35
+ function timeBudgetLines(timeoutMs) {
36
+ if (timeoutMs === undefined)
37
+ return [];
38
+ if (timeoutMs <= 0) {
39
+ return [
40
+ '- There is no fixed time limit on this turn, so work that genuinely takes',
41
+ ' a while is fine — finish it rather than cutting it short. But nothing you',
42
+ ' write is posted until the turn ends, and a long silence gets reported as a',
43
+ ' stalled run, so keep moving toward an answer rather than exploring',
44
+ ' indefinitely.',
45
+ ];
46
+ }
47
+ return [
48
+ `- You have ${renderBudget(timeoutMs)} for this whole turn. Past that you are killed`,
49
+ ' mid-thought and the person waiting gets nothing, so spend the budget on the',
50
+ ' work and leave yourself room to write the answer.',
51
+ ];
52
+ }
9
53
  /**
10
54
  * Describe one attachment.
11
55
  *
@@ -139,12 +183,27 @@ export function buildPrompt(run, context) {
139
183
  ' as you call them. Do the work rather than proposing it — but that\n' +
140
184
  ' also means a mistaken action happens for real, so check before you\n' +
141
185
  ' change or delete anything.', '- Nothing about your access changes after this turn. Do not say a tool is', ' "connecting", "loading", or about to become available, and do not', ' promise to follow up.',
186
+ // Stated up here with the other facts about the shape of the invocation,
187
+ // not buried under the tool advice: how long the agent has changes how it
188
+ // should approach the work, so it has to be read before any of the "go and
189
+ // look" bullets below.
190
+ ...timeBudgetLines(context.timeoutMs),
142
191
  // Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
143
192
  // them — so a run that inspects its loaded tools concludes it cannot
144
193
  // browse and says so to a customer, when one ToolSearch call away it
145
194
  // could. Field-observed on the first 0.2.0 deployment; phrased
146
195
  // conditionally because other `--exec` brains have no ToolSearch.
147
196
  '- If you have a ToolSearch tool, part of your toolkit is deferred and', ' invisible until loaded — web browsing (WebSearch, WebFetch) usually', ' is. Search ToolSearch for a capability before telling anyone you', ' lack it.',
197
+ // The correction to the bullet above, and it is here because the bullet
198
+ // above caused a real production failure. A paired brain searched
199
+ // ToolSearch for "bash", found nothing — ToolSearch indexes only the
200
+ // *deferred* tools, and Bash is a direct one — and concluded across a
201
+ // whole resumed session that it had no shell, while
202
+ // `--dangerously-skip-permissions` had been handing it the full toolset
203
+ // the entire time. "Search before you claim you lack it" quietly taught
204
+ // the opposite of what it meant: that a ToolSearch miss is evidence of
205
+ // absence. It is not evidence of anything.
206
+ '- A ToolSearch miss proves nothing. ToolSearch indexes only the deferred', ' tools; the tools listed directly to you are the authoritative set, and', ' a tool can be fully available while ToolSearch has never heard of it', ' (Bash is exactly this). Never reason from a ToolSearch miss to "I do', ' not have that tool".',
148
207
  // The counterpart to the ToolSearch bullet, and the same failure one level
149
208
  // up: a brain that assumes it is boxed in reports "I have no access to the
150
209
  // codebase" from a machine with the checkout, the credentials and the CLI
@@ -159,8 +218,13 @@ export function buildPrompt(run, context) {
159
218
  ' tell anyone you have no access to some repository, codebase, log, or',
160
219
  ' capability, take a QUICK look for it: the usual places a checkout',
161
220
  ' lives, `gh auth status`, whether the command is on PATH. A quick',
162
- ' look, not an audit — this run has a hard time limit and somebody',
163
- ' waiting at the end of it.',
221
+ ' look, not an audit — there is somebody waiting at the end of this.',
222
+ '- "Looking" means running the command, not inspecting your own tool',
223
+ ' list. Say you have no shell, no file access, or no checkout only',
224
+ ' after an attempt failed and you can quote what it said. Introspection',
225
+ ' is not evidence here: you were started with your tools unrestricted,',
226
+ ' so a capability you cannot find in a list is far more likely to be',
227
+ ' one you have not tried.',
164
228
  ]),
165
229
  // Only when there is something to look at. Said unconditionally it is a
166
230
  // paragraph of advice about a situation that does not exist, which is how a
@@ -16,12 +16,28 @@
16
16
  * * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
17
17
  * attempt is mirrored to this socket. Without dedupe a retry would execute
18
18
  * the command a second time and post a second reply.
19
+ *
20
+ * Runs can be long — hours — so every run this process has accepted and not yet
21
+ * reported is heartbeated (see `heartbeat.ts`), including the ones still queued
22
+ * behind the executing one. That keeps the server's lease alive and is the only
23
+ * way a stop pressed in the UI reaches a machine that accepts no connections.
19
24
  */
20
25
  import type { spawn } from 'node:child_process';
21
26
  import WebSocket from 'ws';
22
27
  import type { AgentCredentials } from './credentials.js';
23
- /** Default ceiling on one agent invocation. */
24
- export declare const DEFAULT_EXEC_TIMEOUT_MS = 300000;
28
+ /**
29
+ * Default ceiling on one agent invocation.
30
+ *
31
+ * Two hours, up from five minutes. Five was a guess made when a run meant
32
+ * "answer a mention", and it was wrong in the expensive direction: a paired
33
+ * brain given a real coding task in production was killed at 300s, mid-edit,
34
+ * and reported `timed out after 300000ms` to the person waiting. A coding
35
+ * agent's unit of work is not a chat reply, and no local clock can tell a
36
+ * wedged run from a productive one — the server's lease can, which is what
37
+ * heartbeats are for. This is now a backstop against a genuinely stuck process
38
+ * rather than a shape imposed on the work; `--timeout 0` removes it entirely.
39
+ */
40
+ export declare const DEFAULT_EXEC_TIMEOUT_MS = 7200000;
25
41
  export interface AgentRunOptions {
26
42
  /** Shell command that runs the agent, e.g. ``claude -p``. */
27
43
  exec: string;
@@ -30,7 +46,7 @@ export interface AgentRunOptions {
30
46
  url?: string;
31
47
  /** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
32
48
  machine?: string;
33
- /** Per-run execution timeout. */
49
+ /** Per-run execution timeout. ``0`` disables it — see `--timeout 0`. */
34
50
  timeoutMs?: number;
35
51
  /** Print the reply that would be posted and make no writes. */
36
52
  dryRun?: boolean;
@@ -59,8 +75,26 @@ export interface AgentRunOptions {
59
75
  maxConnections?: number;
60
76
  /** Injectable sleep so tests don't wait out the reconnect backoff. */
61
77
  sleepImpl?: (ms: number) => Promise<void>;
78
+ /**
79
+ * Injectable short-command runner, used before Codex runs to ask which MCP
80
+ * servers the operator's current config defines. Separate from
81
+ * {@link spawnImpl} because it is synchronous and captures stdout, and
82
+ * because a test wants to answer this question without answering the other.
83
+ * The second argument is the exact cwd the eventual Codex process receives;
84
+ * Codex discovers project-local config from it.
85
+ */
86
+ probeImpl?: (command: string, cwd?: string) => {
87
+ status: number | null;
88
+ stdout: string;
89
+ };
62
90
  /** Environment used to locate the run ledger (tests point this at a temp dir). */
63
91
  env?: NodeJS.ProcessEnv;
92
+ /**
93
+ * How often open runs are heartbeated. Defaults to
94
+ * {@link HEARTBEAT_INTERVAL_MS}; tests shorten it so a cancellation arrives
95
+ * inside a test's lifetime.
96
+ */
97
+ heartbeatIntervalMs?: number;
64
98
  }
65
99
  export interface AgentRunMetrics {
66
100
  connections: number;
@@ -81,6 +115,26 @@ export interface AgentRunMetrics {
81
115
  */
82
116
  fatal?: string;
83
117
  }
118
+ interface PreparedMcp {
119
+ /** Claude Code: path of the strict generated MCP config. */
120
+ mcpConfigPath?: string;
121
+ /** Codex: entries in the single verified `mcp_servers` override. */
122
+ codexMcpEntries?: string[];
123
+ /** Credentials moved out of Codex's process argv. */
124
+ execEnv?: NodeJS.ProcessEnv;
125
+ /** ProhostAI tools are actually reachable from this invocation. */
126
+ mcpWired: boolean;
127
+ /** A fail-closed preparation error. When set, the agent is never spawned. */
128
+ error?: string;
129
+ }
130
+ /**
131
+ * Prepare a fresh Codex MCP override immediately before each invocation.
132
+ *
133
+ * No cache is safe here: Codex rereads its config per invocation and the
134
+ * extras file is the operator's live grant list. Fresh reads make both a new
135
+ * configured server and a revoked extra visible to the very next run.
136
+ */
137
+ export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string): () => PreparedMcp;
84
138
  /**
85
139
  * Run the harness until the abort signal fires (or ``maxConnections`` is hit).
86
140
  *
@@ -88,3 +142,4 @@ export interface AgentRunMetrics {
88
142
  * is fatal, because retrying a rejected credential just hammers the server.
89
143
  */
90
144
  export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;
145
+ export {};