@prohost/cli 0.2.3 → 0.3.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,60 @@ 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.3.0
8
+
9
+ Long runs. Every change here comes out of one production failure: a paired
10
+ brain was given a real coding task, worked on it for five minutes, and was
11
+ killed mid-edit by a timeout that had been chosen when "a run" meant "answer a
12
+ mention". The person waiting got `timed out after 300000ms` and nothing else —
13
+ no partial answer, no way to resume the session it had built up, and no way to
14
+ tell from the thread that the agent had been doing anything at all.
15
+
16
+ - **The default ceiling is two hours, and `--timeout 0` removes it.** A coding
17
+ agent's unit of work is not a chat reply. The timeout is now a backstop
18
+ against a genuinely wedged process rather than a shape imposed on the work,
19
+ and `0` — previously rejected — means the run ends when the agent ends it,
20
+ when somebody cancels it, or when the server stops renewing its lease. The
21
+ timeout message names the way out (`raise the ceiling with --timeout
22
+ <seconds>`) because that string does not stay in `agent.log`: the server
23
+ echoes a failed run's error into the thread, so the person waiting reads it.
24
+ - **Heartbeats keep long runs alive, and are how "stop" reaches a laptop.**
25
+ Every run this process has accepted is reported once a minute — the executing
26
+ one *and* every run queued behind it, since a run waiting behind a four-hour
27
+ job is `running` server-side for four hours and is reaped on exactly the same
28
+ clock. The response carries `cancel_requested`, which is the only channel by
29
+ which a stop pressed in ProhostAI can reach a machine that accepts no inbound
30
+ connections. Cancelling signals the command's whole process group the same way
31
+ a timeout does (SIGTERM, then SIGKILL five seconds later), reports the run
32
+ with the marker the server words as a cancellation, and posts no reply. A run
33
+ the server has already closed out (409) is abandoned outright: the child is
34
+ killed and nothing is posted, because there is nothing left to post to.
35
+ - **Against an older ProhostAI server, nothing changes.** The heartbeat endpoint
36
+ answers `404` there; the CLI says so once per run, stops asking, and behaves
37
+ exactly as 0.2.3 did. No `Depends-on`, no coordinated upgrade.
38
+ - **Claude Code runs are read as they happen (`--output-format stream-json`).**
39
+ Three things fall out of that, none of which could work when stdout was read
40
+ at exit. The session id is recorded the moment the run announces it, so a run
41
+ killed at hour two can still be resumed — where before, the id arrived with
42
+ the final envelope a killed run never produces, and two hours of context went
43
+ with it. The heartbeat carries what the agent is doing (the tool it just
44
+ called, or the opening of what it is writing). And a killed or cancelled run
45
+ reports the tail of the answer it had been writing rather than only the fact
46
+ that it stopped. A rejected reply still un-records the session, so the rule
47
+ that used to be expressed by recording late is kept. Codex and plain `--exec`
48
+ commands are untouched; heartbeats and cancellation apply to them all the
49
+ same, they just have no progress to report.
50
+ - **The prompt states the actual budget.** It used to say "this run has a hard
51
+ time limit" — the same sentence whether the limit was five minutes or four
52
+ hours, which is how an agent with two hours triages work it had time to
53
+ finish. It now says roughly how long, or that there is no fixed limit and a
54
+ long silence gets reported as a stalled run.
55
+ - **A dropped completion is retried for about ten minutes**, up from thirteen
56
+ seconds. The old schedule was sized for a five-minute run; losing the
57
+ completion of a four-hour one is worth waiting out a wifi drop for. The run
58
+ keeps being heartbeated throughout, so the row it is trying to close is not
59
+ reaped underneath the retries.
60
+
7
61
  ## 0.2.3
8
62
 
9
63
  Three things, all of them about a run knowing what is actually true about
package/README.md CHANGED
@@ -400,7 +400,7 @@ 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
+ | `--timeout` | `7200` | Seconds one run may take before the command is killed. `0` removes the local cap entirely — see "Long runs". |
404
404
  | `--dry-run` | off | Print the reply that would be posted; write nothing. |
405
405
  | `--workdir` | `~/.prohost/workspace` | Working directory for the command. See "Where your agent runs". |
406
406
  | `--cwd` | — | Deprecated alias for `--workdir`. |
@@ -409,6 +409,28 @@ same judgement.
409
409
  | `--allow-unverified` | off | Run events whose signature can't be verified. See below. |
410
410
  | `--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
411
 
412
+ ### Long runs
413
+
414
+ A paired brain is often asked to do work, not just answer — write the change,
415
+ run the tests, come back. That takes minutes or hours, and the harness is built
416
+ for it:
417
+
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.
421
+ - **Heartbeats keep it alive.** Every run this CLI has accepted — the one
422
+ executing *and* any queued behind it — is reported to ProhostAI once a minute,
423
+ which is what stops the server reaping a run that is still being worked on.
424
+ Against an older ProhostAI server the heartbeat endpoint answers `404`; the
425
+ CLI says so once per run and otherwise behaves exactly as it did before.
426
+ - **Cancel works.** Stopping a run in ProhostAI is answered on the next
427
+ heartbeat: the command's whole process group gets `SIGTERM`, then `SIGKILL`
428
+ five seconds later, and the run is reported as cancelled. No reply is posted.
429
+ - **A killed run is not silent.** With Claude Code the CLI reads the run's
430
+ output as it is produced, so the run's session id is saved immediately (the
431
+ next turn on that surface can still resume it) and a run that is killed or
432
+ cancelled reports the tail of whatever the agent had written.
433
+
412
434
  ### Keeping it running
413
435
 
414
436
  `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?
@@ -35,14 +35,23 @@ const INVALID_TIMEOUT = Symbol('invalid-timeout');
35
35
  *
36
36
  * Returns ``undefined`` when the flag was absent (the harness applies its own
37
37
  * default) and {@link INVALID_TIMEOUT} after printing the error.
38
+ *
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.
38
43
  */
39
44
  function parseTimeoutSeconds(flags) {
40
45
  const raw = flagString(flags, 'timeout');
41
46
  if (raw === undefined)
42
47
  return undefined;
43
48
  const seconds = Number(raw);
44
- if (!Number.isFinite(seconds) || seconds <= 0) {
45
- process.stderr.write('--timeout must be a positive number of seconds.\n');
49
+ if (!Number.isFinite(seconds) || seconds < 0) {
50
+ // Keeps the phrase "--timeout must be a positive number" verbatim: the
51
+ // desktop app's supervisor matches on it to tell a misconfigured child from
52
+ // a recoverable crash (`desktop/src/main/agent/supervisor-policy.ts`), and
53
+ // 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');
46
55
  return INVALID_TIMEOUT;
47
56
  }
48
57
  return seconds;
@@ -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,50 @@ 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.
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.
74
+ */
75
+ export declare function heartbeatPathFor(run: AgentRunRequest): string;
37
76
  /**
38
77
  * A file or image hanging off a message.
39
78
  *