@prohost/cli 0.3.0 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## 0.4.0
8
+
9
+ The wall clock stops being the safety mechanism. 0.3.0 raised the run ceiling
10
+ to two hours and added heartbeats — but a heartbeat proves the *daemon* is
11
+ alive, not that the *work* is progressing. A child wedged on a stdin prompt or
12
+ stuck in a loop heartbeats forever, looks healthy in the UI, burns the
13
+ operator's tokens, and — because runs are serialized — makes the agent deaf to
14
+ every other mention until the two-hour clock finally runs out. Meanwhile that
15
+ same clock was killing runs that were doing fine.
16
+
17
+ - **A run is killed when it goes silent, not when it goes long.** The new
18
+ inactivity watchdog kills a run that produces **no output at all** for 15
19
+ minutes (`--idle-timeout <seconds>`, `0` disables it). Any byte on stdout or
20
+ stderr resets the window, which covers all three brains: Claude Code's
21
+ stream-json events, Codex's output, and whatever a plain `--exec` command
22
+ prints. An agent that is working never trips it — the gaps in a healthy run
23
+ are seconds — while a wedged one is caught in minutes rather than hours. The
24
+ kill is the same SIGTERM → 5s → SIGKILL escalation a timeout or a cancel
25
+ uses, the partial answer is salvaged, and a session id recorded mid-run
26
+ survives, exactly as for a wall-clock kill. The failure reads
27
+ `no output for 15 minutes — treated as stalled; tune with --idle-timeout
28
+ <seconds> (0 = disabled)`, because the server echoes it into the thread.
29
+ - **`--timeout` now defaults to `0`: no wall-clock limit.** It remains fully
30
+ supported for anyone who wants a hard ceiling, but it is no longer the
31
+ backstop and no longer imposes a shape on the work. The safety story is the
32
+ watchdog (15 minutes of silence), the server's lease on the run, and the Stop
33
+ button in ProhostAI.
34
+ - **The prompt tells the agent which regime it is in.** Three shapes now: a
35
+ named wall-clock budget, the watchdog alone ("no fixed time limit… what is
36
+ limited is silence"), or neither. The middle one is the default and is the
37
+ point — a silence limit is advice an agent can act on, where a duration is
38
+ just an instruction to hurry.
39
+ - **A killed plain `--exec` command salvages its output too.** Claude Code runs
40
+ already reported the tail of the answer they were writing, parsed out of the
41
+ stream; a plain command now reports the tail of its stdout instead of
42
+ nothing, since for a plain command the stdout *is* the answer. Deliberately
43
+ not extended to the shaped brains: their structured streams carry the raw
44
+ output of every local command the agent ran, and this string is echoed into a
45
+ workspace thread.
46
+ - **Both duration flags reject values a timer cannot hold** — anything over
47
+ 2^31-1 ms (~24.9 days), where `setTimeout` silently fires after 1 ms instead
48
+ and would kill the first run on the spot. `0` remains how both flags spell
49
+ "no limit". A flag typed with no value at all (`--timeout`, or `--timeout -1`,
50
+ which the parser reads as two flags) is likewise an error now rather than
51
+ silently applying the default.
52
+ - **`--idle-timeout` is passed through `install-daemon`** into the launchd
53
+ plist and systemd unit, like `--timeout`, including an explicit `0`.
54
+ - **The `heartbeat` block in `agent.run_requested` is used when present.** A
55
+ server that spells out where heartbeats go is followed verbatim; one that
56
+ doesn't gets the 0.3.0 behaviour unchanged — derive from the completion path,
57
+ falling back to the documented one. No coordinated upgrade either way.
58
+
7
59
  ## 0.3.0
8
60
 
9
61
  Long runs. Every change here comes out of one production failure: a paired
package/README.md CHANGED
@@ -400,7 +400,8 @@ same judgement.
400
400
  | Flag | Default | What it does |
401
401
  | --- | --- | --- |
402
402
  | `--exec` | *(required)* | Command to run for each requested run. Prompt arrives on stdin. |
403
- | `--timeout` | `7200` | Seconds one run may take before the command is killed. `0` removes the local cap entirely — see "Long runs". |
403
+ | `--idle-timeout` | `900` | Seconds a run may produce **no output at all** before it is treated as stalled and killed. Any byte on stdout or stderr resets it. `0` disables it — see "Long runs". |
404
+ | `--timeout` | `0` | Seconds one run may take before the command is killed, however busy it is. `0` — the default — means no wall-clock limit. Set it if you want a hard ceiling as well. |
404
405
  | `--dry-run` | off | Print the reply that would be posted; write nothing. |
405
406
  | `--workdir` | `~/.prohost/workspace` | Working directory for the command. See "Where your agent runs". |
406
407
  | `--cwd` | — | Deprecated alias for `--workdir`. |
@@ -415,9 +416,18 @@ A paired brain is often asked to do work, not just answer — write the change,
415
416
  run the tests, come back. That takes minutes or hours, and the harness is built
416
417
  for it:
417
418
 
418
- - **Two hours by default**, and `--timeout 0` removes the local cap entirely.
419
- The timeout is a backstop against a genuinely wedged process, not a shape for
420
- the work.
419
+ - **There is no wall-clock limit by default.** A run ends when the agent ends
420
+ it. `--timeout <seconds>` still imposes a hard ceiling if you want one, but
421
+ the default is `0`, because "this has been going a while" was never evidence
422
+ that anything was wrong — it only ever distinguished long work from short.
423
+ - **A stalled run is killed after 15 minutes of silence** (`--idle-timeout`).
424
+ This is the backstop the wall clock used to be, asking a question a clock
425
+ can't: is the child *doing* anything? Any byte on stdout or stderr resets the
426
+ window, so an agent that is working — streaming events, calling tools,
427
+ logging — never trips it, while one wedged on a stdin prompt is caught even
428
+ though its heartbeats look perfectly healthy. The failed run reports
429
+ `no output for 15 minutes — treated as stalled`, so the person waiting sees
430
+ what happened rather than a silence. `--idle-timeout 0` disables it.
421
431
  - **Heartbeats keep it alive.** Every run this CLI has accepted — the one
422
432
  executing *and* any queued behind it — is reported to ProhostAI once a minute,
423
433
  which is what stops the server reaping a run that is still being worked on.
@@ -5,6 +5,30 @@
5
5
  * (and testable) as plain async functions with no argv or process concerns.
6
6
  */
7
7
  type Flags = Record<string, string | boolean>;
8
+ /** Returned by {@link parseSeconds} for a value that isn't a duration. */
9
+ export declare const INVALID_DURATION: unique symbol;
10
+ /**
11
+ * Read a duration flag's value. Pure — exported so it can be tested without a
12
+ * process to write errors to.
13
+ *
14
+ * Returns ``undefined`` only when the flag was genuinely absent (the harness
15
+ * then applies its own default) and {@link INVALID_DURATION} for anything that
16
+ * is not a non-negative, finite number of seconds.
17
+ *
18
+ * ``0`` is legal for both flags that use this, and means "no limit of this
19
+ * kind". For `--timeout` it was rejected until long runs existed, because
20
+ * before heartbeats an uncapped run on a laptop that went to sleep was a row
21
+ * that span forever.
22
+ *
23
+ * Takes the raw flag value rather than a string, because "the operator typed
24
+ * this flag and got it wrong" and "the operator did not type this flag" have
25
+ * to be told apart. `--timeout` with nothing after it, and `--timeout -1` —
26
+ * which the argument parser reads as two flags, since a value cannot begin
27
+ * with `-` — both arrive here as `true`. Treated as absent, they would apply
28
+ * the default silently and leave someone believing they had set a limit they
29
+ * had not.
30
+ */
31
+ export declare function parseSeconds(raw: string | boolean | undefined): number | undefined | typeof INVALID_DURATION;
8
32
  /** Dispatch an ``agent`` subcommand. Returns the process exit code. */
9
33
  export declare function runAgent(subcommand: string | undefined, flags: Flags, printHelp: () => void): Promise<number>;
10
34
  export {};
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon, launchdLabel, uninstallLaunchdDaemon, } from './daemon.js';
13
13
  import { PairError, runPair } from './pair.js';
