@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.
package/dist/agent/run.js CHANGED
@@ -16,24 +16,43 @@
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 os from 'node:os';
26
+ import { spawnSync } from 'node:child_process';
21
27
  import WebSocket from 'ws';
22
28
  import { completeRun, postReply } from './api.js';
23
- import { claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
24
- import { MCP_API_KEY_ENV_VAR, codexCommand, isCodexExec, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
29
+ import { claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
30
+ import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
31
+ import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
25
32
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
26
- import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, eventData, parseRunRequest, } from './contract.js';
33
+ import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
27
34
  import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
28
35
  import { execAgent } from './exec.js';
36
+ import { HeartbeatLoop } from './heartbeat.js';
29
37
  import { buildPrompt, replyFromStdout } from './prompt.js';
30
38
  import { RunStore } from './runstore.js';
31
39
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
32
40
  import { verifySignature } from './signature.js';
33
41
  import { isWebhookEvent } from '../forward.js';
34
42
  import { DEFAULT_WS_URL } from '../listen.js';
35
- /** Default ceiling on one agent invocation. */
36
- export const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
43
+ /**
44
+ * Default ceiling on one agent invocation.
45
+ *
46
+ * Two hours, up from five minutes. Five was a guess made when a run meant
47
+ * "answer a mention", and it was wrong in the expensive direction: a paired
48
+ * brain given a real coding task in production was killed at 300s, mid-edit,
49
+ * and reported `timed out after 300000ms` to the person waiting. A coding
50
+ * agent's unit of work is not a chat reply, and no local clock can tell a
51
+ * wedged run from a productive one — the server's lease can, which is what
52
+ * heartbeats are for. This is now a backstop against a genuinely stuck process
53
+ * rather than a shape imposed on the work; `--timeout 0` removes it entirely.
54
+ */
55
+ export const DEFAULT_EXEC_TIMEOUT_MS = 7_200_000;
37
56
  /** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
38
57
  const RECONNECT_BASE_DELAY_MS = 1_000;
39
58
  const RECONNECT_MAX_DELAY_MS = 30_000;
@@ -64,8 +83,18 @@ function backoffDelay(attempt) {
64
83
  const cap = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
65
84
  return Math.floor(Math.random() * cap);
66
85
  }
67
- /** Backoff schedule for retrying a completion the server didn't accept. */
68
- const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000];
86
+ /**
87
+ * Backoff schedule for retrying a completion the server didn't accept.
88
+ *
89
+ * Roughly ten minutes in total, up from thirteen seconds. The old schedule was
90
+ * sized for a five-minute run: losing the completion meant losing five minutes
91
+ * of work, and a laptop that has been offline for thirteen seconds is usually
92
+ * offline for longer. Now a run can be four hours of an agent's work, which
93
+ * makes waiting out a wifi drop overwhelmingly the better bet — and the run
94
+ * keeps being heartbeated for the whole retry window, so the row it is trying
95
+ * to close stays alive rather than being reaped underneath the retries.
96
+ */
97
+ const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000, 30_000, 60_000, 120_000, 180_000, 240_000];
69
98
  /**
70
99
  * Complete a run, retrying transient failures.
71
100
  *
@@ -116,13 +145,12 @@ function resolveWorkdir(options, log) {
116
145
  }
117
146
  }
118
147
  /**
119
- * Assemble the per-process runtime: workspace, session ledger, and — for Claude
120
- * Code only — the MCP config that gives the agent tools.
148
+ * Assemble the per-process runtime: workspace, session ledger, and the
149
+ * brain-specific MCP preparation used immediately before each run.
121
150
  *
122
- * Every step here is allowed to fail without stopping the harness. An agent
123
- * that answers from the prompt alone is the product as it shipped; refusing to
124
- * start because a config file couldn't be written would be a regression
125
- * dressed up as a safety check.
151
+ * Brain-specific MCP preparation happens per run. A daemon is long-lived, so
152
+ * an extras-file deletion or newly installed Codex server must take effect on
153
+ * the next invocation rather than after a restart or cache timeout.
126
154
  */
