@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.
@@ -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,25 +28,93 @@ 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.
36
+ *
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`.
35
44
  *
36
- * Returns ``undefined`` when the flag was absent (the harness applies its own
37
- * default) and {@link INVALID_TIMEOUT} after printing the error.
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.
38
48
  */
39
- function parseTimeoutSeconds(flags) {
40
- 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) {
41
72
  if (raw === undefined)
42
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;
43
78
  const seconds = Number(raw);
44
- if (!Number.isFinite(seconds) || seconds <= 0) {
45
- process.stderr.write('--timeout must be a positive number of seconds.\n');
46
- return INVALID_TIMEOUT;
79
+ if (!Number.isFinite(seconds) || seconds < 0 || seconds * 1000 > MAX_TIMER_DELAY_MS) {
80
+ return INVALID_DURATION;
47
81
  }
48
82
  return seconds;
49
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) {
93
+ // Keeps the phrase "--timeout must be a positive number" verbatim: the
94
+ // desktop app's supervisor matches on it to tell a misconfigured child from
95
+ // a recoverable crash (`desktop/src/main/agent/supervisor-policy.ts`), and
96
+ // it reads this CLI's stderr rather than any structured output.
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');
99
+ }
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;
117
+ }
50
118
  /**
51
119
  * Environment the daemon must be given explicitly.
52
120
  *
@@ -120,7 +188,10 @@ async function installDaemonCommand(flags) {
120
188
  // plist makes every launch exit on that same validation and KeepAlive restart
121
189
  // it forever.
122
190
  const timeoutSeconds = parseTimeoutSeconds(flags);
123
- if (timeoutSeconds === INVALID_TIMEOUT)
191
+ if (timeoutSeconds === INVALID_DURATION)
192
+ return 1;
193
+ const idleTimeoutSeconds = parseIdleTimeoutSeconds(flags);
194
+ if (idleTimeoutSeconds === INVALID_DURATION)
124
195
  return 1;
125
196
  // Absolute, and created now. A supervisor does not inherit the installing
126
197
  // shell's current directory, so a relative `--workdir .` copied verbatim into
@@ -142,6 +213,7 @@ async function installDaemonCommand(flags) {
142
213
  exec,
143
214
  workdir,
144
215
  timeoutSeconds,
216
+ idleTimeoutSeconds,
145
217
  machine: flagString(flags, 'machine'),
146
218
  url: flagString(flags, 'url'),
147
219
  allowUnverified: flags['allow-unverified'] === true,
@@ -215,7 +287,10 @@ async function runCommand(flags) {
215
287
  if (!credentials)
216
288
  return 1;
217
289
  const parsedTimeout = parseTimeoutSeconds(flags);
218
- if (parsedTimeout === INVALID_TIMEOUT)
290
+ if (parsedTimeout === INVALID_DURATION)
291
+ return 1;
292
+ const parsedIdleTimeout = parseIdleTimeoutSeconds(flags);
293
+ if (parsedIdleTimeout === INVALID_DURATION)
219
294
  return 1;
220
295
  // First signal stops accepting work and lets the in-flight run finish
221
296
  // reporting itself (so the server doesn't wait on a run we abandoned); a
@@ -239,6 +314,7 @@ async function runCommand(flags) {
239
314
  machine: flagString(flags, 'machine'),
240
315
  cwd: requestedWorkdir(flags),
241
316
  timeoutMs: parsedTimeout === undefined ? DEFAULT_EXEC_TIMEOUT_MS : parsedTimeout * 1000,
317
+ idleTimeoutMs: parsedIdleTimeout === undefined ? DEFAULT_IDLE_TIMEOUT_MS : parsedIdleTimeout * 1000,
242
318
  dryRun: flags['dry-run'] === true,
243
319
  allowUnverified: flags['allow-unverified'] === true,
244
320
  // Two spellings for the same opt-out: `--safe-tools` says what you get,
@@ -13,6 +13,7 @@
13
13
  * Server-side sources of truth:
14
14
  * - payload shape — `shared/events/payloads/agent_runs.py`
15
15
  * - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
16
+ * - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
16
17
  * - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
17
18
  * - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
18
19
  */
@@ -28,12 +29,55 @@ export declare const RUN_STATUS_SUCCEEDED = "succeeded";
28
29
  export declare const RUN_STATUS_FAILED = "failed";
29
30
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
30
31
  export declare const MAX_ERROR_CHARS = 1000;
32
+ /**
33
+ * The completion `error` that means "a person asked for this run to stop".
34
+ *
35
+ * A magic string on purpose, and it must be sent **exactly**: the server
36
+ * matches on it to word the notice it drops in the thread ("cancelled" rather
37
+ * than "failed"). Prefixing it with a reason, or sending
38
+ * `cancelled_by_user: …`, makes a deliberate stop read as a crash.
39
+ */
40
+ export declare const RUN_ERROR_CANCELLED_BY_USER = "cancelled_by_user";
41
+ /**
42
+ * Cap on the `progress` string sent with a heartbeat.
43
+ *
44
+ * Progress is a hint for the UI — "using Bash", the first line of what the
45
+ * agent is drafting — not a transcript. Bounded here so a brain that emits one
46
+ * enormous block of text can't turn a keep-alive into a payload.
47
+ */
48
+ export declare const MAX_PROGRESS_CHARS = 200;
31
49
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
32
50
  export declare const REDEEM_PATH = "/v1/agent-pairing/redeem";
33
51
  /** `POST` — send a message into a conversation as the paired agent. */
34
52
  export declare function conversationMessagesPath(conversationId: string): string;
35
53
  /** `POST` — close out a run. Must be called for every run, success or not. */
36
54
  export declare function agentRunCompletePath(runId: string): string;
55
+ /**
56
+ * `POST` — say a run is still alive, and hear back whether it was cancelled.
57
+ *
58
+ * The server holds a lease on every running row and reaps the ones that stop
59
+ * being heard from; a run that legitimately takes four hours has to say so on a
60
+ * cadence or it is reaped mid-work. The response also carries
61
+ * `cancel_requested`, which is the only way a laptop behind NAT learns that
62
+ * somebody pressed stop.
63
+ */
64
+ export declare function agentRunHeartbeatPath(runId: string): string;
65
+ /**
66
+ * Where this run's heartbeats go, in descending order of how much we know.
67
+ *
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.
79
+ */
80
+ export declare function heartbeatPathFor(run: AgentRunRequest): string;
37
81
  /**
38
82
  * A file or image hanging off a message.
39
83
  *
@@ -177,6 +221,14 @@ export interface AgentRunRequest {
177
221
  reply_body_extra?: Record<string, unknown>;
178
222
  /** Server-supplied path to complete the run at. */
179
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;
180
232
  /**
181
233
  * Earlier messages on this surface, oldest first. Absent on a server that
182
234
  * predates the field — which is the case the harness shipped with, so an
@@ -13,6 +13,7 @@
13
13
  * Server-side sources of truth:
14
14
  * - payload shape — `shared/events/payloads/agent_runs.py`
15
15
  * - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
16
+ * - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
16
17
  * - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
17
18
  * - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
18
19
  */
@@ -28,6 +29,23 @@ export const RUN_STATUS_SUCCEEDED = 'succeeded';
28
29
  export const RUN_STATUS_FAILED = 'failed';
29
30
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
30
31
  export const MAX_ERROR_CHARS = 1000;
32
+ /**
33
+ * The completion `error` that means "a person asked for this run to stop".
34
+ *
35
+ * A magic string on purpose, and it must be sent **exactly**: the server
36
+ * matches on it to word the notice it drops in the thread ("cancelled" rather
37
+ * than "failed"). Prefixing it with a reason, or sending
38
+ * `cancelled_by_user: …`, makes a deliberate stop read as a crash.
39
+ */
40
+ export const RUN_ERROR_CANCELLED_BY_USER = 'cancelled_by_user';
41
+ /**
42
+ * Cap on the `progress` string sent with a heartbeat.
43
+ *
44
+ * Progress is a hint for the UI — "using Bash", the first line of what the
45
+ * agent is drafting — not a transcript. Bounded here so a brain that emits one
46
+ * enormous block of text can't turn a keep-alive into a payload.
47
+ */
48
+ export const MAX_PROGRESS_CHARS = 200;
31
49
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
32
50
  export const REDEEM_PATH = '/v1/agent-pairing/redeem';
33
51
  /** `POST` — send a message into a conversation as the paired agent. */
@@ -38,6 +56,42 @@ export function conversationMessagesPath(conversationId) {
38
56
  export function agentRunCompletePath(runId) {
39
57
  return `/v1/agent-runs/${encodeURIComponent(runId)}/complete`;
40
58
  }
59
+ /**
60
+ * `POST` — say a run is still alive, and hear back whether it was cancelled.
61
+ *
62
+ * The server holds a lease on every running row and reaps the ones that stop
63
+ * being heard from; a run that legitimately takes four hours has to say so on a
64
+ * cadence or it is reaped mid-work. The response also carries
65
+ * `cancel_requested`, which is the only way a laptop behind NAT learns that
66
+ * somebody pressed stop.
67
+ */
68
+ export function agentRunHeartbeatPath(runId) {
69
+ return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
70
+ }
71
+ /**
72
+ * Where this run's heartbeats go, in descending order of how much we know.
73
+ *
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.
85
+ */
86
+ export function heartbeatPathFor(run) {
87
+ if (run.heartbeat_path)
88
+ return run.heartbeat_path;
89
+ const completion = run.completion_path;
90
+ const suffix = '/complete';
91
+ return completion.endsWith(suffix)
92
+ ? `${completion.slice(0, -suffix.length)}/heartbeat`
93
+ : agentRunHeartbeatPath(run.run_id);
94
+ }
41
95
  /**
42
96
  * Ceiling on how many context entries are kept from one payload.
43
97
  *
@@ -208,6 +262,7 @@ export function parseRunRequest(data) {
208
262
  const trigger = obj(data.trigger);
209
263
  const replyInstructions = obj(data.reply_instructions);
210
264
  const completion = obj(data.completion);
265
+ const heartbeat = obj(data.heartbeat);
211
266
  const context = obj(data.context);
212
267
  const declaredSurface = str(replyInstructions.surface);
213
268
  const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
@@ -250,6 +305,9 @@ export function parseRunRequest(data) {
250
305
  reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
251
306
  reply_body_extra: bodyExtra(replyInstructions.body_extra),
252
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),
253
311
  // Accepted both at the top level and nested under `context`, because the
254
312
  // server can move them without a CLI release either way — and a published
255
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)
@@ -17,12 +17,49 @@ export interface ExecOptions {
17
17
  command: string;
18
18
  /** Prompt written to the child's stdin. */
19
19
  prompt: string;
20
- /** Hard cap on wall-clock runtime. */
20
+ /**
21
+ * Hard cap on wall-clock runtime.
22
+ *
23
+ * ``0`` disables it: the child then runs until it exits on its own, until
24
+ * {@link cancelSignal} fires, or until the server gives up on the run. That
25
+ * is a real configuration (`--timeout 0`), not a degenerate one — some work
26
+ * genuinely takes hours and a local clock is the wrong thing to bound it by.
27
+ */
21
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;
22
42
  /** Working directory for the child. Defaults to the CLI's cwd. */
23
43
  cwd?: string;
24
44
  /** Extra environment for the child, merged over ``process.env``. */
25
45
  env?: NodeJS.ProcessEnv;
46
+ /**
47
+ * Ends the run early — a person pressed stop, or the server closed the run.
48
+ *
49
+ * Terminated through exactly the same SIGTERM → grace → SIGKILL path a
50
+ * timeout uses, because the problem is identical: the thing being stopped is
51
+ * a process *group* that may be ignoring polite signals.
52
+ */
53
+ cancelSignal?: AbortSignal;
54
+ /**
55
+ * Each stdout chunk, as it arrives.
56
+ *
57
+ * The captured ``stdout`` is still returned in full; this exists for the
58
+ * things that cannot wait for the process to exit — reading a session id out
59
+ * of the first event, tracking what the agent is doing for the heartbeat, and
60
+ * having *something* to salvage from a run that gets killed.
61
+ */
62
+ onStdout?: (chunk: string) => void;
26
63
  /** Injectable spawn — tests substitute a fake process. */
27
64
  spawnImpl?: typeof spawn;
28
65
  }
@@ -31,18 +68,54 @@ export interface ExecResult {
31
68
  stdout: string;
32
69
  stderr: string;
33
70
  exitCode: number | null;
71
+ /** The run overran {@link ExecOptions.timeoutMs}. */
34
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;
83
+ /** The run was stopped through {@link ExecOptions.cancelSignal}. */
84
+ cancelled: boolean;
35
85
  /** Populated when the process could not be started at all. */
36
86
  error?: string;
37
87
  durationMs: number;
38
88
  }
39
89
  /** Cap on captured output (in characters) so a runaway agent can't exhaust memory. */
40
90
  export declare const MAX_OUTPUT_CHARS: number;
91
+ /** ``error`` on a result the caller stopped deliberately. */
92
+ export declare const CANCELLED_ERROR = "cancelled";
93
+ /**
94
+ * What a timed-out run reports.
95
+ *
96
+ * Ends with the way out, because this string does not stay in `agent.log`: the
97
+ * server echoes a failed run's error into the thread, so the person who was
98
+ * waiting reads it. Told "timed out after 7200000ms" they learn only that
99
+ * something broke; told how to raise the ceiling they can fix it themselves.
100
+ * Kept to one line for the same reason.
101
+ */
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;
41
114
  /**
42
115
  * Execute the agent command with ``prompt`` on stdin and return its stdout.
43
116
  *
44
- * Never throws: a spawn failure, a non-zero exit, and a timeout all come back
45
- * as ``ok: false`` with the detail filled in, because the caller must report
46
- * *some* outcome to the server for every run.
117
+ * Never throws: a spawn failure, a non-zero exit, a timeout, and a cancellation
118
+ * all come back as ``ok: false`` with the detail filled in, because the caller
119
+ * must report *some* outcome to the server for every run.
47
120
  */
48
121
  export declare function execAgent(options: ExecOptions): Promise<ExecResult>;