@prohost/cli 0.2.3 → 0.4.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,26 +16,61 @@
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';
21
26
  import { spawnSync } from 'node:child_process';
22
27
  import WebSocket from 'ws';
23
28
  import { completeRun, postReply } from './api.js';
24
- import { claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
29
+ import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
25
30
  import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
26
31
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
27
32
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
28
- 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';
29
34
  import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
30
35
  import { execAgent } from './exec.js';
36
+ import { HeartbeatLoop } from './heartbeat.js';
31
37
  import { buildPrompt, replyFromStdout } from './prompt.js';
32
38
  import { RunStore } from './runstore.js';
33
39
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
34
40
  import { verifySignature } from './signature.js';
35
41
  import { isWebhookEvent } from '../forward.js';
36
42
  import { DEFAULT_WS_URL } from '../listen.js';
37
- /** Default ceiling on one agent invocation. */
38
- export const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
43
+ /**
44
+ * Default ceiling on one agent invocation: none.
45
+ *
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.
72
+ */
73
+ export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
39
74
  /** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
40
75
  const RECONNECT_BASE_DELAY_MS = 1_000;
41
76
  const RECONNECT_MAX_DELAY_MS = 30_000;
@@ -66,8 +101,18 @@ function backoffDelay(attempt) {
66
101
  const cap = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1));
67
102
  return Math.floor(Math.random() * cap);
68
103
  }
69
- /** Backoff schedule for retrying a completion the server didn't accept. */
70
- const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000];
104
+ /**
105
+ * Backoff schedule for retrying a completion the server didn't accept.
106
+ *
107
+ * Roughly ten minutes in total, up from thirteen seconds. The old schedule was
108
+ * sized for a five-minute run: losing the completion meant losing five minutes
109
+ * of work, and a laptop that has been offline for thirteen seconds is usually
110
+ * offline for longer. Now a run can be four hours of an agent's work, which
111
+ * makes waiting out a wifi drop overwhelmingly the better bet — and the run
112
+ * keeps being heartbeated for the whole retry window, so the row it is trying
113
+ * to close stays alive rather than being reaped underneath the retries.
114
+ */
115
+ const COMPLETION_RETRY_DELAYS_MS = [1_000, 3_000, 9_000, 30_000, 60_000, 120_000, 180_000, 240_000];
71
116
  /**
72
117
  * Complete a run, retrying transient failures.
73
118
  *
@@ -338,6 +383,25 @@ function defaultProbe(command, cwd) {
338
383
  });
339
384
  return { status: result.status, stdout: result.stdout ?? '' };
340
385
  }
386
+ /**
387
+ * What a killed plain command salvages: the last thing it printed.
388
+ *
389
+ * The streaming reader salvages the last thing the *agent* said, having parsed
390
+ * it out of the event stream. There is no parsing to do for a plain `--exec`
391
+ * command — its stdout is its reply — so its rolling tail is the same evidence
392
+ * reached the other way: what the run had produced before it stopped producing
393
+ * anything.
394
+ *
395
+ * Bounded to the same {@link MAX_SALVAGE_CHARS} for the same reason: the
396
+ * completion `error` this ends up in is capped server-side, and it has a stderr
397
+ * tail to carry as well.
398
+ */
399
+ function partialStdout(tail) {
400
+ const text = tail.trim();
401
+ if (!text)
402
+ return undefined;
403
+ return text.length > MAX_SALVAGE_CHARS ? text.slice(-MAX_SALVAGE_CHARS) : text;
404
+ }
341
405
  /**
342
406
  * Run the agent command once and turn its output into a reply.
343
407
  *
@@ -347,35 +411,151 @@ function defaultProbe(command, cwd) {
347
411
  * tell a failed turn from a successful one. A plain command's behaviour here is
348
412
  * byte-for-byte what it was before this function existed.
349
413
  */
