@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 +106 -0
- package/README.md +33 -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.d.ts +24 -0
- package/dist/agent/command.js +89 -13
- package/dist/agent/contract.d.ts +52 -0
- package/dist/agent/contract.js +58 -0
- package/dist/agent/daemon.d.ts +2 -0
- package/dist/agent/daemon.js +6 -0
- package/dist/agent/exec.d.ts +77 -4
- package/dist/agent/exec.js +201 -18
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/prompt.d.ts +22 -0
- package/dist/agent/prompt.js +87 -2
- package/dist/agent/run.d.ts +51 -3
- package/dist/agent/run.js +392 -39
- package/dist/index.js +9 -3
- 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,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` | `
|
|
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
|
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.d.ts
CHANGED
|
@@ -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 {};
|