127
155
  function buildRuntime(options, log) {
128
156
  const workdir = resolveWorkdir(options, log);
@@ -134,53 +162,51 @@ function buildRuntime(options, log) {
134
162
  : 'plain';
135
163
  const skipPermissions = options.safeTools !== true;
136
164
  if (brain === 'plain') {
137
- return { workdir, sessions, brain, skipPermissions, mcpWired: false };
165
+ return { workdir, sessions, brain, skipPermissions, prepareMcp: () => ({ mcpWired: false }) };
138
166
  }
139
167
  const mcpUrl = options.credentials.mcp_url;
140
- let mcpConfigPath;
141
- let codexMcpUrl;
142
- let execEnv;
168
+ let announced = '';
169
+ const announce = (lines) => {
170
+ const message = lines.join('\n');
171
+ if (message === announced)
172
+ return;
173
+ for (const line of lines)
174
+ log(line);
175
+ announced = message;
176
+ };
143
177
  if (!mcpUrl) {
144
178
  log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
145
179
  }
146
- else if (brain === 'claude') {
147
- try {
148
- mcpConfigPath = writeMcpConfig({
149
- mcpUrl,
150
- apiKey: options.credentials.api_key,
151
- env: options.env,
152
- });
153
- }
154
- catch (err) {
155
- // Said out loud rather than swallowed: the visible symptom would
156
- // otherwise be an agent that answers "I can't look that up", which reads
157
- // like a model problem and sends someone to the wrong file.
158
- log(`${timestamp()} ! could not write the MCP config ` +
159
- `(${err instanceof Error ? err.message : String(err)}); the agent will run without ` +
160
- 'ProhostAI tools');
180
+ const prepareMcp = brain === 'claude'
181
+ ? () => {
182
+ const extras = loadExtraMcpServers({ env: options.env });
183
+ announce(extras.warnings.map((warning) => `${timestamp()} ! ${warning}`));
184
+ // Always pass an explicit strict config, including when it is empty.
185
+ // Omitting the path would make `--strict-mcp-config` ineffective and
186
+ // expose Claude's user/project MCP servers under skipped permissions.
187
+ try {
188
+ const mcpConfigPath = writeMcpConfig({
189
+ mcpUrl,
190
+ apiKey: options.credentials.api_key,
191
+ extraServers: extras.servers,
192
+ env: options.env,
193
+ });
194
+ return { mcpConfigPath, mcpWired: Boolean(mcpUrl) };
195
+ }
196
+ catch (err) {
197
+ const detail = err instanceof Error ? err.message : String(err);
198
+ return {
199
+ mcpWired: false,
200
+ error: `could not write the strict Claude MCP config (${detail})`,
201
+ };
202
+ }
161
203
  }
162
- }
163
- else {
164
- // Codex needs no file: the server is a `-c` override on each invocation and
165
- // the key is read from the child's environment. Nothing to fail, so nothing
166
- // to fall back from.
167
- codexMcpUrl = mcpUrl;
168
- execEnv = { [MCP_API_KEY_ENV_VAR]: options.credentials.api_key };
169
- }
170
- const mcpWired = Boolean(mcpConfigPath ?? codexMcpUrl);
171
- const tools = mcpWired ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '';
204
+ : codexMcpEntryProvider(options, log, workdir);
205
+ const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
172
206
  const where = workdir ? ` (workspace: ${workdir})` : '';
173
207
  log(brain === 'claude'
174
208
  ? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
175
209
  : `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
176
- if (brain === 'codex' && mcpWired) {
177
- // The one asymmetry worth an operator knowing about, because it is a
178
- // security property they may be assuming they have. Claude Code is told to
179
- // load our config and nothing else; Codex has no equivalent switch, so its
180
- // own `~/.codex/config.toml` servers come along.
181
- log(`${timestamp()} ! Codex has no --strict-mcp-config equivalent: this run also holds ` +
182
- 'whatever MCP servers your ~/.codex/config.toml defines.');
183
- }
184
210
  // Said out loud every start-up, not buried in --help. This is the line that
185
211
  // tells an operator their machine will run tool calls, unprompted, on text
186
212
  // written by someone else in their workspace.
@@ -191,7 +217,153 @@ function buildRuntime(options, log) {
191
217
  'access, on instructions from your workspace. Use --safe-tools to gate them.'
192
218
  : `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
193
219
  'approval will not run (nobody is at a terminal to approve it).');
194
- return { workdir, sessions, brain, mcpConfigPath, codexMcpUrl, execEnv, mcpWired, skipPermissions };
220
+ return { workdir, sessions, brain, prepareMcp, skipPermissions };
221
+ }
222
+ /**
223
+ * Prepare a fresh Codex MCP override immediately before each invocation.
224
+ *
225
+ * No cache is safe here: Codex rereads its config per invocation and the
226
+ * extras file is the operator's live grant list. Fresh reads make both a new
227
+ * configured server and a revoked extra visible to the very next run.
228
+ */
229
+ export function codexMcpEntryProvider(options, log, workdir) {
230
+ let announced = '';
231
+ return () => {
232
+ const extras = loadExtraMcpServers({ env: options.env });
233
+ const lines = extras.warnings.map((warning) => `${timestamp()} ! ${warning}`);
234
+ const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir);
235
+ const said = lines.join('\n');
236
+ if (said !== announced) {
237
+ for (const line of lines)
238
+ log(line);
239
+ announced = said;
240
+ }
241
+ return prepared;
242
+ };
243
+ }
244
+ /*
245
+ * Every `mcp_servers` entry a Codex run gets: ours, the operator's extras, and
246
+ * verified switches that keep the operator's own servers out. Preparation is
247
+ * fail-closed: an unprovable boundary never reaches an unsandboxed agent.
248
+ */
249
+ function codexMcpEntries(options, extraServers, log, workdir) {
250
+ const fail = (detail) => {
251
+ log(`${timestamp()} ! ${detail}`);
252
+ return { mcpWired: false, error: detail };
253
+ };
254
+ const probe = options.probeImpl ?? defaultProbe;
255
+ if (codexShellSyntax(options.exec)) {
256
+ return fail('--exec uses shell quoting, expansion, glob, comment, escape, or control syntax that the Codex ' +
257
+ 'isolation verifier cannot tokenize safely; use a plain argv-shaped Codex command');
258
+ }
259
+ const binary = codexBinary(options.exec);
260
+ const entries = [];
261
+ const execEnv = {};
262
+ if (options.credentials.mcp_url) {
263
+ entries.push(prohostMcpEntry(options.credentials.mcp_url));
264
+ execEnv[MCP_API_KEY_ENV_VAR] = options.credentials.api_key;
265
+ }
266
+ const profile = codexProfileName(options.exec);
267
+ if (profile) {
268
+ return fail(`--exec runs Codex under profile "${profile}", whose MCP servers cannot be enumerated; ` +
269
+ 'remove the profile flag before running this harness');
270
+ }
271
+ const inventoryFlag = codexMcpInventoryFlag(options.exec);
272
+ if (inventoryFlag) {
273
+ return fail(`--exec includes ${inventoryFlag}, which can change Codex's MCP or plugin inventory, or its ` +
274
+ `working directory, outside the configuration the harness can verify; remove cwd, config, feature, ` +
275
+ 'and --ignore-user-config flags');
276
+ }
277
+ const configured = listMcpServers(binary, probe, undefined, workdir);
278
+ if (configured === null) {
279
+ return fail('could not ask Codex which MCP servers it has configured');
280
+ }
281
+ const configuredNames = new Set(configured.map((server) => server.name));
282
+ if (configuredNames.has(MCP_SERVER_NAME)) {
283
+ return fail(`your Codex config already defines the reserved MCP server name "${MCP_SERVER_NAME}"; ` +
284
+ 'rename or remove it so credentials cannot be merged into the paired server');
285
+ }
286
+ const collisions = Object.keys(extraServers).filter((name) => configuredNames.has(name));
287
+ if (collisions.length > 0) {
288
+ log(`${timestamp()} ! ${EXTRA_MCP_FILENAME} reuses names your Codex config already defines, ` +
289
+ `so those entries were ignored (Codex would merge their credentials): ${collisions.join(', ')}. Rename them to use them.`);
290
+ }
291
+ const usableExtras = Object.fromEntries(Object.entries(extraServers).filter(([name]) => !collisions.includes(name)));
292
+ const extra = extraMcpEntries(usableExtras);
293
+ if (extra.skipped.length > 0) {
294
+ log(`${timestamp()} ! could not pass these extra MCP servers to Codex ` +
295
+ `(value not expressible as a safe config override): ${extra.skipped.join(', ')}`);
296
+ }
297
+ let acceptedExtraEntries = [];
298
+ if (extra.entries.length > 0) {
299
+ if (listMcpServers(binary, probe, mcpServersOverride(extra.entries), workdir) === null) {
300
+ log(`${timestamp()} ! Codex rejected the server definitions in ${EXTRA_MCP_FILENAME}; ` +
301
+ 'the run will continue without those optional extras');
302
+ }
303
+ else {
304
+ acceptedExtraEntries = extra.entries;
305
+ entries.push(...acceptedExtraEntries);
306
+ Object.assign(execEnv, extra.env);
307
+ }
308
+ }
309
+ // A configured collision was dropped above, so it is deliberately disabled.
310
+ const acceptedExtraNames = new Set(Object.keys(usableExtras).filter((name) => !extra.skipped.includes(name)));
311
+ if (acceptedExtraEntries.length === 0)
312
+ acceptedExtraNames.clear();
313
+ const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
314
+ const { entries: disabled, failed } = disableEntriesThatLoad(binary, probe, targets, workdir);
315
+ if (failed.length > 0) {
316
+ return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')}`);
317
+ }
318
+ entries.push(...disabled);
319
+ if (disabled.length > 0) {
320
+ log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
321
+ }
322
+ // Verify the exact combined override, not its pieces. This catches a config
323
+ // merge that re-enables a server and a server installed between the initial
324
+ // survey and this final probe.
325
+ const override = mcpServersOverride(entries);
326
+ const finalListing = listMcpServers(binary, probe, override, workdir);
327
+ if (finalListing === null)
328
+ return fail('Codex rejected the final isolated MCP configuration');
329
+ const allowed = new Set([
330
+ ...(options.credentials.mcp_url ? [MCP_SERVER_NAME] : []),
331
+ ...acceptedExtraNames,
332
+ ]);
333
+ const enabled = new Set(finalListing.filter((server) => server.enabled).map((server) => server.name));
334
+ const missing = [...allowed].filter((name) => !enabled.has(name));
335
+ if (missing.length > 0) {
336
+ return fail(`expected MCP servers are missing or disabled after isolation: ${missing.join(', ')}`);
337
+ }
338
+ const unexpected = finalListing.filter((server) => server.enabled && !allowed.has(server.name));
339
+ if (unexpected.length > 0) {
340
+ return fail(`unexpected MCP servers remain enabled after isolation: ${unexpected.map((server) => server.name).join(', ')}`);
341
+ }
342
+ return {
343
+ codexMcpEntries: entries,
344
+ execEnv: Object.keys(execEnv).length > 0 ? execEnv : undefined,
345
+ mcpWired: Boolean(options.credentials.mcp_url),
346
+ };
347
+ }
348
+ /**
349
+ * Run one short command and capture stdout.
350
+ *
351
+ * `shell: true` for the same reason `exec.ts` uses it — the binary name comes
352
+ * out of the operator's own `--exec` string and may be a quoted path — and for
353
+ * the same reason it is safe: nothing event-derived reaches this command line.
354
+ * `cwd` must match the eventual run exactly, because Codex loads trusted
355
+ * project `.codex/config.toml` layers from there.
356
+ */
357
+ function defaultProbe(command, cwd) {
358
+ const result = spawnSync(command, {
359
+ shell: true,
360
+ cwd,
361
+ encoding: 'utf8',
362
+ // Generous for a config read, short enough that a wedged binary delays
363
+ // a run rather than wedging the daemon.
364
+ timeout: 15_000,
365
+ });
366
+ return { status: result.status, stdout: result.stdout ?? '' };
195
367
  }
196
368
  /**
197
369
  * Run the agent command once and turn its output into a reply.
@@ -202,32 +374,113 @@ function buildRuntime(options, log) {
202
374
  * tell a failed turn from a successful one. A plain command's behaviour here is
203
375
  * byte-for-byte what it was before this function existed.
204
376
  */
205
- async function runTurn(run, prompt, options, runtime, log) {
377
+ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
206
378
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
207
379
  // must not let one run take twice what the operator allowed. Both attempts
208
380
  // share one deadline, which costs the retry nothing in practice — a rejected
209
381
  // `--resume` fails in milliseconds, long before any of the budget is spent.
210
382
  const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
383
+ // `--timeout 0`: no local ceiling at all. The run then ends when the agent
384
+ // ends it, when somebody cancels it, or when the server stops renewing its
385
+ // lease — all of which are better judges of "stuck" than a fixed clock.
386
+ const capped = budgetMs > 0;
211
387
  const startedAt = Date.now();
212
388
  const remainingMs = () => budgetMs - (Date.now() - startedAt);
213
- const exec = (command) => execAgent({
214
- command,
215
- prompt,
216
- timeoutMs: remainingMs(),
217
- cwd: runtime.workdir,
218
- env: runtime.execEnv,
219
- spawnImpl: options.spawnImpl,
220
- });
389
+ const timeLeft = () => !capped || remainingMs() > 0;
390
+ // Which surface's session this turn belongs to, and `undefined` on a dry run
391
+ // — which is what makes every `remember`/`forget` below a no-op there.
392
+ // `--dry-run` promises to make no writes, and a resumed turn is a write: both
393
+ // brains record it in their transcript whether or not we post the reply, so
394
+ // remembering the session would leave the next *real* run continuing from an
395
+ // answer nobody saw. Resolved once, up here, because the streaming reader
396
+ // records it mid-run rather than at the end of the turn.
397
+ const sessionKey = options.dryRun ? undefined : sessionKeyFor(run);
398
+ let recordedSession;
399
+ /** The reader for the attempt currently running, if this brain streams. */
400
+ let stream;
401
+ /**
402
+ * Act on what the stream has said so far.
403
+ *
404
+ * The session id is written to the ledger **as soon as it is announced**,
405
+ * which is a deliberate departure from "record it once the reply is durable".
406
+ * That rule protects against continuing a session whose answer nobody saw;
407
+ * it cannot protect a run that is killed at hour two, because that run never
408
+ * reaches the end where the recording used to happen — and losing the id
409
+ * loses the only handle on two hours of the agent's context. The rejected-
410
+ * reply case is handled where it happens instead, by forgetting again.
411
+ */
412
+ const observe = (reader) => {
413
+ const id = reader.sessionId();
414
+ if (id && id !== recordedSession) {
415
+ recordedSession = id;
416
+ runtime.sessions.remember(sessionKey, id);
417
+ }
418
+ const activity = reader.activity();
419
+ if (activity)
420
+ control.report(activity);
421
+ };
422
+ /**
423
+ * Undo an eagerly recorded Claude session after an ordinary failed turn.
424
+ *
425
+ * Timeouts and cancellations deliberately keep their session so the next
426
+ * delivery can continue a long-running turn whose context would otherwise be
427
+ * lost. A command or parser failure has no such continuity guarantee: its
428
+ * answer was never made durable, so resuming it would skip an unseen turn.
429
+ */
430
+ const forgetRecordedSession = () => {
431
+ if (!recordedSession)
432
+ return;
433
+ runtime.sessions.forget(sessionKey);
434
+ recordedSession = undefined;
435
+ };
436
+ const exec = (command) => {
437
+ // A fresh reader per attempt: a retry without `--resume` is a different
438
+ // session, and inheriting the failed attempt's id would resume nothing.
439
+ const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
440
+ stream = reader;
441
+ return execAgent({
442
+ command,
443
+ prompt,
444
+ timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
445
+ cwd: runtime.workdir,
446
+ env: mcp.execEnv,
447
+ spawnImpl: options.spawnImpl,
448
+ cancelSignal: control.signal,
449
+ onStdout: reader
450
+ ? (chunk) => {
451
+ reader.push(chunk);
452
+ observe(reader);
453
+ }
454
+ : undefined,
455
+ });
456
+ };
221
457
  // Wall clock for the whole turn, retry included — what the operator waited,
222
458
  // rather than what the last attempt took.
223
459
  const elapsed = () => Date.now() - startedAt;
224
- const failure = (result) => ({
225
- ok: false,
226
- reason: result.error ??
227
- (result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
228
- detail: result.stderr.trim().slice(-500),
229
- durationMs: elapsed(),
230
- });
460
+ if (mcp.error) {
461
+ return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
462
+ }
463
+ const failure = (result) => {
464
+ // A killed run has no reply, but it usually has most of one. Reporting the
465
+ // tail of what the agent was writing turns "timed out after 7200000ms" into
466
+ // something the person waiting can act on — and it is the only trace left
467
+ // of however many hours of work.
468
+ const salvaged = result.timedOut || result.cancelled ? stream?.salvage() : undefined;
469
+ // The stderr tail yields room when there is a salvaged answer to carry: the
470
+ // completion `error` is capped server-side, and of the two, what the agent
471
+ // was saying beats the last 500 characters of its logging.
472
+ const stderrTail = result.stderr.trim().slice(salvaged ? -200 : -500);
473
+ const detail = [stderrTail, salvaged ? `unfinished answer: ${salvaged}` : '']
474
+ .filter((part) => part.length > 0)
475
+ .join('\n');
476
+ return {
477
+ ok: false,
478
+ reason: result.error ??
479
+ (result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
480
+ detail,
481
+ durationMs: elapsed(),
482
+ };
483
+ };
231
484
  if (runtime.brain === 'plain') {
232
485
  const result = await exec(options.exec);
233
486
  if (!result.ok)
@@ -237,24 +490,19 @@ async function runTurn(run, prompt, options, runtime, log) {
237
490
  ? { ok: true, reply, durationMs: elapsed() }
238
491
  : { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
239
492
  }
240
- // No session bookkeeping on a dry run. `--dry-run` promises to make no
241
- // writes, and a resumed turn is a write: both brains record it in their
242
- // transcript whether or not we post the reply, so remembering the session
243
- // would leave the next *real* run continuing from an answer nobody saw.
244
- const key = options.dryRun ? undefined : sessionKeyFor(run);
245
- const sessionId = runtime.sessions.get(key);
493
+ const sessionId = runtime.sessions.get(sessionKey);
246
494
  const codex = runtime.brain === 'codex';
247
495
  // Same three questions for both brains — resume this thread, wire these
248
496
  // tools, gate or don't — asked with each one's own flags.
249
497
  const build = (resume) => codex
250
498
  ? codexCommand(options.exec, {
251
499
  sessionId: resume,
252
- mcpUrl: runtime.codexMcpUrl,
500
+ mcpEntries: mcp.codexMcpEntries,
253
501
  skipPermissions: runtime.skipPermissions,
254
502
  })
255
503
  : claudeCommand(options.exec, {
256
504
  sessionId: resume,
257
- mcpConfigPath: runtime.mcpConfigPath,
505
+ mcpConfigPath: mcp.mcpConfigPath,
258
506
  skipPermissions: runtime.skipPermissions,
259
507
  });
260
508
  let result = await exec(build(sessionId));
@@ -268,15 +516,31 @@ async function runTurn(run, prompt, options, runtime, log) {
268
516
  // Skipped rather than squeezed into a token budget: a retry with no time
269
517
  // left would be killed on the spot and reported as a timeout, which is a
270
518
  // worse error message than the resume failure it replaced.
271
- remainingMs() > 0) {
519
+ timeLeft() &&
520
+ // A run somebody just stopped does not get started again on its way out.
521
+ !control.signal.aborted) {
272
522
  log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
273
- runtime.sessions.forget(key);
523
+ runtime.sessions.forget(sessionKey);
524
+ recordedSession = undefined;
274
525
  result = await exec(build());
275
526
  }
276
- if (!result.ok)
527
+ if (!result.ok) {
528
+ if (!result.timedOut && !result.cancelled)
529
+ forgetRecordedSession();
277
530
  return failure(result);
531
+ }
532
+ // A denied tool is not an error either brain reports — the turn succeeds and
533
+ // the reply just quietly lacks whatever the tool would have found. Printing
534
+ // it is the difference between "the agent seems dumb" and "the agent asked
535
+ // for WebSearch and there was nobody to approve it".
536
+ const reportDenials = (denials) => {
537
+ if (denials.length > 0) {
538
+ log(` ! tools the agent asked for and was refused: ${denials.join(', ')}`);
539
+ }
540
+ };
278
541
  if (codex) {
279
542
  const output = parseCodexOutput(result.stdout);
543
+ reportDenials(output.denials);
280
544
  if (!output.ok) {
281
545
  return {
282
546
  ok: false,
@@ -287,15 +551,15 @@ async function runTurn(run, prompt, options, runtime, log) {
287
551
  }
288
552
  return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
289
553
  }
290
- const parsed = parseClaudeOutput(result.stdout);
291
- // A denied tool is not an error the agent reports — the turn succeeds and the
292
- // reply just quietly lacks whatever the tool would have found. Printing it is
293
- // the difference between "the agent seems dumb" and "the agent asked for
294
- // WebSearch and there was nobody to approve it".
295
- if (parsed.denials.length > 0) {
296
- log(` ! tools the agent asked for and was refused: ${parsed.denials.join(', ')}`);
297
- }
554
+ // The reply comes out of the stream's own `result` event, so a run whose
555
+ // stdout we watched arrives already parsed. `finish` still gets the captured
556
+ // stdout: a command that emitted no result event at all — a login prompt, a
557
+ // crash banner, a `claude` that ignored `--output-format` — is a failure, and
558
+ // the whole-output parser is what says so in words.
559
+ const parsed = (stream ?? readClaudeStream()).finish(result.stdout);
560
+ reportDenials(parsed.denials);
298
561
  if (!parsed.ok) {
562
+ forgetRecordedSession();
299
563
  return {
300
564
  ok: false,
301
565
  reason: 'the agent did not produce a usable reply',
@@ -303,7 +567,13 @@ async function runTurn(run, prompt, options, runtime, log) {
303
567
  durationMs: elapsed(),
304
568
  };
305
569
  }
306
- return { ok: true, reply: parsed.text, durationMs: elapsed(), sessionId: parsed.sessionId };
570
+ return {
571
+ ok: true,
572
+ reply: parsed.text,
573
+ durationMs: elapsed(),
574
+ sessionId: parsed.sessionId,
575
+ sessionRecorded: recordedSession !== undefined,
576
+ };
307
577
  }
308
578
  /**
309
579
  * Handle one verified run end-to-end.
@@ -311,23 +581,54 @@ async function runTurn(run, prompt, options, runtime, log) {
311
581
  * Every path except a dry run — including every failure — calls the completion
312
582
  * endpoint before returning.
313
583
  */
314
- async function handleRun(run, options, runtime, api, log, sleep) {
584
+ async function handleRun(run, options, runtime, api, log, sleep, control) {
315
585
  const label = `run ${run.run_id.slice(0, 12)}`;
316
586
  const surface = run.conversation_id ?? run.kind ?? 'no conversation';
317
587
  log(`${timestamp()} ▶ ${label} started (${surface})`);
588
+ const mcp = runtime.prepareMcp();
318
589
  const prompt = buildPrompt(run, {
319
590
  agentName: options.credentials.agent?.name ?? 'your agent',
320
591
  // Named only when the tools are actually reachable from this invocation.
321
592
  // Telling an agent about an endpoint it has no client for is what produced
322
593
  // replies describing lookups that never happened.
323
- mcpServerName: runtime.mcpWired ? MCP_SERVER_NAME : undefined,
324
- // Only Claude Code's spelling has been verified against the binary, so only
325
- // Claude Code gets told a pattern. See PromptContext.mcpToolPattern.
326
- mcpToolPattern: runtime.brain === 'claude' && runtime.mcpWired ? MCP_TOOL_PATTERN : undefined,
594
+ mcpServerName: mcp.mcpWired ? MCP_SERVER_NAME : undefined,
595
+ // Both shaped brains spell MCP tools `mcp__<server>__<tool>` — Codex's was
596
+ // an open question until it was measured against the binary in 0.2.3. A
597
+ // `plain` command is still told the server name only: we have no idea what
598
+ // it is. See PromptContext.mcpToolPattern.
599
+ mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
327
600
  toolsGated: !runtime.skipPermissions,
601
+ // The real number, not a vague "there is a limit" — see PromptContext.
602
+ timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
328
603
  });
329
- const result = await runTurn(run, prompt, options, runtime, log);
604
+ const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
330
605
  const seconds = (result.durationMs / 1000).toFixed(1);
606
+ // Stopped from outside. Checked before the failure branch below, because a
607
+ // stopped run *is* a failed command — reporting it as one would put "timed
608
+ // out"-shaped noise in a thread where somebody deliberately pressed stop.
609
+ const stopped = control.disposition();
610
+ if (stopped === 'resolved') {
611
+ // The server already closed this run: reaped after its lease lapsed, or
612
+ // resolved by something else. There is nothing left to post a reply to and
613
+ // nothing left to complete — a completion would just 409.
614
+ log(`${timestamp()} ⏹ ${label} was already closed server-side (${seconds}s) — nothing was posted`);
615
+ return 'resolved';
616
+ }
617
+ if (stopped === 'cancelled') {
618
+ log(`${timestamp()} ⏹ ${label} cancelled after ${seconds}s`);
619
+ if (options.dryRun) {
620
+ log(' [dry-run] would report the run as cancelled');
621
+ return 'skipped';
622
+ }
623
+ // The exact marker string, alone: the server words the thread notice from
624
+ // it, and anything wrapped around it reads as a crash instead of a stop.
625
+ const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
626
+ if (!closed.ok) {
627
+ log(` ! could not report the cancellation (HTTP ${closed.status}) ${closed.error ?? ''}`);
628
+ return 'unreported';
629
+ }
630
+ return 'cancelled';
631
+ }
331
632
  if (!result.ok) {
332
633
  const { reason } = result;
333
634
  const detail = result.detail ?? '';
@@ -378,6 +679,12 @@ async function handleRun(run, options, runtime, api, log, sleep) {
378
679
  const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
379
680
  if (!posted.ok) {
380
681
  log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
682
+ // Undo the mid-run recording. The session exists and the agent's transcript
683
+ // holds this turn, but the conversation never received its answer, so
684
+ // continuing it would skip reasoning nobody can see — the same rule that
685
+ // used to be expressed by simply not recording until the reply landed.
686
+ if (result.sessionRecorded)
687
+ runtime.sessions.forget(sessionKeyFor(run));
381
688
  const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
382
689
  return reported.ok ? 'failed' : 'unreported';
383
690
  }
@@ -433,9 +740,79 @@ export async function runAgentHarness(options) {
433
740
  // duplicate — the run is in flight right here. Only a run in the ledger that
434
741
  // this process never handled is a candidate for restart recovery.
435
742
  const handledThisSession = new Set();
743
+ /** Accepted, not yet reported — the executing run and everything queued. */
744
+ const accepted = new Map();
745
+ const heartbeats = new HeartbeatLoop({ api, log, intervalMs: options.heartbeatIntervalMs });
436
746
  let queue = Promise.resolve();
437
747
  let fatal;
438
748
  let attempt = 0;
749
+ /** Stop tracking a run — it has reached a terminal state, one way or another. */
750
+ const release = (runId) => {
751
+ accepted.delete(runId);
752
+ heartbeats.untrack(runId);
753
+ };
754
+ /**
755
+ * Work started from a heartbeat that the harness must still wait for on the
756
+ * way out, but that a heartbeat tick must not wait for.
757
+ *
758
+ * Closing out a cancelled queued run can take the whole completion-retry
759
+ * schedule — around ten minutes against an unreachable server — and beats do
760
+ * not overlap. Awaited inside the tick, one unreachable completion would stop
761
+ * every other run being heartbeated, and the four-hour job it was queued
762
+ * behind would be reaped for going quiet.
763
+ */
764
+ const background = [];
765
+ /**
766
+ * Somebody pressed stop in ProhostAI.
767
+ *
768
+ * The two cases are genuinely different work. An executing run reports
769
+ * itself: the abort reaches `execAgent`, which signals the process group the
770
+ * same way a timeout does, and `handleRun` completes it with the marker
771
+ * error. A run still in the queue has nobody to report it — it will never
772
+ * execute, and the queue is about to skip it — so it is closed out here.
773
+ */
774
+ const cancelAccepted = async (entry) => {
775
+ if (entry.disposition)
776
+ return;
777
+ entry.disposition = 'cancelled';
778
+ if (entry.executing) {
779
+ entry.controller.abort();
780
+ return;
781
+ }
782
+ release(entry.run.run_id);
783
+ log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was cancelled before it started`);
784
+ // `--dry-run` promises to make no writes, and a completion is the most
785
+ // terminal write there is. The executing-run path defers to the same check
786
+ // inside `handleRun`; this one has to make it itself, because a queued run
787
+ // never reaches `handleRun` at all.
788
+ if (options.dryRun) {
789
+ log(' [dry-run] would report the run as cancelled');
790
+ return;
791
+ }
792
+ const closed = await completeWithRetry(api, entry.run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
793
+ if (closed.ok)
794
+ runs.markCompleted(entry.run.run_id);
795
+ metrics.runsFailed += 1;
796
+ };
797
+ /**
798
+ * The server has already closed this run out — it was reaped, or resolved by
799
+ * something else. Either way nothing this machine does with it can land, so
800
+ * the only correct move is to stop: kill the command if one is running, and
801
+ * post nothing at all. A completion here would be a 409 by definition.
802
+ */
803
+ const resolveAccepted = (entry) => {
804
+ if (entry.disposition)
805
+ return;
806
+ entry.disposition = 'resolved';
807
+ if (entry.executing) {
808
+ entry.controller.abort();
809
+ return;
810
+ }
811
+ release(entry.run.run_id);
812
+ // Terminal server-side, so a redelivery must not start it either.
813
+ runs.markCompleted(entry.run.run_id);
814
+ log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was closed server-side before it started`);
815
+ };
439
816
  const exhausted = () => options.maxConnections !== undefined && metrics.connections >= options.maxConnections;
440
817
  while (!options.signal?.aborted && fatal === undefined && !exhausted()) {
441
818
  metrics.connections += 1;
@@ -449,8 +826,14 @@ export async function runAgentHarness(options) {
449
826
  log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
450
827
  await sleep(delay);
451
828
  }
452
- // Let an in-flight run finish reporting before we hand back metrics.
829
+ // Let an in-flight run finish reporting before we hand back metrics — and
830
+ // any cancellation the heartbeat loop started and deliberately did not wait
831
+ // for, so the harness never exits with a run half-closed.
453
832
  await queue;
833
+ // Stopped first, so no further tick can start work after the wait below has
834
+ // taken its snapshot of `background`.
835
+ heartbeats.stop();
836
+ await Promise.all(background);
454
837
  if (fatal !== undefined) {
455
838
  log(`${timestamp()} ✗ ${fatal}`);
456
839
  metrics.fatal = fatal;
@@ -598,13 +981,36 @@ export async function runAgentHarness(options) {
598
981
  // redelivery execute the agent a second time.
599
982
  runs.markStarted(run.run_id, run.completion_path);
600
983
  metrics.runsStarted += 1;
984
+ // Heartbeat from the moment it is accepted, not from the moment it
985
+ // starts. The row is `running` server-side either way, and a run that
986
+ // waits an hour behind a long job is reaped exactly like one that has
987
+ // been working for an hour.
988
+ const entry = { run, controller: new AbortController(), executing: false };
989
+ accepted.set(run.run_id, entry);
990
+ heartbeats.track(run.run_id, {
991
+ path: heartbeatPathFor(run),
992
+ progress: () => entry.progress,
993
+ // Deliberately not awaited here — see `background`.
994
+ onCancelRequested: () => {
995
+ background.push(cancelAccepted(entry).catch((err) => {
996
+ log(`${timestamp()} ✗ closing cancelled run ${run.run_id} failed: ${String(err)}`);
997
+ }));
998
+ },
999
+ onResolved: () => resolveAccepted(entry),
1000
+ });
601
1001
  // Serialize execution but never block the socket reader: the ack and
602
1002
  // the heartbeat have to keep flowing while the agent thinks.
603
1003
  queue = queue.then(async () => {
1004
+ // Settled while it waited — cancelled, or closed server-side. Both
1005
+ // were dealt with where they were decided; there is nothing to run
1006
+ // and nothing left to report.
1007
+ if (entry.disposition)
1008
+ return;
604
1009
  // Shutdown was requested while this sat in the queue. Don't start a
605
1010
  // fresh command the operator is waiting to be rid of — but do close
606
1011
  // the run out, so the server isn't left waiting on it either.
607
1012
  if (options.signal?.aborted) {
1013
+ release(run.run_id);
608
1014
  log(`${timestamp()} ⏹ run ${run.run_id.slice(0, 12)} dropped — harness is shutting down`);
609
1015
  const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: 'the harness shut down before this run started' }, log, sleep);
610
1016
  if (closed.ok)
@@ -612,20 +1018,42 @@ export async function runAgentHarness(options) {
612
1018
  metrics.runsFailed += 1;
613
1019
  return;
614
1020
  }
615
- const outcome = await handleRun(run, options, runtime, api, log, sleep).catch((err) => {
1021
+ // Set before the first `await`, so a cancellation arriving from the
1022
+ // heartbeat either sees a queued run (and closes it out itself) or an
1023
+ // executing one (and aborts it) — never both, and never neither.
1024
+ entry.executing = true;
1025
+ const control = {
1026
+ signal: entry.controller.signal,
1027
+ disposition: () => entry.disposition,
1028
+ report: (activity) => {
1029
+ entry.progress = activity;
1030
+ },
1031
+ };
1032
+ const outcome = await handleRun(run, options, runtime, api, log, sleep, control).catch((err) => {
616
1033
  log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
617
1034
  return 'failed';
618
1035
  });
1036
+ // Held until here on purpose: heartbeats keep the row alive for the
1037
+ // whole of `handleRun`, including the ten minutes of completion
1038
+ // retries at the end of it.
1039
+ release(run.run_id);
619
1040
  // Both terminal outcomes reached the completion endpoint; only then
620
1041
  // is the run settled and safe to skip on redelivery.
621
1042
  if (outcome === 'succeeded')
622
1043
  metrics.runsSucceeded += 1;
623
- if (outcome === 'failed' || outcome === 'unreported')
1044
+ // A cancellation is reported to the server as `failed`, and counted
1045
+ // the same way here — the run did not produce the answer it was for.
1046
+ if (outcome === 'failed' || outcome === 'unreported' || outcome === 'cancelled') {
624
1047
  metrics.runsFailed += 1;
1048
+ }
625
1049
  // Only a run the server acknowledged as terminal is settled; an
626
1050
  // unreported one stays open so a redelivery can report it.
627
- if (outcome === 'succeeded' || outcome === 'failed')
1051
+ if (outcome === 'succeeded' ||
1052
+ outcome === 'failed' ||
1053
+ outcome === 'cancelled' ||
1054
+ outcome === 'resolved') {
628
1055
  runs.markCompleted(run.run_id);
1056
+ }
629
1057
  });
630
1058
  });
631
1059
  ws.on('close', () => {