350
- async function runTurn(run, prompt, options, runtime, mcp, log) {
414
+ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
351
415
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
352
416
  // must not let one run take twice what the operator allowed. Both attempts
353
417
  // share one deadline, which costs the retry nothing in practice — a rejected
354
418
  // `--resume` fails in milliseconds, long before any of the budget is spent.
355
419
  const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
420
+ // `--timeout 0`, and the default: no wall-clock ceiling at all. The run then
421
+ // ends when the agent ends it, when the watchdog below finds it silent, when
422
+ // somebody cancels it, or when the server stops renewing its lease — all of
423
+ // which are better judges of "stuck" than a fixed clock.
424
+ const capped = budgetMs > 0;
425
+ // Not shared between attempts the way the wall clock is: the watchdog bounds
426
+ // silence, and a fresh attempt starts a fresh silence.
427
+ const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
356
428
  const startedAt = Date.now();
357
429
  const remainingMs = () => budgetMs - (Date.now() - startedAt);
358
- const exec = (command) => execAgent({
359
- command,
360
- prompt,
361
- timeoutMs: remainingMs(),
362
- cwd: runtime.workdir,
363
- env: mcp.execEnv,
364
- spawnImpl: options.spawnImpl,
365
- });
430
+ const timeLeft = () => !capped || remainingMs() > 0;
431
+ // Which surface's session this turn belongs to, and `undefined` on a dry run
432
+ // — which is what makes every `remember`/`forget` below a no-op there.
433
+ // `--dry-run` promises to make no writes, and a resumed turn is a write: both
434
+ // brains record it in their transcript whether or not we post the reply, so
435
+ // remembering the session would leave the next *real* run continuing from an
436
+ // answer nobody saw. Resolved once, up here, because the streaming reader
437
+ // records it mid-run rather than at the end of the turn.
438
+ const sessionKey = options.dryRun ? undefined : sessionKeyFor(run);
439
+ let recordedSession;
440
+ /** The reader for the attempt currently running, if this brain streams. */
441
+ let stream;
442
+ /**
443
+ * Rolling tail of a plain command's stdout — the last thing it printed.
444
+ *
445
+ * Kept as the output arrives rather than sliced off the captured `stdout` at
446
+ * the end, because capture is bounded at {@link MAX_OUTPUT_CHARS} from the
447
+ * *front*: a chatty command that overran that and then stalled would
448
+ * otherwise salvage half a megabyte of stale opening output and call it the
449
+ * unfinished answer.
450
+ */
451
+ let tail = '';
452
+ /**
453
+ * Act on what the stream has said so far.
454
+ *
455
+ * The session id is written to the ledger **as soon as it is announced**,
456
+ * which is a deliberate departure from "record it once the reply is durable".
457
+ * That rule protects against continuing a session whose answer nobody saw;
458
+ * it cannot protect a run that is killed at hour two, because that run never
459
+ * reaches the end where the recording used to happen — and losing the id
460
+ * loses the only handle on two hours of the agent's context. The rejected-
461
+ * reply case is handled where it happens instead, by forgetting again.
462
+ */
463
+ const observe = (reader) => {
464
+ const id = reader.sessionId();
465
+ if (id && id !== recordedSession) {
466
+ recordedSession = id;
467
+ runtime.sessions.remember(sessionKey, id);
468
+ }
469
+ const activity = reader.activity();
470
+ if (activity)
471
+ control.report(activity);
472
+ };
473
+ /**
474
+ * Undo an eagerly recorded Claude session after an ordinary failed turn.
475
+ *
476
+ * Timeouts and cancellations deliberately keep their session so the next
477
+ * delivery can continue a long-running turn whose context would otherwise be
478
+ * lost. A command or parser failure has no such continuity guarantee: its
479
+ * answer was never made durable, so resuming it would skip an unseen turn.
480
+ */
481
+ const forgetRecordedSession = () => {
482
+ if (!recordedSession)
483
+ return;
484
+ runtime.sessions.forget(sessionKey);
485
+ recordedSession = undefined;
486
+ };
487
+ const exec = (command) => {
488
+ // A fresh reader per attempt: a retry without `--resume` is a different
489
+ // session, and inheriting the failed attempt's id would resume nothing.
490
+ const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
491
+ stream = reader;
492
+ tail = '';
493
+ return execAgent({
494
+ command,
495
+ prompt,
496
+ timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
497
+ idleTimeoutMs: idleMs,
498
+ cwd: runtime.workdir,
499
+ env: mcp.execEnv,
500
+ spawnImpl: options.spawnImpl,
501
+ cancelSignal: control.signal,
502
+ onStdout: reader
503
+ ? (chunk) => {
504
+ reader.push(chunk);
505
+ observe(reader);
506
+ }
507
+ : runtime.brain === 'plain'
508
+ ? (chunk) => {
509
+ tail = (tail + chunk).slice(-MAX_SALVAGE_CHARS);
510
+ }
511
+ : undefined,
512
+ });
513
+ };
366
514
  // Wall clock for the whole turn, retry included — what the operator waited,
367
515
  // rather than what the last attempt took.
368
516
  const elapsed = () => Date.now() - startedAt;
369
517
  if (mcp.error) {
370
518
  return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
371
519
  }
372
- const failure = (result) => ({
373
- ok: false,
374
- reason: result.error ??
375
- (result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
376
- detail: result.stderr.trim().slice(-500),
377
- durationMs: elapsed(),
378
- });
520
+ const failure = (result) => {
521
+ // A killed run has no reply, but it usually has most of one. Reporting the
522
+ // tail of what the agent was writing turns "no output for 15 minutes" into
523
+ // something the person waiting can act on — and it is the only trace left
524
+ // of however many hours of work. A stalled run is the case this matters
525
+ // most for: the last thing it said is the best clue to what it stalled on.
526
+ // A plain command has no stream to salvage a parsed answer from, but its
527
+ // stdout *is* its answer by contract — so the tail of what it managed to
528
+ // print is the same evidence, reached the other way. Without this a stalled
529
+ // plain run reports nothing but the fact that it stalled.
530
+ //
531
+ // Deliberately NOT extended to the shaped brains. Both are run with
532
+ // structured output (`--output-format stream-json`, `--json`), whose
533
+ // stream carries the agent's reasoning and the raw output of every local
534
+ // command and tool call it made — which `readClaudeStream`/
535
+ // `parseCodexOutput` exist to filter out. This string is persisted in the
536
+ // completion and echoed into a workspace thread, so tailing that stream
537
+ // raw would publish local shell output, and whatever was in it, to
538
+ // everyone who can read the conversation. A shaped brain whose parser
539
+ // salvaged nothing salvages nothing.
540
+ const killed = result.timedOut || result.idleTimedOut || result.cancelled;
541
+ const salvaged = killed
542
+ ? (stream?.salvage() ?? (runtime.brain === 'plain' ? partialStdout(tail) : undefined))
543
+ : undefined;
544
+ // The stderr tail yields room when there is a salvaged answer to carry: the
545
+ // completion `error` is capped server-side, and of the two, what the agent
546
+ // was saying beats the last 500 characters of its logging.
547
+ const stderrTail = result.stderr.trim().slice(salvaged ? -200 : -500);
548
+ const detail = [stderrTail, salvaged ? `unfinished answer: ${salvaged}` : '']
549
+ .filter((part) => part.length > 0)
550
+ .join('\n');
551
+ return {
552
+ ok: false,
553
+ reason: result.error ??
554
+ (result.exitCode === null ? 'agent command did not run' : `agent command exited ${result.exitCode}`),
555
+ detail,
556
+ durationMs: elapsed(),
557
+ };
558
+ };
379
559
  if (runtime.brain === 'plain') {
380
560
  const result = await exec(options.exec);
381
561
  if (!result.ok)
@@ -385,12 +565,7 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
385
565
  ? { ok: true, reply, durationMs: elapsed() }
386
566
  : { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
387
567
  }
388
- // No session bookkeeping on a dry run. `--dry-run` promises to make no
389
- // writes, and a resumed turn is a write: both brains record it in their
390
- // transcript whether or not we post the reply, so remembering the session
391
- // would leave the next *real* run continuing from an answer nobody saw.
392
- const key = options.dryRun ? undefined : sessionKeyFor(run);
393
- const sessionId = runtime.sessions.get(key);
568
+ const sessionId = runtime.sessions.get(sessionKey);
394
569
  const codex = runtime.brain === 'codex';
395
570
  // Same three questions for both brains — resume this thread, wire these
396
571
  // tools, gate or don't — asked with each one's own flags.
@@ -416,13 +591,23 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
416
591
  // Skipped rather than squeezed into a token budget: a retry with no time
417
592
  // left would be killed on the spot and reported as a timeout, which is a
418
593
  // worse error message than the resume failure it replaced.
419
- remainingMs() > 0) {
594
+ timeLeft() &&
595
+ // A run somebody just stopped does not get started again on its way out.
596
+ !control.signal.aborted) {
420
597
  log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
421
- runtime.sessions.forget(key);
598
+ runtime.sessions.forget(sessionKey);
599
+ recordedSession = undefined;
422
600
  result = await exec(build());
423
601
  }
424
- if (!result.ok)
602
+ if (!result.ok) {
603
+ // A run we killed keeps its session, whichever clock killed it: the
604
+ // transcript is real, it holds however long the agent worked, and the next
605
+ // delivery on that surface can resume it. Only a turn that failed on its
606
+ // own — a crash, an unparseable answer — has no continuity worth keeping.
607
+ if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
608
+ forgetRecordedSession();
425
609
  return failure(result);
610
+ }
426
611
  // A denied tool is not an error either brain reports — the turn succeeds and
427
612
  // the reply just quietly lacks whatever the tool would have found. Printing
428
613
  // it is the difference between "the agent seems dumb" and "the agent asked
@@ -445,9 +630,15 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
445
630
  }
446
631
  return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
447
632
  }
