@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/CHANGELOG.md CHANGED
@@ -4,6 +4,112 @@ 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
+
59
+ ## 0.3.0
60
+
61
+ Long runs. Every change here comes out of one production failure: a paired
62
+ brain was given a real coding task, worked on it for five minutes, and was
63
+ killed mid-edit by a timeout that had been chosen when "a run" meant "answer a
64
+ mention". The person waiting got `timed out after 300000ms` and nothing else —
65
+ no partial answer, no way to resume the session it had built up, and no way to
66
+ tell from the thread that the agent had been doing anything at all.
67
+
68
+ - **The default ceiling is two hours, and `--timeout 0` removes it.** A coding
69
+ agent's unit of work is not a chat reply. The timeout is now a backstop
70
+ against a genuinely wedged process rather than a shape imposed on the work,
71
+ and `0` — previously rejected — means the run ends when the agent ends it,
72
+ when somebody cancels it, or when the server stops renewing its lease. The
73
+ timeout message names the way out (`raise the ceiling with --timeout
74
+ <seconds>`) because that string does not stay in `agent.log`: the server
75
+ echoes a failed run's error into the thread, so the person waiting reads it.
76
+ - **Heartbeats keep long runs alive, and are how "stop" reaches a laptop.**
77
+ Every run this process has accepted is reported once a minute — the executing
78
+ one *and* every run queued behind it, since a run waiting behind a four-hour
79
+ job is `running` server-side for four hours and is reaped on exactly the same
80
+ clock. The response carries `cancel_requested`, which is the only channel by
81
+ which a stop pressed in ProhostAI can reach a machine that accepts no inbound
82
+ connections. Cancelling signals the command's whole process group the same way
83
+ a timeout does (SIGTERM, then SIGKILL five seconds later), reports the run
84
+ with the marker the server words as a cancellation, and posts no reply. A run
85
+ the server has already closed out (409) is abandoned outright: the child is
86
+ killed and nothing is posted, because there is nothing left to post to.
87
+ - **Against an older ProhostAI server, nothing changes.** The heartbeat endpoint
88
+ answers `404` there; the CLI says so once per run, stops asking, and behaves
89
+ exactly as 0.2.3 did. No `Depends-on`, no coordinated upgrade.
90
+ - **Claude Code runs are read as they happen (`--output-format stream-json`).**
91
+ Three things fall out of that, none of which could work when stdout was read
92
+ at exit. The session id is recorded the moment the run announces it, so a run
93
+ killed at hour two can still be resumed — where before, the id arrived with
94
+ the final envelope a killed run never produces, and two hours of context went
95
+ with it. The heartbeat carries what the agent is doing (the tool it just
96
+ called, or the opening of what it is writing). And a killed or cancelled run
97
+ reports the tail of the answer it had been writing rather than only the fact
98
+ that it stopped. A rejected reply still un-records the session, so the rule
99
+ that used to be expressed by recording late is kept. Codex and plain `--exec`
100
+ commands are untouched; heartbeats and cancellation apply to them all the
101
+ same, they just have no progress to report.
102
+ - **The prompt states the actual budget.** It used to say "this run has a hard
103
+ time limit" — the same sentence whether the limit was five minutes or four
104
+ hours, which is how an agent with two hours triages work it had time to
105
+ finish. It now says roughly how long, or that there is no fixed limit and a
106
+ long silence gets reported as a stalled run.
107
+ - **A dropped completion is retried for about ten minutes**, up from thirteen
108
+ seconds. The old schedule was sized for a five-minute run; losing the
109
+ completion of a four-hour one is worth waiting out a wifi drop for. The run
110
+ keeps being heartbeated throughout, so the row it is trying to close is not
111
+ reaped underneath the retries.
112
+
7
113
  ## 0.2.3
8
114
 
9
115
  Three things, all of them about a run knowing what is actually true about
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` | `300` | Seconds one run may take before the command is killed. |
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`. |
@@ -409,6 +410,37 @@ same judgement.
409
410
  | `--allow-unverified` | off | Run events whose signature can't be verified. See below. |
410
411
  | `--safe-tools` | off | Gate the agent's tools behind permission prompts instead of bypassing them. Claude Code and Codex only. Alias: `--no-skip-permissions`. |
411
412
 