14
14
  import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
15
- import { DEFAULT_EXEC_TIMEOUT_MS, runAgentHarness } from './run.js';
15
+ import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
16
16
  function flagString(flags, name) {
17
17
  const value = flags[name];
18
18
  return typeof value === 'string' ? value : undefined;
@@ -28,33 +28,92 @@ function flagString(flags, name) {
28
28
  function requestedWorkdir(flags) {
29
29
  return flagString(flags, 'workdir') ?? flagString(flags, 'cwd');
30
30
  }
31
- /** Returned by {@link parseTimeoutSeconds} for a value that isn't a timeout. */
32
- const INVALID_TIMEOUT = Symbol('invalid-timeout');
31
+ /** Returned by {@link parseSeconds} for a value that isn't a duration. */
32
+ export const INVALID_DURATION = Symbol('invalid-duration');
33
33
  /**
34
- * Read `--timeout`, complaining once for both commands that take it.
34
+ * Longest delay a timer can hold, in milliseconds — 2^31-1, just under 24.9
35
+ * days.
35
36
  *
36
- * Returns ``undefined`` when the flag was absent (the harness applies its own
37
- * default) and {@link INVALID_TIMEOUT} after printing the error.
37
+ * The bound is `setTimeout`'s, not ours: a delay above this overflows the timer
38
+ * and Node **fires it after 1 ms instead**. So `--idle-timeout 2592000` ("give
39
+ * it thirty days") would not merely be ignored, it would kill the very first
40
+ * run on the spot and report it as stalled — the exact opposite of what was
41
+ * asked for, with nothing to explain it. Rejected at the flag rather than
42
+ * clamped, because anyone typing a number this large means "no limit", and both
43
+ * flags already spell that `0`.
38
44
  *
39
- * ``0`` is legal and means "no local cap": the run ends when the agent ends it,
40
- * when somebody cancels it, or when the server stops renewing its lease. It was
41
- * rejected until long runs existed, because before heartbeats an uncapped run
42
- * on a laptop that went to sleep was a row that span forever.
45
+ * Checked against the *converted* value rather than against seconds, because
46
+ * the flags take fractions: `--timeout 2147483.9` is under any whole-second
47
+ * ceiling and still overflows once multiplied out.
43
48
  */
44
- function parseTimeoutSeconds(flags) {
45
- const raw = flagString(flags, 'timeout');
49
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
50
+ /**
51
+ * Read a duration flag's value. Pure — exported so it can be tested without a
52
+ * process to write errors to.
53
+ *
54
+ * Returns ``undefined`` only when the flag was genuinely absent (the harness
55
+ * then applies its own default) and {@link INVALID_DURATION} for anything that
56
+ * is not a non-negative, finite number of seconds.
57
+ *
58
+ * ``0`` is legal for both flags that use this, and means "no limit of this
59
+ * kind". For `--timeout` it was rejected until long runs existed, because
60
+ * before heartbeats an uncapped run on a laptop that went to sleep was a row
61
+ * that span forever.
62
+ *
63
+ * Takes the raw flag value rather than a string, because "the operator typed
64
+ * this flag and got it wrong" and "the operator did not type this flag" have
65
+ * to be told apart. `--timeout` with nothing after it, and `--timeout -1` —
66
+ * which the argument parser reads as two flags, since a value cannot begin
67
+ * with `-` — both arrive here as `true`. Treated as absent, they would apply
68
+ * the default silently and leave someone believing they had set a limit they
69
+ * had not.
70
+ */
71
+ export function parseSeconds(raw) {
46
72
  if (raw === undefined)
47
73
  return undefined;
74
+ // An empty string is the `--timeout ''` case, and `Number('')` is 0 — which
75
+ // would quietly read as "no limit" rather than as the typo it is.
76
+ if (typeof raw !== 'string' || raw.trim() === '')
77
+ return INVALID_DURATION;
48
78
  const seconds = Number(raw);
49
- if (!Number.isFinite(seconds) || seconds < 0) {
79
+ if (!Number.isFinite(seconds) || seconds < 0 || seconds * 1000 > MAX_TIMER_DELAY_MS) {
80
+ return INVALID_DURATION;
81
+ }
82
+ return seconds;
83
+ }
84
+ /**
85
+ * Read `--timeout`, complaining once for both commands that take it.
86
+ *
87
+ * The wall-clock ceiling is opt-in now (`DEFAULT_EXEC_TIMEOUT_MS` is 0), so an
88
+ * absent flag means "no ceiling" rather than "two hours".
89
+ */
90
+ function parseTimeoutSeconds(flags) {
91
+ const parsed = parseSeconds(flags.timeout);
92
+ if (parsed === INVALID_DURATION) {
50
93
  // Keeps the phrase "--timeout must be a positive number" verbatim: the
51
94
  // desktop app's supervisor matches on it to tell a misconfigured child from
52
95
  // a recoverable crash (`desktop/src/main/agent/supervisor-policy.ts`), and
53
96
  // it reads this CLI's stderr rather than any structured output.
54
- process.stderr.write('--timeout must be a positive number of seconds, or 0 for no local limit.\n');
55
- return INVALID_TIMEOUT;
97
+ process.stderr.write('--timeout must be a positive number of seconds under 2147484 (about 24 days), ' +
98
+ 'or 0 for no local limit.\n');
56
99
  }
57
- return seconds;
100
+ return parsed;
101
+ }
102
+ /**
103
+ * Read `--idle-timeout` — how long a run may produce nothing before it is
104
+ * treated as stalled.
105
+ *
106
+ * Validated in both commands for the same reason `--timeout` is: baked into a
107
+ * plist, a bad value makes every launch exit on this check and KeepAlive
108
+ * restart it forever.
109
+ */
110
+ function parseIdleTimeoutSeconds(flags) {
111
+ const parsed = parseSeconds(flags['idle-timeout']);
112
+ if (parsed === INVALID_DURATION) {
113
+ process.stderr.write('--idle-timeout must be a positive number of seconds under 2147484 (about 24 days), ' +
114
+ 'or 0 to disable the stall watchdog.\n');
115
+ }
116
+ return parsed;
58
117
  }
59
118
  /**
60
119
  * Environment the daemon must be given explicitly.
@@ -129,7 +188,10 @@ async function installDaemonCommand(flags) {
129
188
  // plist makes every launch exit on that same validation and KeepAlive restart
130
189
  // it forever.
131
190
  const timeoutSeconds = parseTimeoutSeconds(flags);
132
- if (timeoutSeconds === INVALID_TIMEOUT)
191
+ if (timeoutSeconds === INVALID_DURATION)
192
+ return 1;
193
+ const idleTimeoutSeconds = parseIdleTimeoutSeconds(flags);
194
+ if (idleTimeoutSeconds === INVALID_DURATION)
133
195
  return 1;
134
196
  // Absolute, and created now. A supervisor does not inherit the installing
135
197
  // shell's current directory, so a relative `--workdir .` copied verbatim into
@@ -151,6 +213,7 @@ async function installDaemonCommand(flags) {
151
213
  exec,
152
214
  workdir,
153
215
  timeoutSeconds,
216
+ idleTimeoutSeconds,
154
217
  machine: flagString(flags, 'machine'),
155
218
  url: flagString(flags, 'url'),
156
219
  allowUnverified: flags['allow-unverified'] === true,
@@ -224,7 +287,10 @@ async function runCommand(flags) {
224
287
  if (!credentials)
225
288
  return 1;
226
289
  const parsedTimeout = parseTimeoutSeconds(flags);
227
- if (parsedTimeout === INVALID_TIMEOUT)
290
+ if (parsedTimeout === INVALID_DURATION)
291
+ return 1;
292
+ const parsedIdleTimeout = parseIdleTimeoutSeconds(flags);
293
+ if (parsedIdleTimeout === INVALID_DURATION)
228
294
  return 1;
229
295
  // First signal stops accepting work and lets the in-flight run finish
230
296
  // reporting itself (so the server doesn't wait on a run we abandoned); a
@@ -248,6 +314,7 @@ async function runCommand(flags) {
248
314
  machine: flagString(flags, 'machine'),
249
315
  cwd: requestedWorkdir(flags),
250
316
  timeoutMs: parsedTimeout === undefined ? DEFAULT_EXEC_TIMEOUT_MS : parsedTimeout * 1000,
317
+ idleTimeoutMs: parsedIdleTimeout === undefined ? DEFAULT_IDLE_TIMEOUT_MS : parsedIdleTimeout * 1000,
251
318
  dryRun: flags['dry-run'] === true,
252
319
  allowUnverified: flags['allow-unverified'] === true,
253
320
  // Two spellings for the same opt-out: `--safe-tools` says what you get,
@@ -63,14 +63,19 @@ export declare function agentRunCompletePath(runId: string): string;
63
63
  */
64
64
  export declare function agentRunHeartbeatPath(runId: string): string;
65
65
  /**
66
- * Where this run's heartbeats go.
66
+ * Where this run's heartbeats go, in descending order of how much we know.
67
67
  *
68
- * Derived from the server-supplied completion path rather than rebuilt from the
69
- * run id, on the same reasoning the rest of this module prefers server-supplied
70
- * paths: the two calls are the same resource, so a server that moves its
71
- * agent-run routes moves both together and this CLI follows without a release.
72
- * The documented path is the fallback for a completion path shaped in some way
73
- * we don't recognise.
68
+ * 1. The `heartbeat` block, when the payload carries one. A path the server
69
+ * stated is not a guess, so it is used verbatim.
70
+ * 2. Otherwise, derived from the server-supplied completion path: the two
71
+ * calls are the same resource, so a server that moves its agent-run routes
72
+ * moves both together and a CLI already on somebody's laptop follows
73
+ * without a release.
74
+ * 3. Otherwise the documented path, for a completion path shaped in some way
75
+ * we don't recognise.
76
+ *
77
+ * Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
78
+ * the block is additive, and every server that predates it must keep working.
74
79
  */
75
80
  export declare function heartbeatPathFor(run: AgentRunRequest): string;
76
81
  /**
@@ -216,6 +221,14 @@ export interface AgentRunRequest {
216
221
  reply_body_extra?: Record<string, unknown>;
217
222
  /** Server-supplied path to complete the run at. */
218
223
  completion_path: string;
224
+ /**
225
+ * Server-supplied path to heartbeat the run at.
226
+ *
227
+ * Absent on every server built before the `heartbeat` block was added
228
+ * alongside `completion`, which is most of them — see {@link heartbeatPathFor}
229
+ * for what happens then.
230
+ */
231
+ heartbeat_path?: string;
219
232
  /**
220
233
  * Earlier messages on this surface, oldest first. Absent on a server that
221
234
  * predates the field — which is the case the harness shipped with, so an
@@ -69,16 +69,23 @@ export function agentRunHeartbeatPath(runId) {
69
69
  return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
70
70
  }
71
71
  /**
72
- * Where this run's heartbeats go.
72
+ * Where this run's heartbeats go, in descending order of how much we know.
73
73
  *
74
- * Derived from the server-supplied completion path rather than rebuilt from the
75
- * run id, on the same reasoning the rest of this module prefers server-supplied
76
- * paths: the two calls are the same resource, so a server that moves its
77
- * agent-run routes moves both together and this CLI follows without a release.
78
- * The documented path is the fallback for a completion path shaped in some way
79
- * we don't recognise.
74
+ * 1. The `heartbeat` block, when the payload carries one. A path the server
75
+ * stated is not a guess, so it is used verbatim.
76
+ * 2. Otherwise, derived from the server-supplied completion path: the two
77
+ * calls are the same resource, so a server that moves its agent-run routes
78
+ * moves both together and a CLI already on somebody's laptop follows
79
+ * without a release.
80
+ * 3. Otherwise the documented path, for a completion path shaped in some way
81
+ * we don't recognise.
82
+ *
83
+ * Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
84
+ * the block is additive, and every server that predates it must keep working.
80
85
  */
81
86
  export function heartbeatPathFor(run) {
87
+ if (run.heartbeat_path)
88
+ return run.heartbeat_path;
82
89
  const completion = run.completion_path;
83
90
  const suffix = '/complete';
84
91
  return completion.endsWith(suffix)
@@ -255,6 +262,7 @@ export function parseRunRequest(data) {
255
262
  const trigger = obj(data.trigger);
256
263
  const replyInstructions = obj(data.reply_instructions);
257
264
  const completion = obj(data.completion);
265
+ const heartbeat = obj(data.heartbeat);
258
266
  const context = obj(data.context);
259
267
  const declaredSurface = str(replyInstructions.surface);
260
268
  const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
@@ -297,6 +305,9 @@ export function parseRunRequest(data) {
297
305
  reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
298
306
  reply_body_extra: bodyExtra(replyInstructions.body_extra),
299
307
  completion_path: str(completion.path) ?? agentRunCompletePath(runId),
308
+ // No fallback of its own: an absent block is not an error, it is an older
309
+ // server, and `heartbeatPathFor` already knows what to do about that.
310
+ heartbeat_path: str(heartbeat.path),
300
311
  // Accepted both at the top level and nested under `context`, because the
301
312
  // server can move them without a CLI release either way — and a published
302
313
  // CLI cannot be redeployed to follow.
@@ -83,6 +83,8 @@ export interface HarnessInvocation {
83
83
  exec: string;
84
84
  workdir: string;
85
85
  timeoutSeconds?: number;
86
+ /** Seconds of total silence before a run is treated as stalled. */
87
+ idleTimeoutSeconds?: number;
86
88
  machine?: string;
87
89
  url?: string;
88
90
  allowUnverified?: boolean;
@@ -176,8 +176,14 @@ export function harnessArguments(invocation) {
176
176
  '--workdir',
177
177
  invocation.workdir,
178
178
  ];
179
+ // Compared against `undefined` rather than tested for truth: `0` is a real
180
+ // setting for both flags — no wall-clock cap, no watchdog — and dropping it
181
+ // would install a daemon quietly running the defaults instead.
179
182
  if (invocation.timeoutSeconds !== undefined)
180
183
  args.push('--timeout', String(invocation.timeoutSeconds));
184
+ if (invocation.idleTimeoutSeconds !== undefined) {
185
+ args.push('--idle-timeout', String(invocation.idleTimeoutSeconds));
186
+ }
181
187
  if (invocation.machine)
182
188
  args.push('--machine', invocation.machine);
183
189
  if (invocation.url)
@@ -26,6 +26,19 @@ export interface ExecOptions {
26
26
  * genuinely takes hours and a local clock is the wrong thing to bound it by.
27
27
  */
28
28
  timeoutMs: number;
29
+ /**
30
+ * Kill the run when the child produces **nothing** for this long.
31
+ *
32
+ * The question a wall clock cannot answer: is this run working, or wedged?
33
+ * Bytes on stdout or stderr are the one signal every brain emits — stream
34
+ * events, a progress line, a log — so a window of total silence is the
35
+ * closest thing to evidence that a child has stopped making progress rather
36
+ * than merely taking a while. Every chunk on either stream resets it.
37
+ *
38
+ * ``0`` or unset disables it, at which point only {@link timeoutMs}, the
39
+ * {@link cancelSignal} and the server's lease can end the run.
40
+ */
41
+ idleTimeoutMs?: number;
29
42
  /** Working directory for the child. Defaults to the CLI's cwd. */
30
43
  cwd?: string;
31
44
  /** Extra environment for the child, merged over ``process.env``. */
@@ -55,7 +68,18 @@ export interface ExecResult {
55
68
  stdout: string;
56
69
  stderr: string;
57
70
  exitCode: number | null;
71
+ /** The run overran {@link ExecOptions.timeoutMs}. */
58
72
  timedOut: boolean;
73
+ /**
74
+ * The run went silent for {@link ExecOptions.idleTimeoutMs} and was killed.
75
+ *
76
+ * Separate from {@link timedOut} because the two say different things to
77
+ * whoever reads the failure: one run used its whole budget, the other stopped
78
+ * doing anything at all. They are handled identically everywhere else — both
79
+ * are kills from outside, so both salvage partial output and keep the
80
+ * session they had recorded.
81
+ */
82
+ idleTimedOut: boolean;
59
83
  /** The run was stopped through {@link ExecOptions.cancelSignal}. */
60
84
  cancelled: boolean;
61
85
  /** Populated when the process could not be started at all. */
@@ -76,6 +100,17 @@ export declare const CANCELLED_ERROR = "cancelled";
76
100
  * Kept to one line for the same reason.
77
101
  */
78
102
  export declare function timeoutMessage(timeoutMs: number): string;
103
+ /**
104
+ * What a run killed by the inactivity watchdog reports.
105
+ *
106
+ * Same job as {@link timeoutMessage}, and the same constraint: the server
107
+ * echoes a failed run's error into the thread, so this is read by the person
108
+ * who was waiting rather than by whoever tails `agent.log`. It has to say what
109
+ * happened ("nothing came out of it"), that this is an inference rather than a
110
+ * crash the agent reported, and how to change the judgement — in one line,
111
+ * because the notice it lands in has room for one.
112
+ */
113
+ export declare function idleTimeoutMessage(idleTimeoutMs: number): string;
79
114
  /**
80
115
  * Execute the agent command with ``prompt`` on stdin and return its stdout.
81
116
  *
@@ -30,6 +30,38 @@ export const CANCELLED_ERROR = 'cancelled';
30
30
  export function timeoutMessage(timeoutMs) {
31
31
  return `timed out after ${timeoutMs}ms — raise the ceiling with --timeout <seconds> (0 = no limit)`;
32
32
  }
33
+ /**
34
+ * Say a silence window the way a person would say it.
35
+ *
36
+ * The default is fifteen minutes and it wants to read as "15 minutes", not
37
+ * "900000ms": this string is shown to whoever was waiting on the agent, and a
38
+ * number they have to convert is a number they will misread.
39
+ */
40
+ function renderWindow(ms) {
41
+ if (ms >= 60_000 && ms % 60_000 === 0) {
42
+ const minutes = ms / 60_000;
43
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
44
+ }
45
+ const seconds = ms / 1_000;
46
+ const rendered = Number.isInteger(seconds)
47
+ ? String(seconds)
48
+ : seconds.toFixed(seconds < 1 ? 2 : 1);
49
+ return `${rendered} second${rendered === '1' ? '' : 's'}`;
50
+ }
51
+ /**
52
+ * What a run killed by the inactivity watchdog reports.
53
+ *
54
+ * Same job as {@link timeoutMessage}, and the same constraint: the server
55
+ * echoes a failed run's error into the thread, so this is read by the person
56
+ * who was waiting rather than by whoever tails `agent.log`. It has to say what
57
+ * happened ("nothing came out of it"), that this is an inference rather than a
58
+ * crash the agent reported, and how to change the judgement — in one line,
59
+ * because the notice it lands in has room for one.
60
+ */
61
+ export function idleTimeoutMessage(idleTimeoutMs) {
62
+ return (`no output for ${renderWindow(idleTimeoutMs)} — treated as stalled; ` +
63
+ 'tune with --idle-timeout <seconds> (0 = disabled)');
64
+ }
33
65
  function clamp(text) {
34
66
  if (text.length <= MAX_OUTPUT_CHARS)
35
67
  return text;
@@ -49,6 +81,7 @@ export async function execAgent(options) {
49
81
  let stdout = '';
50
82
  let stderr = '';
51
83
  let timedOut = false;
84
+ let idleTimedOut = false;
52
85
  let cancelled = false;
53
86
  let settled = false;
54
87
  // Nothing to stop if the stop already happened: a run cancelled while it
@@ -60,6 +93,7 @@ export async function execAgent(options) {
60
93
  stderr: '',
61
94
  exitCode: null,
62
95
  timedOut: false,
96
+ idleTimedOut: false,
63
97
  cancelled: true,
64
98
  error: CANCELLED_ERROR,
65
99
  durationMs: Date.now() - started,
@@ -88,6 +122,7 @@ export async function execAgent(options) {
88
122
  stderr: '',
89
123
  exitCode: null,
90
124
  timedOut: false,
125
+ idleTimedOut: false,
91
126
  cancelled: false,
92
127
  error: err instanceof Error ? err.message : String(err),
93
128
  durationMs: Date.now() - started,
@@ -95,15 +130,32 @@ export async function execAgent(options) {
95
130
  return;
96
131
  }
97
132
  let timer;
133
+ let idleTimer;
98
134
  let killTimer;
99
- /** Why the run ended, when it ended because we ended it. */
100
- const stopReason = () => cancelled ? CANCELLED_ERROR : timedOut ? timeoutMessage(options.timeoutMs) : undefined;
135
+ const idleMs = options.idleTimeoutMs ?? 0;
136
+ /**
137
+ * Why the run ended, when it ended because we ended it.
138
+ *
139
+ * Ordered by which decision was made first in practice: a deliberate stop
140
+ * outranks either clock, and the wall clock outranks the watchdog because
141
+ * `terminate` disarms the watchdog on its way out — so if both flags are
142
+ * somehow set, the ceiling is what actually fired.
143
+ */
144
+ const stopReason = () => cancelled
145
+ ? CANCELLED_ERROR
146
+ : timedOut
147
+ ? timeoutMessage(options.timeoutMs)
148
+ : idleTimedOut
149
+ ? idleTimeoutMessage(idleMs)
150
+ : undefined;
101
151
  const finish = (result) => {
102
152
  if (settled)
103
153
  return;
104
154
  settled = true;
105
155
  if (timer !== undefined)
106
156
  clearTimeout(timer);
157
+ if (idleTimer !== undefined)
158
+ clearTimeout(idleTimer);
107
159
  if (killTimer !== undefined)
108
160
  clearTimeout(killTimer);
109
161
  options.cancelSignal?.removeEventListener('abort', onAbort);
@@ -155,6 +207,21 @@ export async function execAgent(options) {
155
207
  const terminate = () => {
156
208
  if (settled || killTimer !== undefined)
157
209
  return;
210
+ // Both clocks are disarmed here rather than in `finish`, because a
211
+ // termination takes up to KILL_GRACE_MS and neither clock may re-decide
212
+ // the reason during it. A ceiling that lapses while a stalled run is
213
+ // being killed would otherwise overwrite "no output for 15 minutes" with
214
+ // "timed out", sending whoever reads it to the wrong flag — and the
215
+ // child's dying output would likewise re-arm a watchdog for a run that
216
+ // is already on its way out.
217
+ if (timer !== undefined) {
218
+ clearTimeout(timer);
219
+ timer = undefined;
220
+ }
221
+ if (idleTimer !== undefined) {
222
+ clearTimeout(idleTimer);
223
+ idleTimer = undefined;
224
+ }
158
225
  signalTree('SIGTERM');
159
226
  killTimer = setTimeout(() => {
160
227
  signalTree('SIGKILL');
@@ -167,11 +234,30 @@ export async function execAgent(options) {
167
234
  stderr: clamp(stderr),
168
235
  exitCode: null,
169
236
  timedOut,
237
+ idleTimedOut,
170
238
  cancelled,
171
239
  error: stopReason(),
172
240
  });
173
241
  }, KILL_GRACE_MS);
174
242
  };
243
+ /**
244
+ * (Re)start the silence window.
245
+ *
246
+ * Armed once the child exists and again on every chunk it writes, so the
247
+ * window measures time since the last sign of life rather than time since
248
+ * the run began. Deliberately a no-op once a kill is under way: the run's
249
+ * fate is already decided, and the flag that explains it is already set.
250
+ */
251
+ const armIdleWatchdog = () => {
252
+ if (idleMs <= 0 || settled || killTimer !== undefined)
253
+ return;
254
+ if (idleTimer !== undefined)
255
+ clearTimeout(idleTimer);
256
+ idleTimer = setTimeout(() => {
257
+ idleTimedOut = true;
258
+ terminate();
259
+ }, idleMs);
260
+ };
175
261
  function onAbort() {
176
262
  cancelled = true;
177
263
  terminate();
@@ -185,6 +271,7 @@ export async function execAgent(options) {
185
271
  options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
186
272
  child.stdout?.on('data', (chunk) => {
187
273
  const text = chunk.toString();
274
+ armIdleWatchdog();
188
275
  // Capture is bounded; the callback is not. A parser that stops being fed
189
276
  // at 512 KB would report stale progress and salvage the wrong text for
190
277
  // the rest of a long run — and the callback keeps nothing per chunk.
@@ -198,6 +285,10 @@ export async function execAgent(options) {
198
285
  }
199
286
  });
200
287
  child.stderr?.on('data', (chunk) => {
288
+ // stderr counts as life exactly as much as stdout does. An agent that
289
+ // logs its progress to stderr and answers on stdout — several do — is
290
+ // plainly working, and a watchdog watching one stream would kill it.
291
+ armIdleWatchdog();
201
292
  if (stderr.length < MAX_OUTPUT_CHARS)
202
293
  stderr += chunk.toString();
203
294
  });
@@ -208,21 +299,27 @@ export async function execAgent(options) {
208
299
  stderr: clamp(stderr),
209
300
  exitCode: null,
210
301
  timedOut,
302
+ idleTimedOut,
211
303
  cancelled,
212
304
  error: err.message,
213
305
  });
214
306
  });
215
307
  child.on('close', (code) => {
216
308
  finish({
217
- ok: !timedOut && !cancelled && code === 0,
309
+ ok: !timedOut && !idleTimedOut && !cancelled && code === 0,
218
310
  stdout: clamp(stdout),
219
311
  stderr: clamp(stderr),
220
312
  exitCode: code,
221
313
  timedOut,
314
+ idleTimedOut,
222
315
  cancelled,
223
316
  error: stopReason(),
224
317
  });
225
318
  });
319
+ // Started here, not at the top of the function: the window measures the
320
+ // child's silence, and a child that has not been spawned yet — or that
321
+ // failed to spawn at all — has nothing to be silent about.
322
+ armIdleWatchdog();
226
323
  // stdin may already be closed if the command failed to start.
227
324
  try {
228
325
  child.stdin?.on('error', () => {
@@ -50,6 +50,17 @@ export interface PromptContext {
50
50
  * They cannot tell those apart from a sentence that says neither.
51
51
  */
52
52
  timeoutMs?: number;
53
+ /**
54
+ * How long the run may produce nothing at all before it is killed, in
55
+ * milliseconds. ``0`` is `--idle-timeout 0`: no watchdog.
56
+ *
57
+ * Threaded in for the same reason as {@link timeoutMs}, and it now carries
58
+ * most of the weight: with no wall-clock ceiling by default, this is usually
59
+ * the only local limit there is. It is also a limit an agent can act on,
60
+ * which a duration is not — "do not go completely silent for a quarter of an
61
+ * hour" is advice about how to work, not a request to hurry.
62
+ */
63
+ idleTimeoutMs?: number;
53
64
  }
54
65
  /** Build the stdin prompt for one run. */
55
66
  export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
@@ -23,31 +23,68 @@ function renderBudget(timeoutMs) {
23
23
  return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
24
24
  }
25
25
  /**
26
- * The bullet that tells the agent how much time it actually has.
26
+ * The bullet that tells the agent what its run is actually bounded by.
27
27
  *
28
- * Both branches are load-bearing. A named ceiling stops an agent from treating
29
- * a two-hour budget like a five-minute one (the failure this replaced: a
30
- * multi-minute coding task killed at 300s having been told only that a limit
31
- * existed). And an *uncapped* run still needs a word about pace, because
32
- * nothing is posted until the turn ends — a run that explores forever reads,
33
- * from the thread, exactly like one that crashed.
28
+ * Three shapes, and each says something different about how to work:
29
+ *
30
+ * * **A wall-clock ceiling** (`--timeout <n>`) means budget the turn. Naming
31
+ * the number is what stops an agent from treating two hours like five
32
+ * minutes — the failure this replaced was a multi-minute coding task killed
33
+ * at 300s having been told only that "a limit" existed. A watchdog set
34
+ * *shorter* than the ceiling is named alongside it, because it is then the
35
+ * deadline that actually fires first: told only "you have two hours", an
36
+ * agent has no reason to break up the quiet forty-minute step that kills it
37
+ * at fifteen.
38
+ * * **The watchdog alone** — the default — means the turn is bounded by
39
+ * *silence*, not duration. That is a limit an agent can actually act on, and
40
+ * it must be stated as what it is, because an agent told it has "no limit"
41
+ * and then killed at fifteen minutes of quiet was told something false.
42
+ * * **Neither** means nothing local will stop the run, which still needs a word
43
+ * about pace: nothing is posted until the turn ends, so a run that explores
44
+ * forever reads, from the thread, exactly like one that crashed.
34
45
  */
35
- function timeBudgetLines(timeoutMs) {
36
- if (timeoutMs === undefined)
46
+ function timeBudgetLines(timeoutMs, idleTimeoutMs) {
47
+ // Said nothing about by the caller: say nothing back, rather than describe a
48
+ // regime this prompt has not been told is in force.
49
+ if (timeoutMs === undefined && idleTimeoutMs === undefined)
37
50
  return [];
38
- if (timeoutMs <= 0) {
51
+ const wallMs = timeoutMs ?? 0;
52
+ const idleMs = idleTimeoutMs ?? 0;
53
+ if (wallMs > 0) {
54
+ return [
55
+ `- You have ${renderBudget(wallMs)} for this whole turn. Past that you are killed`,
56
+ ' mid-thought and the person waiting gets nothing, so spend the budget on the',
57
+ ' work and leave yourself room to write the answer.',
58
+ // Only when it can actually fire first. A watchdog window at or above the
59
+ // ceiling is unreachable — a run silent that long has already overrun —
60
+ // and naming an unreachable limit is noise the agent has to reason past.
61
+ ...(idleMs > 0 && idleMs < wallMs
62
+ ? [
63
+ '- Going quiet ends the turn early, well before that: produce no output at',
64
+ ` all for ${renderBudget(idleMs)} and the run is killed and reported as`,
65
+ ' stalled. Ordinary work clears that on its own; a single step that will',
66
+ ' be silent for longer is the one thing to break up.',
67
+ ]
68
+ : []),
69
+ ];
70
+ }
71
+ if (idleMs > 0) {
39
72
  return [
40
- '- There is no fixed time limit on this turn, so work that genuinely takes',
41
- ' a while is fine — finish it rather than cutting it short. But nothing you',
42
- ' write is posted until the turn ends, and a long silence gets reported as a',
43
- ' stalled run, so keep moving toward an answer rather than exploring',
44
- ' indefinitely.',
73
+ '- There is no fixed time limit on this turn, so work that genuinely takes a',
74
+ ' while is fine — finish it rather than cutting it short. What is limited is',
75
+ ` silence: produce no output at all for ${renderBudget(idleMs)} and the run is`,
76
+ ' killed and reported as stalled. Ordinary work clears that on its own; a',
77
+ ' single step that will be quiet for longer than that is the one thing to',
78
+ ' break up. Nothing you write is posted until the turn ends, so keep moving',
79
+ ' toward an answer rather than exploring indefinitely.',
45
80
  ];
46
81
  }
47
82
  return [
48
- `- You have ${renderBudget(timeoutMs)} for this whole turn. Past that you are killed`,
49
- ' mid-thought and the person waiting gets nothing, so spend the budget on the',
50
- ' work and leave yourself room to write the answer.',
83
+ '- There is no time limit on this turn and nothing local will stop it, so work',
84
+ ' that genuinely takes a while is fine — finish it rather than cutting it',
85
+ ' short. But nothing you write is posted until the turn ends, so a run that',
86
+ ' explores indefinitely is indistinguishable from one that crashed. Keep',
87
+ ' moving toward an answer.',
51
88
  ];
52
89
  }
53
90
  /**
@@ -187,7 +224,7 @@ export function buildPrompt(run, context) {
187
224
  // not buried under the tool advice: how long the agent has changes how it
188
225
  // should approach the work, so it has to be read before any of the "go and
189
226
  // look" bullets below.
190
- ...timeBudgetLines(context.timeoutMs),
227
+ ...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs),
191
228
  // Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
192
229
  // them — so a run that inspects its loaded tools concludes it cannot
193
230
  // browse and says so to a customer, when one ToolSearch call away it
@@ -26,18 +26,36 @@ import type { spawn } from 'node:child_process';
26
26
  import WebSocket from 'ws';
27
27
  import type { AgentCredentials } from './credentials.js';
28
28
  /**
29
- * Default ceiling on one agent invocation.
29
+ * Default ceiling on one agent invocation: none.
30
30
  *
31
- * Two hours, up from five minutes. Five was a guess made when a run meant
32
- * "answer a mention", and it was wrong in the expensive direction: a paired
33
- * brain given a real coding task in production was killed at 300s, mid-edit,
34
- * and reported `timed out after 300000ms` to the person waiting. A coding
35
- * agent's unit of work is not a chat reply, and no local clock can tell a
36
- * wedged run from a productive one — the server's lease can, which is what
37
- * heartbeats are for. This is now a backstop against a genuinely stuck process
38
- * rather than a shape imposed on the work; `--timeout 0` removes it entirely.
31
+ * The wall clock kept being raised — five minutes, then two hours — because it
32
+ * was answering the wrong question. "How long has this run been going?" does
33
+ * not distinguish a wedged child from a productive one; it only distinguishes
34
+ * long work from short, and killing long work is precisely the failure the two
35
+ * previous defaults each produced in production. What the harness can actually
36
+ * observe is whether the child is *doing* anything, which is what
37
+ * {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
38
+ *
39
+ * So a run is now bounded by three things, none of them a fixed duration:
40
+ * the inactivity watchdog (default 15 minutes of total silence), the server's
41
+ * lease on the run (heartbeats stop, the row is reaped), and the Stop button in
42
+ * ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
43
+ * remains fully supported for anyone who wants a hard ceiling as well.
44
+ */
45
+ export declare const DEFAULT_EXEC_TIMEOUT_MS = 0;
46
+ /**
47
+ * Default silence window before a run is treated as stalled.
48
+ *
49
+ * Fifteen minutes is chosen against what real work looks like, not against
50
+ * what feels responsive: an agent mid-task emits stream events, tool calls and
51
+ * log lines continuously, so the gaps in a healthy run are seconds. The long
52
+ * tail that legitimately goes quiet is a single slow tool call — a test suite,
53
+ * a build, a big fetch — and fifteen minutes clears those comfortably while
54
+ * still catching the failure this exists for, a child wedged on a stdin prompt
55
+ * that heartbeats look perfectly healthy for and that burns the operator's
56
+ * tokens and their agent's availability indefinitely.
39
57
  */
40
- export declare const DEFAULT_EXEC_TIMEOUT_MS = 7200000;
58
+ export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
41
59
  export interface AgentRunOptions {
42
60
  /** Shell command that runs the agent, e.g. ``claude -p``. */
43
61
  exec: string;
@@ -46,8 +64,16 @@ export interface AgentRunOptions {
46
64
  url?: string;
47
65
  /** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
48
66
  machine?: string;
49
- /** Per-run execution timeout. ``0`` disables it — see `--timeout 0`. */
67
+ /**
68
+ * Per-run wall-clock ceiling. ``0`` disables it, and that is now the default
69
+ * — see {@link DEFAULT_EXEC_TIMEOUT_MS}.
70
+ */
50
71
  timeoutMs?: number;
72
+ /**
73
+ * How long a run may produce no output at all before it is killed. ``0``
74
+ * disables the watchdog — see {@link DEFAULT_IDLE_TIMEOUT_MS}.
75
+ */
76
+ idleTimeoutMs?: number;
51
77
  /** Print the reply that would be posted and make no writes. */
52
78
  dryRun?: boolean;
53
79
  /** Execute runs whose signature could not be verified. Off by default. */
package/dist/agent/run.js CHANGED
@@ -26,7 +26,7 @@ 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';
29
+ import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
30
30
  import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, 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';
@@ -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;
@@ -365,6 +383,25 @@ function defaultProbe(command, cwd) {
365
383
  });
366
384
  return { status: result.status, stdout: result.stdout ?? '' };
367
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
+ }
368
405
  /**
369
406
  * Run the agent command once and turn its output into a reply.
370
407
  *
@@ -380,10 +417,14 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
380
417
  // share one deadline, which costs the retry nothing in practice — a rejected
381
418
  // `--resume` fails in milliseconds, long before any of the budget is spent.
382
419
  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.
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.
386
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;
387
428
  const startedAt = Date.now();
388
429
  const remainingMs = () => budgetMs - (Date.now() - startedAt);
389
430
  const timeLeft = () => !capped || remainingMs() > 0;
@@ -398,6 +439,16 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
398
439
  let recordedSession;
399
440
  /** The reader for the attempt currently running, if this brain streams. */
400
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 = '';
401
452
  /**
402
453
  * Act on what the stream has said so far.
403
454
  *
@@ -438,10 +489,12 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
438
489
  // session, and inheriting the failed attempt's id would resume nothing.
439
490
  const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
440
491
  stream = reader;
492
+ tail = '';
441
493
  return execAgent({
442
494
  command,
443
495
  prompt,
444
496
  timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
497
+ idleTimeoutMs: idleMs,
445
498
  cwd: runtime.workdir,
446
499
  env: mcp.execEnv,
447
500
  spawnImpl: options.spawnImpl,
@@ -451,7 +504,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
451
504
  reader.push(chunk);
452
505
  observe(reader);
453
506
  }
454
- : undefined,
507
+ : runtime.brain === 'plain'
508
+ ? (chunk) => {
509
+ tail = (tail + chunk).slice(-MAX_SALVAGE_CHARS);
510
+ }
511
+ : undefined,
455
512
  });
456
513
  };
457
514
  // Wall clock for the whole turn, retry included — what the operator waited,
@@ -462,10 +519,28 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
462
519
  }
463
520
  const failure = (result) => {
464
521
  // 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
522
+ // tail of what the agent was writing turns "no output for 15 minutes" into
466
523
  // 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;
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;
469
544
  // The stderr tail yields room when there is a salvaged answer to carry: the
470
545
  // completion `error` is capped server-side, and of the two, what the agent
471
546
  // was saying beats the last 500 characters of its logging.
@@ -525,7 +600,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
525
600
  result = await exec(build());
526
601
  }
527
602
  if (!result.ok) {
528
- if (!result.timedOut && !result.cancelled)
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)
529
608
  forgetRecordedSession();
530
609
  return failure(result);
531
610
  }
@@ -598,8 +677,9 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
598
677
  // it is. See PromptContext.mcpToolPattern.
599
678
  mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
600
679
  toolsGated: !runtime.skipPermissions,
601
- // The real number, not a vague "there is a limit" — see PromptContext.
680
+ // The real numbers, not a vague "there is a limit" — see PromptContext.
602
681
  timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
682
+ idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
603
683
  });
604
684
  const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
605
685
  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.0";
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.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.3.0';
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.3.0",
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",