448
- const parsed = parseClaudeOutput(result.stdout);
633
+ // The reply comes out of the stream's own `result` event, so a run whose
634
+ // stdout we watched arrives already parsed. `finish` still gets the captured
635
+ // stdout: a command that emitted no result event at all — a login prompt, a
636
+ // crash banner, a `claude` that ignored `--output-format` — is a failure, and
637
+ // the whole-output parser is what says so in words.
638
+ const parsed = (stream ?? readClaudeStream()).finish(result.stdout);
449
639
  reportDenials(parsed.denials);
450
640
  if (!parsed.ok) {
641
+ forgetRecordedSession();
451
642
  return {
452
643
  ok: false,
453
644
  reason: 'the agent did not produce a usable reply',
@@ -455,7 +646,13 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
455
646
  durationMs: elapsed(),
456
647
  };
457
648
  }
458
- return { ok: true, reply: parsed.text, durationMs: elapsed(), sessionId: parsed.sessionId };
649
+ return {
650
+ ok: true,
651
+ reply: parsed.text,
652
+ durationMs: elapsed(),
653
+ sessionId: parsed.sessionId,
654
+ sessionRecorded: recordedSession !== undefined,
655
+ };
459
656
  }
460
657
  /**
461
658
  * Handle one verified run end-to-end.
@@ -463,7 +660,7 @@ async function runTurn(run, prompt, options, runtime, mcp, log) {
463
660
  * Every path except a dry run — including every failure — calls the completion
464
661
  * endpoint before returning.
465
662
  */