413
+ ### Long runs
414
+
415
+ A paired brain is often asked to do work, not just answer — write the change,
416
+ run the tests, come back. That takes minutes or hours, and the harness is built
417
+ for it:
418
+
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.
431
+ - **Heartbeats keep it alive.** Every run this CLI has accepted — the one
432
+ executing *and* any queued behind it — is reported to ProhostAI once a minute,
433
+ which is what stops the server reaping a run that is still being worked on.
434
+ Against an older ProhostAI server the heartbeat endpoint answers `404`; the
435
+ CLI says so once per run and otherwise behaves exactly as it did before.
436
+ - **Cancel works.** Stopping a run in ProhostAI is answered on the next
437
+ heartbeat: the command's whole process group gets `SIGTERM`, then `SIGKILL`
438
+ five seconds later, and the run is reported as cancelled. No reply is posted.
439
+ - **A killed run is not silent.** With Claude Code the CLI reads the run's
440
+ output as it is produced, so the run's session id is saved immediately (the
441
+ next turn on that surface can still resume it) and a run that is killed or
442
+ cancelled reports the tail of whatever the agent had written.
443
+
412
444
  ### Keeping it running
413
445
 
414
446
  `agent run` in a terminal window stops being an agent the moment you close the
@@ -1,10 +1,10 @@
1
1
  /**
2
- * The two HTTP writes the harness makes back into ProhostAI: post the reply,
3
- * then close out the run.
2
+ * The HTTP writes the harness makes back into ProhostAI: post the reply, say a
3
+ * long run is still alive, and close the run out.
4
4
  *
5
- * Both authenticate with the paired API key over ``X-API-Key`` and both return
6
- * a result object rather than throwing — the runner has to keep going (and
7
- * still complete the run) whichever one fails.
5
+ * All of them authenticate with the paired API key over ``X-API-Key`` and all
6
+ * of them return a result object rather than throwing — the runner has to keep
7
+ * going (and still complete the run) whichever one fails.
8
8
  *
9
9
  * The paths come from the run event itself (`reply_instructions.path` /
10
10
  * `completion.path`), which is why these take a path rather than building one.
@@ -20,6 +20,13 @@ export interface ApiResult {
20
20
  ok: boolean;
21
21
  status: number;
22
22
  error?: string;
23
+ /**
24
+ * The response body, when the caller asked for it.
25
+ *
26
+ * Only the heartbeat reads a success body — it is the one call whose answer
27
+ * carries information (``cancel_requested``) rather than just an acceptance.
28
+ */
29
+ body?: string;
23
30
  }
24
31
  /**
25
32
  * Post the agent's reply into the conversation that triggered the run.
@@ -34,6 +41,47 @@ export interface ApiResult {
34
41
  * a CLI release.
35
42
  */
36
43
  export declare function postReply(options: ApiOptions, path: string, bodyKey: string, message: string, runId: string, extra?: Record<string, unknown>): Promise<ApiResult>;
44
+ /**
45
+ * What one heartbeat told us about a run.
46
+ *
47
+ * Four outcomes, and the harness does something different with each — which is
48
+ * why this is a tagged union rather than an {@link ApiResult}. In particular
49
+ * ``unknown`` (404) is not an error to report: it is what **every server built
50
+ * before heartbeats existed** answers, and the only correct response to it is
51
+ * to stop asking and behave exactly as this CLI did before.
52
+ */
53
+ export type HeartbeatOutcome =
54
+ /** The run is still open server-side. ``cancelRequested`` means: stop now. */
55
+ {
56
+ kind: 'alive';
57
+ cancelRequested: boolean;
58
+ }
59
+ /** 404 — an older server, or a run this credential no longer owns. */
60
+ | {
61
+ kind: 'unknown';
62
+ }
63
+ /** 409 — the run already reached a terminal state without us. */
64
+ | {
65
+ kind: 'resolved';
66
+ }
67
+ /** Anything else, including a network failure (``status: 0``). */
68
+ | {
69
+ kind: 'error';
70
+ status: number;
71
+ detail?: string;
72
+ };
73
+ /**
74
+ * Tell the server a run is still being worked on, and ask whether it should be.
75
+ *
76
+ * Sent on a cadence for every run this process has accepted and not yet
77
+ * reported — the one being executed *and* the ones queued behind it, because a
78
+ * run queued behind a four-hour job is exactly as reapable as the job itself.
79
+ *
80
+ * ``progress`` is a courtesy for whoever is watching the run in the UI, never a
81
+ * requirement: a queued run has none, and the brains that don't stream their
82
+ * work send none either.
83
+ */
84
+ export declare function heartbeatRun(options: ApiOptions, path: string, progress?: string): Promise<HeartbeatOutcome>;
37
85
  /**
38
86
  * Report a run's terminal state. Called for every run — including one whose
39
87
  * command crashed — so the server never waits on a run this harness abandoned.
package/dist/agent/api.js CHANGED
@@ -1,18 +1,20 @@
1
1
  /**
2
- * The two HTTP writes the harness makes back into ProhostAI: post the reply,
3
- * then close out the run.
2
+ * The HTTP writes the harness makes back into ProhostAI: post the reply, say a
3
+ * long run is still alive, and close the run out.
4
4
  *
5
- * Both authenticate with the paired API key over ``X-API-Key`` and both return
6
- * a result object rather than throwing — the runner has to keep going (and
7
- * still complete the run) whichever one fails.
5
+ * All of them authenticate with the paired API key over ``X-API-Key`` and all
6
+ * of them return a result object rather than throwing — the runner has to keep
7
+ * going (and still complete the run) whichever one fails.
8
8
  *
9
9
  * The paths come from the run event itself (`reply_instructions.path` /
10
10
  * `completion.path`), which is why these take a path rather than building one.
11
11
  */
