@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 +54 -0
- package/README.md +23 -1
- package/dist/agent/api.d.ts +53 -5
- package/dist/agent/api.js +51 -11
- package/dist/agent/claude.d.ts +53 -1
- package/dist/agent/claude.js +165 -20
- package/dist/agent/command.js +11 -2
- package/dist/agent/contract.d.ts +39 -0
- package/dist/agent/contract.js +47 -0
- package/dist/agent/exec.d.ts +42 -4
- package/dist/agent/exec.js +104 -18
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +50 -2
- package/dist/agent/run.d.ts +25 -3
- package/dist/agent/run.js +312 -39
- package/dist/index.js +3 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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` | `
|
|
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
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
* a result object rather than throwing — the runner has to keep
|
|
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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
* a result object rather than throwing — the runner has to keep
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
text = await response
|
|
37
40
|
.text()
|
|
38
|
-
.then((t) => t.slice(0,
|
|
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.
|
package/dist/agent/claude.d.ts
CHANGED
|
@@ -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
|
|
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?
|
package/dist/agent/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
211
|
+
* Turn a `result` envelope into the harness's view of the turn.
|
|
202
212
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
-
|
|
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?
|
package/dist/agent/command.js
CHANGED
|
@@ -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
|
|
45
|
-
|
|
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;
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -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
|
*
|