466
- async function handleRun(run, options, runtime, api, log, sleep) {
663
+ async function handleRun(run, options, runtime, api, log, sleep, control) {
467
664
  const label = `run ${run.run_id.slice(0, 12)}`;
468
665
  const surface = run.conversation_id ?? run.kind ?? 'no conversation';
469
666
  log(`${timestamp()} ▶ ${label} started (${surface})`);
@@ -480,9 +677,38 @@ async function handleRun(run, options, runtime, api, log, sleep) {
480
677
  // it is. See PromptContext.mcpToolPattern.
481
678
  mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
482
679
  toolsGated: !runtime.skipPermissions,
680
+ // The real numbers, not a vague "there is a limit" — see PromptContext.
681
+ timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
682
+ idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
483
683
  });
484
- const result = await runTurn(run, prompt, options, runtime, mcp, log);
684
+ const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
485
685
  const seconds = (result.durationMs / 1000).toFixed(1);
686
+ // Stopped from outside. Checked before the failure branch below, because a
687
+ // stopped run *is* a failed command — reporting it as one would put "timed
688
+ // out"-shaped noise in a thread where somebody deliberately pressed stop.
689
+ const stopped = control.disposition();
690
+ if (stopped === 'resolved') {
691
+ // The server already closed this run: reaped after its lease lapsed, or
692
+ // resolved by something else. There is nothing left to post a reply to and
693
+ // nothing left to complete — a completion would just 409.
694
+ log(`${timestamp()} ⏹ ${label} was already closed server-side (${seconds}s) — nothing was posted`);
695
+ return 'resolved';
696
+ }
697
+ if (stopped === 'cancelled') {
698
+ log(`${timestamp()} ⏹ ${label} cancelled after ${seconds}s`);
699
+ if (options.dryRun) {
700
+ log(' [dry-run] would report the run as cancelled');
701
+ return 'skipped';
702
+ }
703
+ // The exact marker string, alone: the server words the thread notice from
704
+ // it, and anything wrapped around it reads as a crash instead of a stop.
705
+ const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
706
+ if (!closed.ok) {
707
+ log(` ! could not report the cancellation (HTTP ${closed.status}) ${closed.error ?? ''}`);
708
+ return 'unreported';
709
+ }
710
+ return 'cancelled';
711
+ }
486
712
  if (!result.ok) {
487
713
  const { reason } = result;
488
714
  const detail = result.detail ?? '';
@@ -533,6 +759,12 @@ async function handleRun(run, options, runtime, api, log, sleep) {
533
759
  const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
534
760
  if (!posted.ok) {
535
761
  log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
762
+ // Undo the mid-run recording. The session exists and the agent's transcript
763
+ // holds this turn, but the conversation never received its answer, so
764
+ // continuing it would skip reasoning nobody can see — the same rule that
765
+ // used to be expressed by simply not recording until the reply landed.
766
+ if (result.sessionRecorded)
767
+ runtime.sessions.forget(sessionKeyFor(run));
536
768
  const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
537
769
  return reported.ok ? 'failed' : 'unreported';
538
770
  }
@@ -588,9 +820,79 @@ export async function runAgentHarness(options) {
588
820
  // duplicate — the run is in flight right here. Only a run in the ledger that
589
821
  // this process never handled is a candidate for restart recovery.
590
822
  const handledThisSession = new Set();
823
+ /** Accepted, not yet reported — the executing run and everything queued. */
824
+ const accepted = new Map();
825
+ const heartbeats = new HeartbeatLoop({ api, log, intervalMs: options.heartbeatIntervalMs });
591
826
  let queue = Promise.resolve();
592
827
  let fatal;
593
828
  let attempt = 0;
829
+ /** Stop tracking a run — it has reached a terminal state, one way or another. */
830
+ const release = (runId) => {
831
+ accepted.delete(runId);
832
+ heartbeats.untrack(runId);
833
+ };
834
+ /**
835
+ * Work started from a heartbeat that the harness must still wait for on the
836
+ * way out, but that a heartbeat tick must not wait for.
837
+ *
838
+ * Closing out a cancelled queued run can take the whole completion-retry
839
+ * schedule — around ten minutes against an unreachable server — and beats do
840
+ * not overlap. Awaited inside the tick, one unreachable completion would stop
841
+ * every other run being heartbeated, and the four-hour job it was queued
842
+ * behind would be reaped for going quiet.
843
+ */
844
+ const background = [];
845
+ /**
846
+ * Somebody pressed stop in ProhostAI.
847
+ *
848
+ * The two cases are genuinely different work. An executing run reports
849
+ * itself: the abort reaches `execAgent`, which signals the process group the
850
+ * same way a timeout does, and `handleRun` completes it with the marker
851
+ * error. A run still in the queue has nobody to report it — it will never
852
+ * execute, and the queue is about to skip it — so it is closed out here.
853
+ */
854
+ const cancelAccepted = async (entry) => {
855
+ if (entry.disposition)
856
+ return;
857
+ entry.disposition = 'cancelled';
858
+ if (entry.executing) {
859
+ entry.controller.abort();
860
+ return;
861
+ }
862
+ release(entry.run.run_id);
863
+ log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was cancelled before it started`);
864
+ // `--dry-run` promises to make no writes, and a completion is the most
865
+ // terminal write there is. The executing-run path defers to the same check
866
+ // inside `handleRun`; this one has to make it itself, because a queued run
867
+ // never reaches `handleRun` at all.
868
+ if (options.dryRun) {
869
+ log(' [dry-run] would report the run as cancelled');
870
+ return;
871
+ }
872
+ const closed = await completeWithRetry(api, entry.run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
873
+ if (closed.ok)
874
+ runs.markCompleted(entry.run.run_id);
875
+ metrics.runsFailed += 1;
876
+ };
877
+ /**
878
+ * The server has already closed this run out — it was reaped, or resolved by
879
+ * something else. Either way nothing this machine does with it can land, so
880
+ * the only correct move is to stop: kill the command if one is running, and
881
+ * post nothing at all. A completion here would be a 409 by definition.
882
+ */
883
+ const resolveAccepted = (entry) => {
884
+ if (entry.disposition)
885
+ return;
886
+ entry.disposition = 'resolved';
887
+ if (entry.executing) {
888
+ entry.controller.abort();
889
+ return;
890
+ }
891
+ release(entry.run.run_id);
892
+ // Terminal server-side, so a redelivery must not start it either.
893
+ runs.markCompleted(entry.run.run_id);
894
+ log(`${timestamp()} ⏹ run ${entry.run.run_id.slice(0, 12)} was closed server-side before it started`);
895
+ };
594
896
  const exhausted = () => options.maxConnections !== undefined && metrics.connections >= options.maxConnections;
595
897
  while (!options.signal?.aborted && fatal === undefined && !exhausted()) {
596
898
  metrics.connections += 1;
@@ -604,8 +906,14 @@ export async function runAgentHarness(options) {
604
906
  log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
605
907
  await sleep(delay);
606
908
  }
607
- // Let an in-flight run finish reporting before we hand back metrics.
909
+ // Let an in-flight run finish reporting before we hand back metrics — and
910
+ // any cancellation the heartbeat loop started and deliberately did not wait
911
+ // for, so the harness never exits with a run half-closed.
608
912
  await queue;
913
+ // Stopped first, so no further tick can start work after the wait below has
914
+ // taken its snapshot of `background`.
915
+ heartbeats.stop();
916
+ await Promise.all(background);
609
917
  if (fatal !== undefined) {
610
918
  log(`${timestamp()} ✗ ${fatal}`);
611
919
  metrics.fatal = fatal;
@@ -753,13 +1061,36 @@ export async function runAgentHarness(options) {
753
1061
  // redelivery execute the agent a second time.
754
1062
  runs.markStarted(run.run_id, run.completion_path);
755
1063
  metrics.runsStarted += 1;
1064
+ // Heartbeat from the moment it is accepted, not from the moment it
1065
+ // starts. The row is `running` server-side either way, and a run that
1066
+ // waits an hour behind a long job is reaped exactly like one that has
1067
+ // been working for an hour.
1068
+ const entry = { run, controller: new AbortController(), executing: false };
1069
+ accepted.set(run.run_id, entry);
1070
+ heartbeats.track(run.run_id, {
1071
+ path: heartbeatPathFor(run),
1072
+ progress: () => entry.progress,
1073
+ // Deliberately not awaited here — see `background`.
1074
+ onCancelRequested: () => {
1075
+ background.push(cancelAccepted(entry).catch((err) => {
1076
+ log(`${timestamp()} ✗ closing cancelled run ${run.run_id} failed: ${String(err)}`);
1077
+ }));
1078
+ },
1079
+ onResolved: () => resolveAccepted(entry),
1080
+ });
756
1081
  // Serialize execution but never block the socket reader: the ack and
757
1082
  // the heartbeat have to keep flowing while the agent thinks.
758
1083
  queue = queue.then(async () => {
1084
+ // Settled while it waited — cancelled, or closed server-side. Both
1085
+ // were dealt with where they were decided; there is nothing to run
1086
+ // and nothing left to report.
1087
+ if (entry.disposition)
1088
+ return;
759
1089
  // Shutdown was requested while this sat in the queue. Don't start a
760
1090
  // fresh command the operator is waiting to be rid of — but do close
761
1091
  // the run out, so the server isn't left waiting on it either.
762
1092
  if (options.signal?.aborted) {
1093
+ release(run.run_id);
763
1094
  log(`${timestamp()} ⏹ run ${run.run_id.slice(0, 12)} dropped — harness is shutting down`);
764
1095
  const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: 'the harness shut down before this run started' }, log, sleep);
765
1096
  if (closed.ok)
@@ -767,20 +1098,42 @@ export async function runAgentHarness(options) {
767
1098
  metrics.runsFailed += 1;
768
1099
  return;
769
1100
  }
770
- const outcome = await handleRun(run, options, runtime, api, log, sleep).catch((err) => {
1101
+ // Set before the first `await`, so a cancellation arriving from the
1102
+ // heartbeat either sees a queued run (and closes it out itself) or an
1103
+ // executing one (and aborts it) — never both, and never neither.
1104
+ entry.executing = true;
1105
+ const control = {
1106
+ signal: entry.controller.signal,
1107
+ disposition: () => entry.disposition,
1108
+ report: (activity) => {
1109
+ entry.progress = activity;
1110
+ },
1111
+ };
1112
+ const outcome = await handleRun(run, options, runtime, api, log, sleep, control).catch((err) => {
771
1113
  log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
772
1114
  return 'failed';
773
1115
  });
1116
+ // Held until here on purpose: heartbeats keep the row alive for the
1117
+ // whole of `handleRun`, including the ten minutes of completion
1118
+ // retries at the end of it.
1119
+ release(run.run_id);
774
1120
  // Both terminal outcomes reached the completion endpoint; only then
775
1121
  // is the run settled and safe to skip on redelivery.
776
1122
  if (outcome === 'succeeded')
777
1123
  metrics.runsSucceeded += 1;
778
- if (outcome === 'failed' || outcome === 'unreported')
1124
+ // A cancellation is reported to the server as `failed`, and counted
1125
+ // the same way here — the run did not produce the answer it was for.
1126
+ if (outcome === 'failed' || outcome === 'unreported' || outcome === 'cancelled') {
779
1127
  metrics.runsFailed += 1;
1128
+ }
780
1129
  // Only a run the server acknowledged as terminal is settled; an
781
1130
  // unreported one stays open so a redelivery can report it.
782
- if (outcome === 'succeeded' || outcome === 'failed')
1131
+ if (outcome === 'succeeded' ||
1132
+ outcome === 'failed' ||
1133
+ outcome === 'cancelled' ||
1134
+ outcome === 'resolved') {
783
1135
  runs.markCompleted(run.run_id);
1136
+ }
784
1137
  });
785
1138
  });
786
1139
  ws.on('close', () => {
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,7 +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 300.',
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.',
47
52
  ' --dry-run Print the reply that would be posted; write nothing.',
48
53
  ' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
49
54
  ' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
@@ -69,7 +74,8 @@ function printHelp() {
69
74
  '',
70
75
  'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
71
76
  ' --exec The same command you would pass to `agent run` (required).',
72
- ' --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`.',
73
79
  '',
74
80
  'Global flags:',
75
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.2.3";
10
+ export declare const CLI_VERSION = "0.4.0";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.2.3";
12
+ export declare const USER_AGENT = "prohost-cli/0.4.0";
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.2.3';
10
+ export const CLI_VERSION = '0.4.0';
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.2.3",
3
+ "version": "0.4.0",
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",