12
12
  import { USER_AGENT } from '../version.js';
13
- import { MAX_ERROR_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED } from './contract.js';
13
+ import { MAX_ERROR_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
14
14
  const DEFAULT_TIMEOUT_MS = 30_000;
15
- async function post(options, path, body, idempotencyKey) {
15
+ /** Cap on a captured response body. Every one we parse is a small object. */
16
+ const MAX_BODY_CHARS = 2_000;
17
+ async function post(options, path, body, idempotencyKey, captureBody = false) {
16
18
  const fetchImpl = options.fetchImpl ?? fetch;
17
19
  const controller = new AbortController();
18
20
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -30,15 +32,18 @@ async function post(options, path, body, idempotencyKey) {
30
32
  });
31
33
  const ok = response.status >= 200 && response.status < 300;
32
34
  let error;
33
- if (!ok) {
35
+ let text;
36
+ if (!ok || captureBody) {
34
37
  // Surface the server's reason — a scope or tier denial is the most
35
38
  // likely failure here and the host can act on it.
36
- error = await response
39
+ text = await response
37
40
  .text()
38
- .then((t) => t.slice(0, 500))
41
+ .then((t) => t.slice(0, MAX_BODY_CHARS))
39
42
  .catch(() => undefined);
43
+ if (!ok)
44
+ error = text?.slice(0, 500);
40
45
  }
41
- return { ok, status: response.status, error };
46
+ return { ok, status: response.status, error, body: captureBody ? text : undefined };
42
47
  }
43
48
  catch (err) {
44
49
  return {
@@ -69,6 +74,41 @@ export async function postReply(options, path, bodyKey, message, runId, extra) {
69
74
  // thing in this request that must survive.
70
75
  return post(options, path, { ...extra, [bodyKey]: message }, `prohost-cli-run-${runId}`);
71
76
  }
77
+ function booleanField(body, key) {
78
+ if (!body)
79
+ return false;
80
+ try {
81
+ const parsed = JSON.parse(body);
82
+ if (!parsed || typeof parsed !== 'object')
83
+ return false;
84
+ return parsed[key] === true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ /**
91
+ * Tell the server a run is still being worked on, and ask whether it should be.
92
+ *
93
+ * Sent on a cadence for every run this process has accepted and not yet
94
+ * reported — the one being executed *and* the ones queued behind it, because a
95
+ * run queued behind a four-hour job is exactly as reapable as the job itself.
96
+ *
97
+ * ``progress`` is a courtesy for whoever is watching the run in the UI, never a
98
+ * requirement: a queued run has none, and the brains that don't stream their
99
+ * work send none either.
100
+ */
101
+ export async function heartbeatRun(options, path, progress) {
102
+ const trimmed = progress?.trim();
103
+ const result = await post(options, path, trimmed ? { progress: trimmed.slice(0, MAX_PROGRESS_CHARS) } : {}, undefined, true);
104
+ if (result.ok)
105
+ return { kind: 'alive', cancelRequested: booleanField(result.body, 'cancel_requested') };
106
+ if (result.status === 404)
107
+ return { kind: 'unknown' };
108
+ if (result.status === 409)
109
+ return { kind: 'resolved' };
110
+ return { kind: 'error', status: result.status, detail: result.error };
111
+ }
72
112
  /**
73
113
  * Report a run's terminal state. Called for every run — including one whose
74
114
  * command crashed — so the server never waits on a run this harness abandoned.
@@ -106,7 +106,7 @@ export type ClaudeOutput = {
106
106
  denials: string[];
107
107
  };
108
108
  /**
109
- * Read the reply and the session id out of `--output-format json` stdout.
109
+ * Read the reply and the session id out of a finished run's stdout.
110
110
  *
111
111
  * Unparseable output is a **failure**, not a fallback to posting the raw text.
112
112
  * We asked for JSON; stdout that isn't JSON means something went wrong that we
@@ -114,8 +114,60 @@ export type ClaudeOutput = {
114
114
  * one thing that must not happen is that being posted verbatim into a customer's
115
115
  * conversation. Reporting the run failed is recoverable; sending a stack trace
116
116
  * to a guest is not.
117
+ *
118
+ * Still whole-stdout rather than streaming, and still used: it is what
119
+ * {@link readClaudeStream} falls back to when a run produced no `result` event
120
+ * at all, and it reads a plain `--output-format json` envelope exactly as it
121
+ * always did.
117
122
  */
118
123
  export declare function parseClaudeOutput(stdout: string): ClaudeOutput;
124
+ /** Cap on the activity line handed to the heartbeat. */
125
+ export declare const MAX_ACTIVITY_CHARS = 120;
126
+ /** Cap on the partial answer salvaged from a run that was killed. */
127
+ export declare const MAX_SALVAGE_CHARS = 500;
128
+ /**
129
+ * A run's stdout, read as it is produced.
130
+ *
131
+ * Everything here is an enhancement over reading stdout at exit, and every one
132
+ * of them fails soft: a stream we cannot parse costs progress reporting and
133
+ * salvage, never the run.
134
+ */
135
+ export interface ClaudeStream {
136
+ /** Feed one stdout chunk. Never throws. */
137
+ push(chunk: string): void;
138
+ /**
139
+ * The session id, as soon as the run announced it.
140
+ *
141
+ * This is the whole reason the session id is read here rather than out of the
142
+ * final envelope: a run killed at hour two never produces a final envelope,
143
+ * and without the id its two hours of context are unreachable — the next turn
144
+ * on that surface starts from nothing.
145
+ */
146
+ sessionId(): string | undefined;
147
+ /**
148
+ * What the agent is doing right now, in one short line — the last tool it
149
+ * called, or the opening of the text it is writing.
150
+ */
151
+ activity(): string | undefined;
152
+ /** The tail of the last thing the agent wrote, for a run that was killed. */
153
+ salvage(): string | undefined;
154
+ /**
155
+ * The turn's outcome. ``stdout`` is the complete captured output, used only
156
+ * when the stream held no `result` event — a crashed binary, a login prompt,
157
+ * or a plain `--output-format json` envelope from a command we did not shape.
158
+ */
159
+ finish(stdout: string): ClaudeOutput;
160
+ }
161
+ /**
162
+ * Parse `--output-format stream-json` incrementally.
163
+ *
164
+ * Line-delimited JSON, one event per line, so the parser is a buffer and a
165
+ * split. Anything it doesn't recognise is skipped in silence: the event
166
+ * vocabulary belongs to Claude Code and grows without asking us, and a reader
167
+ * that failed on an unknown `type` would turn a Claude Code release into a
168
+ * fleet of broken laptops.
169
+ */
170
+ export declare function readClaudeStream(): ClaudeStream;
119
171
  /**
120
172
  * Does this look like the resume having been rejected, rather than the agent
121
173
  * having failed at its actual work?
@@ -109,7 +109,17 @@ export function claudeCommand(base, flags = {}) {
109
109
  .split(/\s+/)
110
110
  .filter((word) => word !== '--dangerously-skip-permissions')
111
111
  .join(' ');
112
- const parts = [command, '--output-format', 'json'];
112
+ // `stream-json` rather than `json`, and the two flags travel together:
113
+ // Claude Code refuses `--output-format stream-json` in print mode without
114
+ // `--verbose`. What it buys is everything that cannot wait for the process to
115
+ // exit — the session id lands in the ledger on the first event rather than
116
+ // after the reply is posted, the heartbeat can say what the agent is doing,
117
+ // and a run that gets killed at hour two still has a partial answer to
118
+ // report. The final `result` event carries the same fields the single JSON
119
+ // envelope did, so the reply itself is unchanged.
120
+ const parts = [command, '--output-format', 'stream-json'];
121
+ if (!/(^|\s)--verbose(\s|$)/.test(command))
122
+ parts.push('--verbose');
113
123
  if (skipPermissions)
114
124
  parts.push('--dangerously-skip-permissions');
115
125
  if (flags.sessionId)
@@ -198,27 +208,13 @@ function text(value) {
198
208
  return typeof value === 'string' && value.length > 0 ? value : undefined;
199
209
  }
200
210
  /**
201
- * Read the reply and the session id out of `--output-format json` stdout.
211
+ * Turn a `result` envelope into the harness's view of the turn.
202
212
  *
203
- * Unparseable output is a **failure**, not a fallback to posting the raw text.
204
- * We asked for JSON; stdout that isn't JSON means something went wrong that we
205
- * do not understand — a crash banner, a login prompt, an npm warning — and the
206
- * one thing that must not happen is that being posted verbatim into a customer's
207
- * conversation. Reporting the run failed is recoverable; sending a stack trace
208
- * to a guest is not.
213
+ * Shared by the whole-stdout parser and the streaming reader, because the
214
+ * envelope is identical either way: `--output-format stream-json` ends with the
215
+ * same object `--output-format json` used to print on its own.
209
216
  */
210
- export function parseClaudeOutput(stdout) {
211
- const parsed = resultEnvelope(stdout);
212
- if (!parsed) {
213
- const sample = stdout.trim().slice(0, 200);
214
- return {
215
- denials: [],
216
- ok: false,
217
- error: sample
218
- ? `claude did not return JSON (asked for --output-format json). First 200 chars: ${sample}`
219
- : 'claude returned no output',
220
- };
221
- }
217
+ function outputFromEnvelope(parsed) {
222
218
  const sessionId = text(parsed.session_id);
223
219
  const result = text(parsed.result);
224
220
  const denials = denialNames(parsed.permission_denials);
@@ -239,6 +235,155 @@ export function parseClaudeOutput(stdout) {
239
235
  }
240
236
  return { ok: true, text: result, sessionId, denials };
241
237
  }
238
+ /**
239
+ * Read the reply and the session id out of a finished run's stdout.
240
+ *
241
+ * Unparseable output is a **failure**, not a fallback to posting the raw text.
242
+ * We asked for JSON; stdout that isn't JSON means something went wrong that we
243
+ * do not understand — a crash banner, a login prompt, an npm warning — and the
244
+ * one thing that must not happen is that being posted verbatim into a customer's
245
+ * conversation. Reporting the run failed is recoverable; sending a stack trace
246
+ * to a guest is not.
247
+ *
248
+ * Still whole-stdout rather than streaming, and still used: it is what
249
+ * {@link readClaudeStream} falls back to when a run produced no `result` event
250
+ * at all, and it reads a plain `--output-format json` envelope exactly as it
251
+ * always did.
252
+ */
253
+ export function parseClaudeOutput(stdout) {
254
+ const parsed = resultEnvelope(stdout);
255
+ if (!parsed) {
256
+ const sample = stdout.trim().slice(0, 200);
257
+ return {
258
+ denials: [],
259
+ ok: false,
260
+ error: sample
261
+ ? `claude did not return JSON (asked for --output-format stream-json). First 200 chars: ${sample}`
262
+ : 'claude returned no output',
263
+ };
264
+ }
265
+ return outputFromEnvelope(parsed);
266
+ }
267
+ /** Cap on the activity line handed to the heartbeat. */
268
+ export const MAX_ACTIVITY_CHARS = 120;
269
+ /** Cap on the partial answer salvaged from a run that was killed. */
270
+ export const MAX_SALVAGE_CHARS = 500;
271
+ /**
272
+ * Cap on a single buffered stdout line.
273
+ *
274
+ * A stream line is one JSON event, and a pathological one that never ends
275
+ * (binary output from a wrapper script, say) would otherwise accumulate
276
+ * unbounded between newlines. Dropped rather than truncated: half a JSON object
277
+ * parses as nothing useful anyway.
278
+ */
279
+ const MAX_LINE_CHARS = 4 * 1024 * 1024;
280
+ /** Text of an `assistant` event's message, and the last tool it invoked. */
281
+ function assistantContent(event) {
282
+ const message = event.message;
283
+ if (!message || typeof message !== 'object')
284
+ return {};
285
+ const content = message.content;
286
+ if (!Array.isArray(content))
287
+ return {};
288
+ let latestText;
289
+ let latestTool;
290
+ for (const block of content) {
291
+ if (!block || typeof block !== 'object')
292
+ continue;
293
+ const entry = block;
294
+ if (entry.type === 'text' && typeof entry.text === 'string' && entry.text.trim()) {
295
+ latestText = entry.text;
296
+ }
297
+ else if (entry.type === 'tool_use' && typeof entry.name === 'string' && entry.name) {
298
+ latestTool = entry.name;
299
+ }
300
+ }
301
+ return { text: latestText, tool: latestTool };
302
+ }
303
+ /**
304
+ * Parse `--output-format stream-json` incrementally.
305
+ *
306
+ * Line-delimited JSON, one event per line, so the parser is a buffer and a
307
+ * split. Anything it doesn't recognise is skipped in silence: the event
308
+ * vocabulary belongs to Claude Code and grows without asking us, and a reader
309
+ * that failed on an unknown `type` would turn a Claude Code release into a
310
+ * fleet of broken laptops.
311
+ */
312
+ export function readClaudeStream() {
313
+ let buffer = '';
314
+ let session;
315
+ let lastActivity;
316
+ let lastText;
317
+ let result;
318
+ const handle = (line) => {
319
+ const event = asObject(line);
320
+ if (!event)
321
+ return;
322
+ // Present on the `system`/`init` event, and repeated on most others. Taken
323
+ // from whichever arrives first — they all name the same session.
324
+ if (!session)
325
+ session = text(event.session_id);
326
+ if (event.type === 'result') {
327
+ result = event;
328
+ return;
329
+ }
330
+ if (event.type !== 'assistant')
331
+ return;
332
+ const { text: written, tool } = assistantContent(event);
333
+ if (written)
334
+ lastText = written;
335
+ // Whichever happened last within this message: a tool call after some text
336
+ // means the agent has moved on from writing to doing.
337
+ if (tool)
338
+ lastActivity = `using ${tool}`;
339
+ else if (written)
340
+ lastActivity = written.replace(/\s+/g, ' ').trim().slice(0, MAX_ACTIVITY_CHARS);
341
+ };
342
+ return {
343
+ push(chunk) {
344
+ buffer += chunk;
345
+ if (!buffer.includes('\n')) {
346
+ if (buffer.length > MAX_LINE_CHARS)
347
+ buffer = '';
348
+ return;
349
+ }
350
+ const lines = buffer.split('\n');
351
+ buffer = lines.pop() ?? '';
352
+ if (buffer.length > MAX_LINE_CHARS)
353
+ buffer = '';
354
+ for (const line of lines) {
355
+ const trimmed = line.trim();
356
+ if (trimmed)
357
+ handle(trimmed);
358
+ }
359
+ },
360
+ sessionId: () => session,
361
+ activity: () => lastActivity,
362
+ salvage: () => {
363
+ const trimmed = lastText?.trim();
364
+ if (!trimmed)
365
+ return undefined;
366
+ return trimmed.length > MAX_SALVAGE_CHARS ? trimmed.slice(-MAX_SALVAGE_CHARS) : trimmed;
367
+ },
368
+ finish(stdout) {
369
+ // A trailing event with no newline after it is still an event — a binary
370
+ // that exits without flushing a final `\n` must not lose its result.
371
+ const tail = buffer.trim();
372
+ if (tail) {
373
+ handle(tail);
374
+ buffer = '';
375
+ }
376
+ if (result)
377
+ return outputFromEnvelope(result);
378
+ // No `result` event: either the command isn't emitting the stream we
379
+ // asked for, or it died before finishing. Either way the whole-output
380
+ // parser gives the better error message, and it still understands a
381
+ // single `json` envelope.
382
+ const parsed = parseClaudeOutput(stdout);
383
+ return session && !parsed.sessionId ? { ...parsed, sessionId: session } : parsed;
384
+ },
385
+ };
386
+ }
242
387
  /**
243
388
  * Does this look like the resume having been rejected, rather than the agent
244
389
  * having failed at its actual work?
@@ -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 {};