@prohost/cli 0.3.0 → 0.4.1

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
@@ -26,8 +26,8 @@ import os from 'node:os';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import WebSocket from 'ws';
28
28
  import { completeRun, postReply } from './api.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';
29
+ import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
30
+ import { MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
31
31
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
32
32
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
33
33
  import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
@@ -41,18 +41,36 @@ import { verifySignature } from './signature.js';
41
41
  import { isWebhookEvent } from '../forward.js';
42
42
  import { DEFAULT_WS_URL } from '../listen.js';
43
43
  /**
44
- * Default ceiling on one agent invocation.
44
+ * Default ceiling on one agent invocation: none.
45
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.
46
+ * The wall clock kept being raised — five minutes, then two hours — because it
47
+ * was answering the wrong question. "How long has this run been going?" does
48
+ * not distinguish a wedged child from a productive one; it only distinguishes
49
+ * long work from short, and killing long work is precisely the failure the two
50
+ * previous defaults each produced in production. What the harness can actually
51
+ * observe is whether the child is *doing* anything, which is what
52
+ * {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
53
+ *
54
+ * So a run is now bounded by three things, none of them a fixed duration:
55
+ * the inactivity watchdog (default 15 minutes of total silence), the server's
56
+ * lease on the run (heartbeats stop, the row is reaped), and the Stop button in
57
+ * ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
58
+ * remains fully supported for anyone who wants a hard ceiling as well.
59
+ */
60
+ export const DEFAULT_EXEC_TIMEOUT_MS = 0;
61
+ /**
62
+ * Default silence window before a run is treated as stalled.
63
+ *
64
+ * Fifteen minutes is chosen against what real work looks like, not against
65
+ * what feels responsive: an agent mid-task emits stream events, tool calls and
66
+ * log lines continuously, so the gaps in a healthy run are seconds. The long
67
+ * tail that legitimately goes quiet is a single slow tool call — a test suite,
68
+ * a build, a big fetch — and fifteen minutes clears those comfortably while
69
+ * still catching the failure this exists for, a child wedged on a stdin prompt
70
+ * that heartbeats look perfectly healthy for and that burns the operator's
71
+ * tokens and their agent's availability indefinitely.
54
72
  */
55
- export const DEFAULT_EXEC_TIMEOUT_MS = 7_200_000;
73
+ export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
56
74
  /** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
57
75
  const RECONNECT_BASE_DELAY_MS = 1_000;
58
76
  const RECONNECT_MAX_DELAY_MS = 30_000;
@@ -311,19 +329,28 @@ function codexMcpEntries(options, extraServers, log, workdir) {
311
329
  if (acceptedExtraEntries.length === 0)
312
330
  acceptedExtraNames.clear();
313
331
  const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
314
- const { entries: disabled, failed } = disableEntriesThatLoad(binary, probe, targets, workdir);
332
+ const { entries: disabled, configOverrides, failed } = isolateConfiguredServers(binary, probe, targets, workdir);
315
333
  if (failed.length > 0) {
316
- return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')}`);
334
+ // The remedy belongs in the message. This string is echoed into the thread
335
+ // the teammate is waiting in, and "could not be disabled" on its own leaves
336
+ // them with a run that will not start and nothing to do about it.
337
+ return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')} — remove them from ` +
338
+ '~/.codex/config.toml, or run the daemon with a dedicated CODEX_HOME containing only auth.json');
317
339
  }
318
340
  entries.push(...disabled);
319
- if (disabled.length > 0) {
341
+ if (targets.length > 0) {
320
342
  log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
321
343
  }
344
+ if (configOverrides.includes(PLUGINS_DISABLED_OVERRIDE)) {
345
+ log(`${timestamp()} ⚙ Codex plugins are switched off for agent runs — a plugin supplies an MCP ` +
346
+ 'server that cannot be disabled on its own');
347
+ }
322
348
  // Verify the exact combined override, not its pieces. This catches a config
323
349
  // merge that re-enables a server and a server installed between the initial
324
350
  // survey and this final probe.
325
351
  const override = mcpServersOverride(entries);
326
- const finalListing = listMcpServers(binary, probe, override, workdir);
352
+ const runOverrides = [...configOverrides, ...(override ? [override] : [])];
353
+ const finalListing = listMcpServers(binary, probe, runOverrides, workdir);
327
354
  if (finalListing === null)
328
355
  return fail('Codex rejected the final isolated MCP configuration');
329
356
  const allowed = new Set([
@@ -341,6 +368,7 @@ function codexMcpEntries(options, extraServers, log, workdir) {
341
368
  }
342
369
  return {
343
370
  codexMcpEntries: entries,
371
+ codexConfigOverrides: configOverrides.length > 0 ? configOverrides : undefined,
344
372
  execEnv: Object.keys(execEnv).length > 0 ? execEnv : undefined,
345
373
  mcpWired: Boolean(options.credentials.mcp_url),
346
374
  };
@@ -365,6 +393,25 @@ function defaultProbe(command, cwd) {
365
393
  });
366
394
  return { status: result.status, stdout: result.stdout ?? '' };
367
395
  }
396
+ /**
397
+ * What a killed plain command salvages: the last thing it printed.
398
+ *
399
+ * The streaming reader salvages the last thing the *agent* said, having parsed
400
+ * it out of the event stream. There is no parsing to do for a plain `--exec`
401
+ * command — its stdout is its reply — so its rolling tail is the same evidence
402
+ * reached the other way: what the run had produced before it stopped producing
403
+ * anything.
404
+ *
405
+ * Bounded to the same {@link MAX_SALVAGE_CHARS} for the same reason: the
406
+ * completion `error` this ends up in is capped server-side, and it has a stderr
407
+ * tail to carry as well.
408
+ */
409
+ function partialStdout(tail) {
410
+ const text = tail.trim();
411
+ if (!text)
412
+ return undefined;
413
+ return text.length > MAX_SALVAGE_CHARS ? text.slice(-MAX_SALVAGE_CHARS) : text;
414
+ }
368
415
  /**
369
416
  * Run the agent command once and turn its output into a reply.
370
417
  *
@@ -380,10 +427,14 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
380
427
  // share one deadline, which costs the retry nothing in practice — a rejected
381
428
  // `--resume` fails in milliseconds, long before any of the budget is spent.
382
429
  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.
430
+ // `--timeout 0`, and the default: no wall-clock ceiling at all. The run then
431
+ // ends when the agent ends it, when the watchdog below finds it silent, when
432
+ // somebody cancels it, or when the server stops renewing its lease — all of
433
+ // which are better judges of "stuck" than a fixed clock.
386
434
  const capped = budgetMs > 0;
435
+ // Not shared between attempts the way the wall clock is: the watchdog bounds
436
+ // silence, and a fresh attempt starts a fresh silence.
437
+ const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
387
438
  const startedAt = Date.now();
388
439
  const remainingMs = () => budgetMs - (Date.now() - startedAt);
389
440
  const timeLeft = () => !capped || remainingMs() > 0;
@@ -398,6 +449,16 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
398
449
  let recordedSession;
399
450
  /** The reader for the attempt currently running, if this brain streams. */
400
451
  let stream;
452
+ /**
453
+ * Rolling tail of a plain command's stdout — the last thing it printed.
454
+ *
455
+ * Kept as the output arrives rather than sliced off the captured `stdout` at
456
+ * the end, because capture is bounded at {@link MAX_OUTPUT_CHARS} from the
457
+ * *front*: a chatty command that overran that and then stalled would
458
+ * otherwise salvage half a megabyte of stale opening output and call it the
459
+ * unfinished answer.
460
+ */
461
+ let tail = '';
401
462
  /**
402
463
  * Act on what the stream has said so far.
403
464
  *
@@ -438,10 +499,12 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
438
499
  // session, and inheriting the failed attempt's id would resume nothing.
439
500
  const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
440
501
  stream = reader;
502
+ tail = '';
441
503
  return execAgent({
442
504
  command,
443
505
  prompt,
444
506
  timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
507
+ idleTimeoutMs: idleMs,
445
508
  cwd: runtime.workdir,
446
509
  env: mcp.execEnv,
447
510
  spawnImpl: options.spawnImpl,
@@ -451,7 +514,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
451
514
  reader.push(chunk);
452
515
  observe(reader);
453
516
  }
454
- : undefined,
517
+ : runtime.brain === 'plain'
518
+ ? (chunk) => {
519
+ tail = (tail + chunk).slice(-MAX_SALVAGE_CHARS);
520
+ }
521
+ : undefined,
455
522
  });
456
523
  };
457
524
  // Wall clock for the whole turn, retry included — what the operator waited,
@@ -462,10 +529,28 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
462
529
  }
463
530
  const failure = (result) => {
464
531
  // 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
532
+ // tail of what the agent was writing turns "no output for 15 minutes" into
466
533
  // 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;
534
+ // of however many hours of work. A stalled run is the case this matters
535
+ // most for: the last thing it said is the best clue to what it stalled on.
536
+ // A plain command has no stream to salvage a parsed answer from, but its
537
+ // stdout *is* its answer by contract — so the tail of what it managed to
538
+ // print is the same evidence, reached the other way. Without this a stalled
539
+ // plain run reports nothing but the fact that it stalled.
540
+ //
541
+ // Deliberately NOT extended to the shaped brains. Both are run with
542
+ // structured output (`--output-format stream-json`, `--json`), whose
543
+ // stream carries the agent's reasoning and the raw output of every local
544
+ // command and tool call it made — which `readClaudeStream`/
545
+ // `parseCodexOutput` exist to filter out. This string is persisted in the
546
+ // completion and echoed into a workspace thread, so tailing that stream
547
+ // raw would publish local shell output, and whatever was in it, to
548
+ // everyone who can read the conversation. A shaped brain whose parser
549
+ // salvaged nothing salvages nothing.
550
+ const killed = result.timedOut || result.idleTimedOut || result.cancelled;
551
+ const salvaged = killed
552
+ ? (stream?.salvage() ?? (runtime.brain === 'plain' ? partialStdout(tail) : undefined))
553
+ : undefined;
469
554
  // The stderr tail yields room when there is a salvaged answer to carry: the
470
555
  // completion `error` is capped server-side, and of the two, what the agent
471
556
  // was saying beats the last 500 characters of its logging.
@@ -498,6 +583,7 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
498
583
  ? codexCommand(options.exec, {
499
584
  sessionId: resume,
500
585
  mcpEntries: mcp.codexMcpEntries,
586
+ configOverrides: mcp.codexConfigOverrides,
501
587
  skipPermissions: runtime.skipPermissions,
502
588
  })
503
589
  : claudeCommand(options.exec, {
@@ -525,7 +611,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
525
611
  result = await exec(build());
526
612
  }
527
613
  if (!result.ok) {
528
- if (!result.timedOut && !result.cancelled)
614
+ // A run we killed keeps its session, whichever clock killed it: the
615
+ // transcript is real, it holds however long the agent worked, and the next
616
+ // delivery on that surface can resume it. Only a turn that failed on its
617
+ // own — a crash, an unparseable answer — has no continuity worth keeping.
618
+ if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
529
619
  forgetRecordedSession();
530
620
  return failure(result);
531
621
  }
@@ -598,8 +688,9 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
598
688
  // it is. See PromptContext.mcpToolPattern.
599
689
  mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
600
690
  toolsGated: !runtime.skipPermissions,
601
- // The real number, not a vague "there is a limit" — see PromptContext.
691
+ // The real numbers, not a vague "there is a limit" — see PromptContext.
602
692
  timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
693
+ idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
603
694
  });
604
695
  const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
605
696
  const seconds = (result.durationMs / 1000).toFixed(1);
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ function printHelp() {
25
25
  'Usage:',
26
26
  ' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
27
27
  ' prohost agent pair --code <pairing-code> [--webhook-url <https-url>] [--base-url <url>]',
28
- " prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
28
+ " prohost agent run --exec '<command>' [--idle-timeout <seconds>] [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
29
29
  " prohost agent install-daemon --exec '<command>' [--workdir <dir>] Keep the agent running",
30
30
  ' prohost agent uninstall-daemon',
31
31
  '',
@@ -43,9 +43,12 @@ function printHelp() {
43
43
  '',
44
44
  'agent run flags:',
45
45
  ' --exec Command to run for each mention; the prompt arrives on stdin (required)',
46
- ' --timeout Seconds one run may take before it is killed. Defaults to 7200 (2 hours).',
47
- ' Pass 0 for no local limit — the run then ends when the agent finishes,',
48
- ' when someone cancels it in ProhostAI, or when the server reaps it.',
46
+ ' --idle-timeout Seconds a run may produce NO output at all before it is treated as',
47
+ ' stalled and killed. Defaults to 900 (15 minutes). Any byte on stdout or',
48
+ ' stderr resets it, so a working agent never trips it. Pass 0 to disable.',
49
+ ' --timeout Seconds one run may take before it is killed, however busy it is.',
50
+ ' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
51
+ ' one. Set it if you want a hard ceiling as well as the stall watchdog.',
49
52
  ' --dry-run Print the reply that would be posted; write nothing.',
50
53
  ' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
51
54
  ' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
@@ -71,7 +74,8 @@ function printHelp() {
71
74
  '',
72
75
  'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
73
76
  ' --exec The same command you would pass to `agent run` (required).',
74
- ' --workdir, --timeout, --machine, --url, --allow-unverified, --safe-tools — passed to `agent run`.',
77
+ ' --workdir, --idle-timeout, --timeout, --machine, --url, --allow-unverified, --safe-tools',
78
+ ' — passed to `agent run`.',
75
79
  '',
76
80
  'Global flags:',
77
81
  ' --help, -h Show this help and exit.',
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.3.0";
10
+ export declare const CLI_VERSION = "0.4.1";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.3.0";
12
+ export declare const USER_AGENT = "prohost-cli/0.4.1";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.3.0';
10
+ export const CLI_VERSION = '0.4.1';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",