@prohost/cli 0.3.0 → 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 +52 -0
- package/README.md +14 -4
- package/dist/agent/command.d.ts +24 -0
- package/dist/agent/command.js +85 -18
- package/dist/agent/contract.d.ts +20 -7
- package/dist/agent/contract.js +18 -7
- package/dist/agent/daemon.d.ts +2 -0
- package/dist/agent/daemon.js +6 -0
- package/dist/agent/exec.d.ts +35 -0
- package/dist/agent/exec.js +100 -3
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +56 -19
- package/dist/agent/run.d.ts +37 -11
- package/dist/agent/run.js +100 -20
- package/dist/index.js +9 -5
- 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,58 @@ 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
|
+
|
|
7
59
|
## 0.3.0
|
|
8
60
|
|
|
9
61
|
Long runs. Every change here comes out of one production failure: a paired
|
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`. |
|
|
@@ -415,9 +416,18 @@ A paired brain is often asked to do work, not just answer — write the change,
|
|
|
415
416
|
run the tests, come back. That takes minutes or hours, and the harness is built
|
|
416
417
|
for it:
|
|
417
418
|
|
|
418
|
-
- **
|
|
419
|
-
|
|
420
|
-
the
|
|
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.
|
|
421
431
|
- **Heartbeats keep it alive.** Every run this CLI has accepted — the one
|
|
422
432
|
executing *and* any queued behind it — is reported to ProhostAI once a minute,
|
|
423
433
|
which is what stops the server reaping a run that is still being worked on.
|
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 {};
|
package/dist/agent/command.js
CHANGED
|
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
12
|
import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon, launchdLabel, uninstallLaunchdDaemon, } from './daemon.js';
|
|
13
13
|
import { PairError, runPair } from './pair.js';
|
|
14
14
|
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
|
|
15
|
-
import { DEFAULT_EXEC_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
15
|
+
import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
16
16
|
function flagString(flags, name) {
|
|
17
17
|
const value = flags[name];
|
|
18
18
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -28,33 +28,92 @@ function flagString(flags, name) {
|
|
|
28
28
|
function requestedWorkdir(flags) {
|
|
29
29
|
return flagString(flags, 'workdir') ?? flagString(flags, 'cwd');
|
|
30
30
|
}
|
|
31
|
-
/** Returned by {@link
|
|
32
|
-
const
|
|
31
|
+
/** Returned by {@link parseSeconds} for a value that isn't a duration. */
|
|
32
|
+
export const INVALID_DURATION = Symbol('invalid-duration');
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* Longest delay a timer can hold, in milliseconds — 2^31-1, just under 24.9
|
|
35
|
+
* days.
|
|
35
36
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* The bound is `setTimeout`'s, not ours: a delay above this overflows the timer
|
|
38
|
+
* and Node **fires it after 1 ms instead**. So `--idle-timeout 2592000` ("give
|
|
39
|
+
* it thirty days") would not merely be ignored, it would kill the very first
|
|
40
|
+
* run on the spot and report it as stalled — the exact opposite of what was
|
|
41
|
+
* asked for, with nothing to explain it. Rejected at the flag rather than
|
|
42
|
+
* clamped, because anyone typing a number this large means "no limit", and both
|
|
43
|
+
* flags already spell that `0`.
|
|
38
44
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* on a laptop that went to sleep was a row that span forever.
|
|
45
|
+
* Checked against the *converted* value rather than against seconds, because
|
|
46
|
+
* the flags take fractions: `--timeout 2147483.9` is under any whole-second
|
|
47
|
+
* ceiling and still overflows once multiplied out.
|
|
43
48
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
50
|
+
/**
|
|
51
|
+
* Read a duration flag's value. Pure — exported so it can be tested without a
|
|
52
|
+
* process to write errors to.
|
|
53
|
+
*
|
|
54
|
+
* Returns ``undefined`` only when the flag was genuinely absent (the harness
|
|
55
|
+
* then applies its own default) and {@link INVALID_DURATION} for anything that
|
|
56
|
+
* is not a non-negative, finite number of seconds.
|
|
57
|
+
*
|
|
58
|
+
* ``0`` is legal for both flags that use this, and means "no limit of this
|
|
59
|
+
* kind". For `--timeout` it was rejected until long runs existed, because
|
|
60
|
+
* before heartbeats an uncapped run on a laptop that went to sleep was a row
|
|
61
|
+
* that span forever.
|
|
62
|
+
*
|
|
63
|
+
* Takes the raw flag value rather than a string, because "the operator typed
|
|
64
|
+
* this flag and got it wrong" and "the operator did not type this flag" have
|
|
65
|
+
* to be told apart. `--timeout` with nothing after it, and `--timeout -1` —
|
|
66
|
+
* which the argument parser reads as two flags, since a value cannot begin
|
|
67
|
+
* with `-` — both arrive here as `true`. Treated as absent, they would apply
|
|
68
|
+
* the default silently and leave someone believing they had set a limit they
|
|
69
|
+
* had not.
|
|
70
|
+
*/
|
|
71
|
+
export function parseSeconds(raw) {
|
|
46
72
|
if (raw === undefined)
|
|
47
73
|
return undefined;
|
|
74
|
+
// An empty string is the `--timeout ''` case, and `Number('')` is 0 — which
|
|
75
|
+
// would quietly read as "no limit" rather than as the typo it is.
|
|
76
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
77
|
+
return INVALID_DURATION;
|
|
48
78
|
const seconds = Number(raw);
|
|
49
|
-
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
79
|
+
if (!Number.isFinite(seconds) || seconds < 0 || seconds * 1000 > MAX_TIMER_DELAY_MS) {
|
|
80
|
+
return INVALID_DURATION;
|
|
81
|
+
}
|
|
82
|
+
return seconds;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read `--timeout`, complaining once for both commands that take it.
|
|
86
|
+
*
|
|
87
|
+
* The wall-clock ceiling is opt-in now (`DEFAULT_EXEC_TIMEOUT_MS` is 0), so an
|
|
88
|
+
* absent flag means "no ceiling" rather than "two hours".
|
|
89
|
+
*/
|
|
90
|
+
function parseTimeoutSeconds(flags) {
|
|
91
|
+
const parsed = parseSeconds(flags.timeout);
|
|
92
|
+
if (parsed === INVALID_DURATION) {
|
|
50
93
|
// Keeps the phrase "--timeout must be a positive number" verbatim: the
|
|
51
94
|
// desktop app's supervisor matches on it to tell a misconfigured child from
|
|
52
95
|
// a recoverable crash (`desktop/src/main/agent/supervisor-policy.ts`), and
|
|
53
96
|
// it reads this CLI's stderr rather than any structured output.
|
|
54
|
-
process.stderr.write('--timeout must be a positive number of seconds
|
|
55
|
-
|
|
97
|
+
process.stderr.write('--timeout must be a positive number of seconds under 2147484 (about 24 days), ' +
|
|
98
|
+
'or 0 for no local limit.\n');
|
|
56
99
|
}
|
|
57
|
-
return
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Read `--idle-timeout` — how long a run may produce nothing before it is
|
|
104
|
+
* treated as stalled.
|
|
105
|
+
*
|
|
106
|
+
* Validated in both commands for the same reason `--timeout` is: baked into a
|
|
107
|
+
* plist, a bad value makes every launch exit on this check and KeepAlive
|
|
108
|
+
* restart it forever.
|
|
109
|
+
*/
|
|
110
|
+
function parseIdleTimeoutSeconds(flags) {
|
|
111
|
+
const parsed = parseSeconds(flags['idle-timeout']);
|
|
112
|
+
if (parsed === INVALID_DURATION) {
|
|
113
|
+
process.stderr.write('--idle-timeout must be a positive number of seconds under 2147484 (about 24 days), ' +
|
|
114
|
+
'or 0 to disable the stall watchdog.\n');
|
|
115
|
+
}
|
|
116
|
+
return parsed;
|
|
58
117
|
}
|
|
59
118
|
/**
|
|
60
119
|
* Environment the daemon must be given explicitly.
|
|
@@ -129,7 +188,10 @@ async function installDaemonCommand(flags) {
|
|
|
129
188
|
// plist makes every launch exit on that same validation and KeepAlive restart
|
|
130
189
|
// it forever.
|
|
131
190
|
const timeoutSeconds = parseTimeoutSeconds(flags);
|
|
132
|
-
if (timeoutSeconds ===
|
|
191
|
+
if (timeoutSeconds === INVALID_DURATION)
|
|
192
|
+
return 1;
|
|
193
|
+
const idleTimeoutSeconds = parseIdleTimeoutSeconds(flags);
|
|
194
|
+
if (idleTimeoutSeconds === INVALID_DURATION)
|
|
133
195
|
return 1;
|
|
134
196
|
// Absolute, and created now. A supervisor does not inherit the installing
|
|
135
197
|
// shell's current directory, so a relative `--workdir .` copied verbatim into
|
|
@@ -151,6 +213,7 @@ async function installDaemonCommand(flags) {
|
|
|
151
213
|
exec,
|
|
152
214
|
workdir,
|
|
153
215
|
timeoutSeconds,
|
|
216
|
+
idleTimeoutSeconds,
|
|
154
217
|
machine: flagString(flags, 'machine'),
|
|
155
218
|
url: flagString(flags, 'url'),
|
|
156
219
|
allowUnverified: flags['allow-unverified'] === true,
|
|
@@ -224,7 +287,10 @@ async function runCommand(flags) {
|
|
|
224
287
|
if (!credentials)
|
|
225
288
|
return 1;
|
|
226
289
|
const parsedTimeout = parseTimeoutSeconds(flags);
|
|
227
|
-
if (parsedTimeout ===
|
|
290
|
+
if (parsedTimeout === INVALID_DURATION)
|
|
291
|
+
return 1;
|
|
292
|
+
const parsedIdleTimeout = parseIdleTimeoutSeconds(flags);
|
|
293
|
+
if (parsedIdleTimeout === INVALID_DURATION)
|
|
228
294
|
return 1;
|
|
229
295
|
// First signal stops accepting work and lets the in-flight run finish
|
|
230
296
|
// reporting itself (so the server doesn't wait on a run we abandoned); a
|
|
@@ -248,6 +314,7 @@ async function runCommand(flags) {
|
|
|
248
314
|
machine: flagString(flags, 'machine'),
|
|
249
315
|
cwd: requestedWorkdir(flags),
|
|
250
316
|
timeoutMs: parsedTimeout === undefined ? DEFAULT_EXEC_TIMEOUT_MS : parsedTimeout * 1000,
|
|
317
|
+
idleTimeoutMs: parsedIdleTimeout === undefined ? DEFAULT_IDLE_TIMEOUT_MS : parsedIdleTimeout * 1000,
|
|
251
318
|
dryRun: flags['dry-run'] === true,
|
|
252
319
|
allowUnverified: flags['allow-unverified'] === true,
|
|
253
320
|
// Two spellings for the same opt-out: `--safe-tools` says what you get,
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -63,14 +63,19 @@ export declare function agentRunCompletePath(runId: string): string;
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function agentRunHeartbeatPath(runId: string): string;
|
|
65
65
|
/**
|
|
66
|
-
* Where this run's heartbeats go.
|
|
66
|
+
* Where this run's heartbeats go, in descending order of how much we know.
|
|
67
67
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
68
|
+
* 1. The `heartbeat` block, when the payload carries one. A path the server
|
|
69
|
+
* stated is not a guess, so it is used verbatim.
|
|
70
|
+
* 2. Otherwise, derived from the server-supplied completion path: the two
|
|
71
|
+
* calls are the same resource, so a server that moves its agent-run routes
|
|
72
|
+
* moves both together and a CLI already on somebody's laptop follows
|
|
73
|
+
* without a release.
|
|
74
|
+
* 3. Otherwise the documented path, for a completion path shaped in some way
|
|
75
|
+
* we don't recognise.
|
|
76
|
+
*
|
|
77
|
+
* Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
|
|
78
|
+
* the block is additive, and every server that predates it must keep working.
|
|
74
79
|
*/
|
|
75
80
|
export declare function heartbeatPathFor(run: AgentRunRequest): string;
|
|
76
81
|
/**
|
|
@@ -216,6 +221,14 @@ export interface AgentRunRequest {
|
|
|
216
221
|
reply_body_extra?: Record<string, unknown>;
|
|
217
222
|
/** Server-supplied path to complete the run at. */
|
|
218
223
|
completion_path: string;
|
|
224
|
+
/**
|
|
225
|
+
* Server-supplied path to heartbeat the run at.
|
|
226
|
+
*
|
|
227
|
+
* Absent on every server built before the `heartbeat` block was added
|
|
228
|
+
* alongside `completion`, which is most of them — see {@link heartbeatPathFor}
|
|
229
|
+
* for what happens then.
|
|
230
|
+
*/
|
|
231
|
+
heartbeat_path?: string;
|
|
219
232
|
/**
|
|
220
233
|
* Earlier messages on this surface, oldest first. Absent on a server that
|
|
221
234
|
* predates the field — which is the case the harness shipped with, so an
|
package/dist/agent/contract.js
CHANGED
|
@@ -69,16 +69,23 @@ export function agentRunHeartbeatPath(runId) {
|
|
|
69
69
|
return `/v1/agent-runs/${encodeURIComponent(runId)}/heartbeat`;
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Where this run's heartbeats go.
|
|
72
|
+
* Where this run's heartbeats go, in descending order of how much we know.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
74
|
+
* 1. The `heartbeat` block, when the payload carries one. A path the server
|
|
75
|
+
* stated is not a guess, so it is used verbatim.
|
|
76
|
+
* 2. Otherwise, derived from the server-supplied completion path: the two
|
|
77
|
+
* calls are the same resource, so a server that moves its agent-run routes
|
|
78
|
+
* moves both together and a CLI already on somebody's laptop follows
|
|
79
|
+
* without a release.
|
|
80
|
+
* 3. Otherwise the documented path, for a completion path shaped in some way
|
|
81
|
+
* we don't recognise.
|
|
82
|
+
*
|
|
83
|
+
* Steps 2 and 3 are exactly what shipped in 0.3.0 and are not going anywhere:
|
|
84
|
+
* the block is additive, and every server that predates it must keep working.
|
|
80
85
|
*/
|
|
81
86
|
export function heartbeatPathFor(run) {
|
|
87
|
+
if (run.heartbeat_path)
|
|
88
|
+
return run.heartbeat_path;
|
|
82
89
|
const completion = run.completion_path;
|
|
83
90
|
const suffix = '/complete';
|
|
84
91
|
return completion.endsWith(suffix)
|
|
@@ -255,6 +262,7 @@ export function parseRunRequest(data) {
|
|
|
255
262
|
const trigger = obj(data.trigger);
|
|
256
263
|
const replyInstructions = obj(data.reply_instructions);
|
|
257
264
|
const completion = obj(data.completion);
|
|
265
|
+
const heartbeat = obj(data.heartbeat);
|
|
258
266
|
const context = obj(data.context);
|
|
259
267
|
const declaredSurface = str(replyInstructions.surface);
|
|
260
268
|
const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
|
|
@@ -297,6 +305,9 @@ export function parseRunRequest(data) {
|
|
|
297
305
|
reply_body_key: str(replyInstructions.body_field) ?? legacyBodyKeyFor(surface),
|
|
298
306
|
reply_body_extra: bodyExtra(replyInstructions.body_extra),
|
|
299
307
|
completion_path: str(completion.path) ?? agentRunCompletePath(runId),
|
|
308
|
+
// No fallback of its own: an absent block is not an error, it is an older
|
|
309
|
+
// server, and `heartbeatPathFor` already knows what to do about that.
|
|
310
|
+
heartbeat_path: str(heartbeat.path),
|
|
300
311
|
// Accepted both at the top level and nested under `context`, because the
|
|
301
312
|
// server can move them without a CLI release either way — and a published
|
|
302
313
|
// CLI cannot be redeployed to follow.
|
package/dist/agent/daemon.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface HarnessInvocation {
|
|
|
83
83
|
exec: string;
|
|
84
84
|
workdir: string;
|
|
85
85
|
timeoutSeconds?: number;
|
|
86
|
+
/** Seconds of total silence before a run is treated as stalled. */
|
|
87
|
+
idleTimeoutSeconds?: number;
|
|
86
88
|
machine?: string;
|
|
87
89
|
url?: string;
|
|
88
90
|
allowUnverified?: boolean;
|
package/dist/agent/daemon.js
CHANGED
|
@@ -176,8 +176,14 @@ export function harnessArguments(invocation) {
|
|
|
176
176
|
'--workdir',
|
|
177
177
|
invocation.workdir,
|
|
178
178
|
];
|
|
179
|
+
// Compared against `undefined` rather than tested for truth: `0` is a real
|
|
180
|
+
// setting for both flags — no wall-clock cap, no watchdog — and dropping it
|
|
181
|
+
// would install a daemon quietly running the defaults instead.
|
|
179
182
|
if (invocation.timeoutSeconds !== undefined)
|
|
180
183
|
args.push('--timeout', String(invocation.timeoutSeconds));
|
|
184
|
+
if (invocation.idleTimeoutSeconds !== undefined) {
|
|
185
|
+
args.push('--idle-timeout', String(invocation.idleTimeoutSeconds));
|
|
186
|
+
}
|
|
181
187
|
if (invocation.machine)
|
|
182
188
|
args.push('--machine', invocation.machine);
|
|
183
189
|
if (invocation.url)
|
package/dist/agent/exec.d.ts
CHANGED
|
@@ -26,6 +26,19 @@ export interface ExecOptions {
|
|
|
26
26
|
* genuinely takes hours and a local clock is the wrong thing to bound it by.
|
|
27
27
|
*/
|
|
28
28
|
timeoutMs: number;
|
|
29
|
+
/**
|
|
30
|
+
* Kill the run when the child produces **nothing** for this long.
|
|
31
|
+
*
|
|
32
|
+
* The question a wall clock cannot answer: is this run working, or wedged?
|
|
33
|
+
* Bytes on stdout or stderr are the one signal every brain emits — stream
|
|
34
|
+
* events, a progress line, a log — so a window of total silence is the
|
|
35
|
+
* closest thing to evidence that a child has stopped making progress rather
|
|
36
|
+
* than merely taking a while. Every chunk on either stream resets it.
|
|
37
|
+
*
|
|
38
|
+
* ``0`` or unset disables it, at which point only {@link timeoutMs}, the
|
|
39
|
+
* {@link cancelSignal} and the server's lease can end the run.
|
|
40
|
+
*/
|
|
41
|
+
idleTimeoutMs?: number;
|
|
29
42
|
/** Working directory for the child. Defaults to the CLI's cwd. */
|
|
30
43
|
cwd?: string;
|
|
31
44
|
/** Extra environment for the child, merged over ``process.env``. */
|
|
@@ -55,7 +68,18 @@ export interface ExecResult {
|
|
|
55
68
|
stdout: string;
|
|
56
69
|
stderr: string;
|
|
57
70
|
exitCode: number | null;
|
|
71
|
+
/** The run overran {@link ExecOptions.timeoutMs}. */
|
|
58
72
|
timedOut: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The run went silent for {@link ExecOptions.idleTimeoutMs} and was killed.
|
|
75
|
+
*
|
|
76
|
+
* Separate from {@link timedOut} because the two say different things to
|
|
77
|
+
* whoever reads the failure: one run used its whole budget, the other stopped
|
|
78
|
+
* doing anything at all. They are handled identically everywhere else — both
|
|
79
|
+
* are kills from outside, so both salvage partial output and keep the
|
|
80
|
+
* session they had recorded.
|
|
81
|
+
*/
|
|
82
|
+
idleTimedOut: boolean;
|
|
59
83
|
/** The run was stopped through {@link ExecOptions.cancelSignal}. */
|
|
60
84
|
cancelled: boolean;
|
|
61
85
|
/** Populated when the process could not be started at all. */
|
|
@@ -76,6 +100,17 @@ export declare const CANCELLED_ERROR = "cancelled";
|
|
|
76
100
|
* Kept to one line for the same reason.
|
|
77
101
|
*/
|
|
78
102
|
export declare function timeoutMessage(timeoutMs: number): string;
|
|
103
|
+
/**
|
|
104
|
+
* What a run killed by the inactivity watchdog reports.
|
|
105
|
+
*
|
|
106
|
+
* Same job as {@link timeoutMessage}, and the same constraint: the server
|
|
107
|
+
* echoes a failed run's error into the thread, so this is read by the person
|
|
108
|
+
* who was waiting rather than by whoever tails `agent.log`. It has to say what
|
|
109
|
+
* happened ("nothing came out of it"), that this is an inference rather than a
|
|
110
|
+
* crash the agent reported, and how to change the judgement — in one line,
|
|
111
|
+
* because the notice it lands in has room for one.
|
|
112
|
+
*/
|
|
113
|
+
export declare function idleTimeoutMessage(idleTimeoutMs: number): string;
|
|
79
114
|
/**
|
|
80
115
|
* Execute the agent command with ``prompt`` on stdin and return its stdout.
|
|
81
116
|
*
|
package/dist/agent/exec.js
CHANGED
|
@@ -30,6 +30,38 @@ export const CANCELLED_ERROR = 'cancelled';
|
|
|
30
30
|
export function timeoutMessage(timeoutMs) {
|
|
31
31
|
return `timed out after ${timeoutMs}ms — raise the ceiling with --timeout <seconds> (0 = no limit)`;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Say a silence window the way a person would say it.
|
|
35
|
+
*
|
|
36
|
+
* The default is fifteen minutes and it wants to read as "15 minutes", not
|
|
37
|
+
* "900000ms": this string is shown to whoever was waiting on the agent, and a
|
|
38
|
+
* number they have to convert is a number they will misread.
|
|
39
|
+
*/
|
|
40
|
+
function renderWindow(ms) {
|
|
41
|
+
if (ms >= 60_000 && ms % 60_000 === 0) {
|
|
42
|
+
const minutes = ms / 60_000;
|
|
43
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
44
|
+
}
|
|
45
|
+
const seconds = ms / 1_000;
|
|
46
|
+
const rendered = Number.isInteger(seconds)
|
|
47
|
+
? String(seconds)
|
|
48
|
+
: seconds.toFixed(seconds < 1 ? 2 : 1);
|
|
49
|
+
return `${rendered} second${rendered === '1' ? '' : 's'}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* What a run killed by the inactivity watchdog reports.
|
|
53
|
+
*
|
|
54
|
+
* Same job as {@link timeoutMessage}, and the same constraint: the server
|
|
55
|
+
* echoes a failed run's error into the thread, so this is read by the person
|
|
56
|
+
* who was waiting rather than by whoever tails `agent.log`. It has to say what
|
|
57
|
+
* happened ("nothing came out of it"), that this is an inference rather than a
|
|
58
|
+
* crash the agent reported, and how to change the judgement — in one line,
|
|
59
|
+
* because the notice it lands in has room for one.
|
|
60
|
+
*/
|
|
61
|
+
export function idleTimeoutMessage(idleTimeoutMs) {
|
|
62
|
+
return (`no output for ${renderWindow(idleTimeoutMs)} — treated as stalled; ` +
|
|
63
|
+
'tune with --idle-timeout <seconds> (0 = disabled)');
|
|
64
|
+
}
|
|
33
65
|
function clamp(text) {
|
|
34
66
|
if (text.length <= MAX_OUTPUT_CHARS)
|
|
35
67
|
return text;
|
|
@@ -49,6 +81,7 @@ export async function execAgent(options) {
|
|
|
49
81
|
let stdout = '';
|
|
50
82
|
let stderr = '';
|
|
51
83
|
let timedOut = false;
|
|
84
|
+
let idleTimedOut = false;
|
|
52
85
|
let cancelled = false;
|
|
53
86
|
let settled = false;
|
|
54
87
|
// Nothing to stop if the stop already happened: a run cancelled while it
|
|
@@ -60,6 +93,7 @@ export async function execAgent(options) {
|
|
|
60
93
|
stderr: '',
|
|
61
94
|
exitCode: null,
|
|
62
95
|
timedOut: false,
|
|
96
|
+
idleTimedOut: false,
|
|
63
97
|
cancelled: true,
|
|
64
98
|
error: CANCELLED_ERROR,
|
|
65
99
|
durationMs: Date.now() - started,
|
|
@@ -88,6 +122,7 @@ export async function execAgent(options) {
|
|
|
88
122
|
stderr: '',
|
|
89
123
|
exitCode: null,
|
|
90
124
|
timedOut: false,
|
|
125
|
+
idleTimedOut: false,
|
|
91
126
|
cancelled: false,
|
|
92
127
|
error: err instanceof Error ? err.message : String(err),
|
|
93
128
|
durationMs: Date.now() - started,
|
|
@@ -95,15 +130,32 @@ export async function execAgent(options) {
|
|
|
95
130
|
return;
|
|
96
131
|
}
|
|
97
132
|
let timer;
|
|
133
|
+
let idleTimer;
|
|
98
134
|
let killTimer;
|
|
99
|
-
|
|
100
|
-
|
|
135
|
+
const idleMs = options.idleTimeoutMs ?? 0;
|
|
136
|
+
/**
|
|
137
|
+
* Why the run ended, when it ended because we ended it.
|
|
138
|
+
*
|
|
139
|
+
* Ordered by which decision was made first in practice: a deliberate stop
|
|
140
|
+
* outranks either clock, and the wall clock outranks the watchdog because
|
|
141
|
+
* `terminate` disarms the watchdog on its way out — so if both flags are
|
|
142
|
+
* somehow set, the ceiling is what actually fired.
|
|
143
|
+
*/
|
|
144
|
+
const stopReason = () => cancelled
|
|
145
|
+
? CANCELLED_ERROR
|
|
146
|
+
: timedOut
|
|
147
|
+
? timeoutMessage(options.timeoutMs)
|
|
148
|
+
: idleTimedOut
|
|
149
|
+
? idleTimeoutMessage(idleMs)
|
|
150
|
+
: undefined;
|
|
101
151
|
const finish = (result) => {
|
|
102
152
|
if (settled)
|
|
103
153
|
return;
|
|
104
154
|
settled = true;
|
|
105
155
|
if (timer !== undefined)
|
|
106
156
|
clearTimeout(timer);
|
|
157
|
+
if (idleTimer !== undefined)
|
|
158
|
+
clearTimeout(idleTimer);
|
|
107
159
|
if (killTimer !== undefined)
|
|
108
160
|
clearTimeout(killTimer);
|
|
109
161
|
options.cancelSignal?.removeEventListener('abort', onAbort);
|
|
@@ -155,6 +207,21 @@ export async function execAgent(options) {
|
|
|
155
207
|
const terminate = () => {
|
|
156
208
|
if (settled || killTimer !== undefined)
|
|
157
209
|
return;
|
|
210
|
+
// Both clocks are disarmed here rather than in `finish`, because a
|
|
211
|
+
// termination takes up to KILL_GRACE_MS and neither clock may re-decide
|
|
212
|
+
// the reason during it. A ceiling that lapses while a stalled run is
|
|
213
|
+
// being killed would otherwise overwrite "no output for 15 minutes" with
|
|
214
|
+
// "timed out", sending whoever reads it to the wrong flag — and the
|
|
215
|
+
// child's dying output would likewise re-arm a watchdog for a run that
|
|
216
|
+
// is already on its way out.
|
|
217
|
+
if (timer !== undefined) {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
timer = undefined;
|
|
220
|
+
}
|
|
221
|
+
if (idleTimer !== undefined) {
|
|
222
|
+
clearTimeout(idleTimer);
|
|
223
|
+
idleTimer = undefined;
|
|
224
|
+
}
|
|
158
225
|
signalTree('SIGTERM');
|
|
159
226
|
killTimer = setTimeout(() => {
|
|
160
227
|
signalTree('SIGKILL');
|
|
@@ -167,11 +234,30 @@ export async function execAgent(options) {
|
|
|
167
234
|
stderr: clamp(stderr),
|
|
168
235
|
exitCode: null,
|
|
169
236
|
timedOut,
|
|
237
|
+
idleTimedOut,
|
|
170
238
|
cancelled,
|
|
171
239
|
error: stopReason(),
|
|
172
240
|
});
|
|
173
241
|
}, KILL_GRACE_MS);
|
|
174
242
|
};
|
|
243
|
+
/**
|
|
244
|
+
* (Re)start the silence window.
|
|
245
|
+
*
|
|
246
|
+
* Armed once the child exists and again on every chunk it writes, so the
|
|
247
|
+
* window measures time since the last sign of life rather than time since
|
|
248
|
+
* the run began. Deliberately a no-op once a kill is under way: the run's
|
|
249
|
+
* fate is already decided, and the flag that explains it is already set.
|
|
250
|
+
*/
|
|
251
|
+
const armIdleWatchdog = () => {
|
|
252
|
+
if (idleMs <= 0 || settled || killTimer !== undefined)
|
|
253
|
+
return;
|
|
254
|
+
if (idleTimer !== undefined)
|
|
255
|
+
clearTimeout(idleTimer);
|
|
256
|
+
idleTimer = setTimeout(() => {
|
|
257
|
+
idleTimedOut = true;
|
|
258
|
+
terminate();
|
|
259
|
+
}, idleMs);
|
|
260
|
+
};
|
|
175
261
|
function onAbort() {
|
|
176
262
|
cancelled = true;
|
|
177
263
|
terminate();
|
|
@@ -185,6 +271,7 @@ export async function execAgent(options) {
|
|
|
185
271
|
options.cancelSignal?.addEventListener('abort', onAbort, { once: true });
|
|
186
272
|
child.stdout?.on('data', (chunk) => {
|
|
187
273
|
const text = chunk.toString();
|
|
274
|
+
armIdleWatchdog();
|
|
188
275
|
// Capture is bounded; the callback is not. A parser that stops being fed
|
|
189
276
|
// at 512 KB would report stale progress and salvage the wrong text for
|
|
190
277
|
// the rest of a long run — and the callback keeps nothing per chunk.
|
|
@@ -198,6 +285,10 @@ export async function execAgent(options) {
|
|
|
198
285
|
}
|
|
199
286
|
});
|
|
200
287
|
child.stderr?.on('data', (chunk) => {
|
|
288
|
+
// stderr counts as life exactly as much as stdout does. An agent that
|
|
289
|
+
// logs its progress to stderr and answers on stdout — several do — is
|
|
290
|
+
// plainly working, and a watchdog watching one stream would kill it.
|
|
291
|
+
armIdleWatchdog();
|
|
201
292
|
if (stderr.length < MAX_OUTPUT_CHARS)
|
|
202
293
|
stderr += chunk.toString();
|
|
203
294
|
});
|
|
@@ -208,21 +299,27 @@ export async function execAgent(options) {
|
|
|
208
299
|
stderr: clamp(stderr),
|
|
209
300
|
exitCode: null,
|
|
210
301
|
timedOut,
|
|
302
|
+
idleTimedOut,
|
|
211
303
|
cancelled,
|
|
212
304
|
error: err.message,
|
|
213
305
|
});
|
|
214
306
|
});
|
|
215
307
|
child.on('close', (code) => {
|
|
216
308
|
finish({
|
|
217
|
-
ok: !timedOut && !cancelled && code === 0,
|
|
309
|
+
ok: !timedOut && !idleTimedOut && !cancelled && code === 0,
|
|
218
310
|
stdout: clamp(stdout),
|
|
219
311
|
stderr: clamp(stderr),
|
|
220
312
|
exitCode: code,
|
|
221
313
|
timedOut,
|
|
314
|
+
idleTimedOut,
|
|
222
315
|
cancelled,
|
|
223
316
|
error: stopReason(),
|
|
224
317
|
});
|
|
225
318
|
});
|
|
319
|
+
// Started here, not at the top of the function: the window measures the
|
|
320
|
+
// child's silence, and a child that has not been spawned yet — or that
|
|
321
|
+
// failed to spawn at all — has nothing to be silent about.
|
|
322
|
+
armIdleWatchdog();
|
|
226
323
|
// stdin may already be closed if the command failed to start.
|
|
227
324
|
try {
|
|
228
325
|
child.stdin?.on('error', () => {
|
package/dist/agent/prompt.d.ts
CHANGED
|
@@ -50,6 +50,17 @@ export interface PromptContext {
|
|
|
50
50
|
* They cannot tell those apart from a sentence that says neither.
|
|
51
51
|
*/
|
|
52
52
|
timeoutMs?: number;
|
|
53
|
+
/**
|
|
54
|
+
* How long the run may produce nothing at all before it is killed, in
|
|
55
|
+
* milliseconds. ``0`` is `--idle-timeout 0`: no watchdog.
|
|
56
|
+
*
|
|
57
|
+
* Threaded in for the same reason as {@link timeoutMs}, and it now carries
|
|
58
|
+
* most of the weight: with no wall-clock ceiling by default, this is usually
|
|
59
|
+
* the only local limit there is. It is also a limit an agent can act on,
|
|
60
|
+
* which a duration is not — "do not go completely silent for a quarter of an
|
|
61
|
+
* hour" is advice about how to work, not a request to hurry.
|
|
62
|
+
*/
|
|
63
|
+
idleTimeoutMs?: number;
|
|
53
64
|
}
|
|
54
65
|
/** Build the stdin prompt for one run. */
|
|
55
66
|
export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;
|
package/dist/agent/prompt.js
CHANGED
|
@@ -23,31 +23,68 @@ function renderBudget(timeoutMs) {
|
|
|
23
23
|
return `about ${Math.max(1, Math.round(timeoutMs / 1_000))} seconds`;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* The bullet that tells the agent
|
|
26
|
+
* The bullet that tells the agent what its run is actually bounded by.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* Three shapes, and each says something different about how to work:
|
|
29
|
+
*
|
|
30
|
+
* * **A wall-clock ceiling** (`--timeout <n>`) means budget the turn. Naming
|
|
31
|
+
* the number is what stops an agent from treating two hours like five
|
|
32
|
+
* minutes — the failure this replaced was a multi-minute coding task killed
|
|
33
|
+
* at 300s having been told only that "a limit" existed. A watchdog set
|
|
34
|
+
* *shorter* than the ceiling is named alongside it, because it is then the
|
|
35
|
+
* deadline that actually fires first: told only "you have two hours", an
|
|
36
|
+
* agent has no reason to break up the quiet forty-minute step that kills it
|
|
37
|
+
* at fifteen.
|
|
38
|
+
* * **The watchdog alone** — the default — means the turn is bounded by
|
|
39
|
+
* *silence*, not duration. That is a limit an agent can actually act on, and
|
|
40
|
+
* it must be stated as what it is, because an agent told it has "no limit"
|
|
41
|
+
* and then killed at fifteen minutes of quiet was told something false.
|
|
42
|
+
* * **Neither** means nothing local will stop the run, which still needs a word
|
|
43
|
+
* about pace: nothing is posted until the turn ends, so a run that explores
|
|
44
|
+
* forever reads, from the thread, exactly like one that crashed.
|
|
34
45
|
*/
|
|
35
|
-
function timeBudgetLines(timeoutMs) {
|
|
36
|
-
|
|
46
|
+
function timeBudgetLines(timeoutMs, idleTimeoutMs) {
|
|
47
|
+
// Said nothing about by the caller: say nothing back, rather than describe a
|
|
48
|
+
// regime this prompt has not been told is in force.
|
|
49
|
+
if (timeoutMs === undefined && idleTimeoutMs === undefined)
|
|
37
50
|
return [];
|
|
38
|
-
|
|
51
|
+
const wallMs = timeoutMs ?? 0;
|
|
52
|
+
const idleMs = idleTimeoutMs ?? 0;
|
|
53
|
+
if (wallMs > 0) {
|
|
54
|
+
return [
|
|
55
|
+
`- You have ${renderBudget(wallMs)} for this whole turn. Past that you are killed`,
|
|
56
|
+
' mid-thought and the person waiting gets nothing, so spend the budget on the',
|
|
57
|
+
' work and leave yourself room to write the answer.',
|
|
58
|
+
// Only when it can actually fire first. A watchdog window at or above the
|
|
59
|
+
// ceiling is unreachable — a run silent that long has already overrun —
|
|
60
|
+
// and naming an unreachable limit is noise the agent has to reason past.
|
|
61
|
+
...(idleMs > 0 && idleMs < wallMs
|
|
62
|
+
? [
|
|
63
|
+
'- Going quiet ends the turn early, well before that: produce no output at',
|
|
64
|
+
` all for ${renderBudget(idleMs)} and the run is killed and reported as`,
|
|
65
|
+
' stalled. Ordinary work clears that on its own; a single step that will',
|
|
66
|
+
' be silent for longer is the one thing to break up.',
|
|
67
|
+
]
|
|
68
|
+
: []),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
if (idleMs > 0) {
|
|
39
72
|
return [
|
|
40
|
-
'- There is no fixed time limit on this turn, so work that genuinely takes',
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
'
|
|
73
|
+
'- There is no fixed time limit on this turn, so work that genuinely takes a',
|
|
74
|
+
' while is fine — finish it rather than cutting it short. What is limited is',
|
|
75
|
+
` silence: produce no output at all for ${renderBudget(idleMs)} and the run is`,
|
|
76
|
+
' killed and reported as stalled. Ordinary work clears that on its own; a',
|
|
77
|
+
' single step that will be quiet for longer than that is the one thing to',
|
|
78
|
+
' break up. Nothing you write is posted until the turn ends, so keep moving',
|
|
79
|
+
' toward an answer rather than exploring indefinitely.',
|
|
45
80
|
];
|
|
46
81
|
}
|
|
47
82
|
return [
|
|
48
|
-
|
|
49
|
-
'
|
|
50
|
-
'
|
|
83
|
+
'- There is no time limit on this turn and nothing local will stop it, so work',
|
|
84
|
+
' that genuinely takes a while is fine — finish it rather than cutting it',
|
|
85
|
+
' short. But nothing you write is posted until the turn ends, so a run that',
|
|
86
|
+
' explores indefinitely is indistinguishable from one that crashed. Keep',
|
|
87
|
+
' moving toward an answer.',
|
|
51
88
|
];
|
|
52
89
|
}
|
|
53
90
|
/**
|
|
@@ -187,7 +224,7 @@ export function buildPrompt(run, context) {
|
|
|
187
224
|
// not buried under the tool advice: how long the agent has changes how it
|
|
188
225
|
// should approach the work, so it has to be read before any of the "go and
|
|
189
226
|
// look" bullets below.
|
|
190
|
-
...timeBudgetLines(context.timeoutMs),
|
|
227
|
+
...timeBudgetLines(context.timeoutMs, context.idleTimeoutMs),
|
|
191
228
|
// Claude Code lazy-loads part of its catalog — WebSearch/WebFetch among
|
|
192
229
|
// them — so a run that inspects its loaded tools concludes it cannot
|
|
193
230
|
// browse and says so to a customer, when one ToolSearch call away it
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -26,18 +26,36 @@ import type { spawn } from 'node:child_process';
|
|
|
26
26
|
import WebSocket from 'ws';
|
|
27
27
|
import type { AgentCredentials } from './credentials.js';
|
|
28
28
|
/**
|
|
29
|
-
* Default ceiling on one agent invocation.
|
|
29
|
+
* Default ceiling on one agent invocation: none.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* and
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
31
|
+
* The wall clock kept being raised — five minutes, then two hours — because it
|
|
32
|
+
* was answering the wrong question. "How long has this run been going?" does
|
|
33
|
+
* not distinguish a wedged child from a productive one; it only distinguishes
|
|
34
|
+
* long work from short, and killing long work is precisely the failure the two
|
|
35
|
+
* previous defaults each produced in production. What the harness can actually
|
|
36
|
+
* observe is whether the child is *doing* anything, which is what
|
|
37
|
+
* {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
|
|
38
|
+
*
|
|
39
|
+
* So a run is now bounded by three things, none of them a fixed duration:
|
|
40
|
+
* the inactivity watchdog (default 15 minutes of total silence), the server's
|
|
41
|
+
* lease on the run (heartbeats stop, the row is reaped), and the Stop button in
|
|
42
|
+
* ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
|
|
43
|
+
* remains fully supported for anyone who wants a hard ceiling as well.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_EXEC_TIMEOUT_MS = 0;
|
|
46
|
+
/**
|
|
47
|
+
* Default silence window before a run is treated as stalled.
|
|
48
|
+
*
|
|
49
|
+
* Fifteen minutes is chosen against what real work looks like, not against
|
|
50
|
+
* what feels responsive: an agent mid-task emits stream events, tool calls and
|
|
51
|
+
* log lines continuously, so the gaps in a healthy run are seconds. The long
|
|
52
|
+
* tail that legitimately goes quiet is a single slow tool call — a test suite,
|
|
53
|
+
* a build, a big fetch — and fifteen minutes clears those comfortably while
|
|
54
|
+
* still catching the failure this exists for, a child wedged on a stdin prompt
|
|
55
|
+
* that heartbeats look perfectly healthy for and that burns the operator's
|
|
56
|
+
* tokens and their agent's availability indefinitely.
|
|
39
57
|
*/
|
|
40
|
-
export declare const
|
|
58
|
+
export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
|
|
41
59
|
export interface AgentRunOptions {
|
|
42
60
|
/** Shell command that runs the agent, e.g. ``claude -p``. */
|
|
43
61
|
exec: string;
|
|
@@ -46,8 +64,16 @@ export interface AgentRunOptions {
|
|
|
46
64
|
url?: string;
|
|
47
65
|
/** Machine name shown in the ProhostAI UI. Defaults to the OS hostname. */
|
|
48
66
|
machine?: string;
|
|
49
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Per-run wall-clock ceiling. ``0`` disables it, and that is now the default
|
|
69
|
+
* — see {@link DEFAULT_EXEC_TIMEOUT_MS}.
|
|
70
|
+
*/
|
|
50
71
|
timeoutMs?: number;
|
|
72
|
+
/**
|
|
73
|
+
* How long a run may produce no output at all before it is killed. ``0``
|
|
74
|
+
* disables the watchdog — see {@link DEFAULT_IDLE_TIMEOUT_MS}.
|
|
75
|
+
*/
|
|
76
|
+
idleTimeoutMs?: number;
|
|
51
77
|
/** Print the reply that would be posted and make no writes. */
|
|
52
78
|
dryRun?: boolean;
|
|
53
79
|
/** Execute runs whose signature could not be verified. Off by default. */
|
package/dist/agent/run.js
CHANGED
|
@@ -26,7 +26,7 @@ import os from 'node:os';
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import WebSocket from 'ws';
|
|
28
28
|
import { completeRun, postReply } from './api.js';
|
|
29
|
-
import { claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
29
|
+
import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
30
30
|
import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
31
31
|
import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
|
|
32
32
|
import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
|
|
@@ -41,18 +41,36 @@ import { verifySignature } from './signature.js';
|
|
|
41
41
|
import { isWebhookEvent } from '../forward.js';
|
|
42
42
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
43
43
|
/**
|
|
44
|
-
* Default ceiling on one agent invocation.
|
|
44
|
+
* Default ceiling on one agent invocation: none.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* and
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
46
|
+
* The wall clock kept being raised — five minutes, then two hours — because it
|
|
47
|
+
* was answering the wrong question. "How long has this run been going?" does
|
|
48
|
+
* not distinguish a wedged child from a productive one; it only distinguishes
|
|
49
|
+
* long work from short, and killing long work is precisely the failure the two
|
|
50
|
+
* previous defaults each produced in production. What the harness can actually
|
|
51
|
+
* observe is whether the child is *doing* anything, which is what
|
|
52
|
+
* {@link DEFAULT_IDLE_TIMEOUT_MS} watches.
|
|
53
|
+
*
|
|
54
|
+
* So a run is now bounded by three things, none of them a fixed duration:
|
|
55
|
+
* the inactivity watchdog (default 15 minutes of total silence), the server's
|
|
56
|
+
* lease on the run (heartbeats stop, the row is reaped), and the Stop button in
|
|
57
|
+
* ProhostAI (which reaches this machine on the next heartbeat). `--timeout`
|
|
58
|
+
* remains fully supported for anyone who wants a hard ceiling as well.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 0;
|
|
61
|
+
/**
|
|
62
|
+
* Default silence window before a run is treated as stalled.
|
|
63
|
+
*
|
|
64
|
+
* Fifteen minutes is chosen against what real work looks like, not against
|
|
65
|
+
* what feels responsive: an agent mid-task emits stream events, tool calls and
|
|
66
|
+
* log lines continuously, so the gaps in a healthy run are seconds. The long
|
|
67
|
+
* tail that legitimately goes quiet is a single slow tool call — a test suite,
|
|
68
|
+
* a build, a big fetch — and fifteen minutes clears those comfortably while
|
|
69
|
+
* still catching the failure this exists for, a child wedged on a stdin prompt
|
|
70
|
+
* that heartbeats look perfectly healthy for and that burns the operator's
|
|
71
|
+
* tokens and their agent's availability indefinitely.
|
|
54
72
|
*/
|
|
55
|
-
export const
|
|
73
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
|
|
56
74
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
57
75
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
58
76
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
@@ -365,6 +383,25 @@ function defaultProbe(command, cwd) {
|
|
|
365
383
|
});
|
|
366
384
|
return { status: result.status, stdout: result.stdout ?? '' };
|
|
367
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* What a killed plain command salvages: the last thing it printed.
|
|
388
|
+
*
|
|
389
|
+
* The streaming reader salvages the last thing the *agent* said, having parsed
|
|
390
|
+
* it out of the event stream. There is no parsing to do for a plain `--exec`
|
|
391
|
+
* command — its stdout is its reply — so its rolling tail is the same evidence
|
|
392
|
+
* reached the other way: what the run had produced before it stopped producing
|
|
393
|
+
* anything.
|
|
394
|
+
*
|
|
395
|
+
* Bounded to the same {@link MAX_SALVAGE_CHARS} for the same reason: the
|
|
396
|
+
* completion `error` this ends up in is capped server-side, and it has a stderr
|
|
397
|
+
* tail to carry as well.
|
|
398
|
+
*/
|
|
399
|
+
function partialStdout(tail) {
|
|
400
|
+
const text = tail.trim();
|
|
401
|
+
if (!text)
|
|
402
|
+
return undefined;
|
|
403
|
+
return text.length > MAX_SALVAGE_CHARS ? text.slice(-MAX_SALVAGE_CHARS) : text;
|
|
404
|
+
}
|
|
368
405
|
/**
|
|
369
406
|
* Run the agent command once and turn its output into a reply.
|
|
370
407
|
*
|
|
@@ -380,10 +417,14 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
380
417
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
381
418
|
// `--resume` fails in milliseconds, long before any of the budget is spent.
|
|
382
419
|
const budgetMs = options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
383
|
-
// `--timeout 0
|
|
384
|
-
// ends
|
|
385
|
-
//
|
|
420
|
+
// `--timeout 0`, and the default: no wall-clock ceiling at all. The run then
|
|
421
|
+
// ends when the agent ends it, when the watchdog below finds it silent, when
|
|
422
|
+
// somebody cancels it, or when the server stops renewing its lease — all of
|
|
423
|
+
// which are better judges of "stuck" than a fixed clock.
|
|
386
424
|
const capped = budgetMs > 0;
|
|
425
|
+
// Not shared between attempts the way the wall clock is: the watchdog bounds
|
|
426
|
+
// silence, and a fresh attempt starts a fresh silence.
|
|
427
|
+
const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
387
428
|
const startedAt = Date.now();
|
|
388
429
|
const remainingMs = () => budgetMs - (Date.now() - startedAt);
|
|
389
430
|
const timeLeft = () => !capped || remainingMs() > 0;
|
|
@@ -398,6 +439,16 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
398
439
|
let recordedSession;
|
|
399
440
|
/** The reader for the attempt currently running, if this brain streams. */
|
|
400
441
|
let stream;
|
|
442
|
+
/**
|
|
443
|
+
* Rolling tail of a plain command's stdout — the last thing it printed.
|
|
444
|
+
*
|
|
445
|
+
* Kept as the output arrives rather than sliced off the captured `stdout` at
|
|
446
|
+
* the end, because capture is bounded at {@link MAX_OUTPUT_CHARS} from the
|
|
447
|
+
* *front*: a chatty command that overran that and then stalled would
|
|
448
|
+
* otherwise salvage half a megabyte of stale opening output and call it the
|
|
449
|
+
* unfinished answer.
|
|
450
|
+
*/
|
|
451
|
+
let tail = '';
|
|
401
452
|
/**
|
|
402
453
|
* Act on what the stream has said so far.
|
|
403
454
|
*
|
|
@@ -438,10 +489,12 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
438
489
|
// session, and inheriting the failed attempt's id would resume nothing.
|
|
439
490
|
const reader = runtime.brain === 'claude' ? readClaudeStream() : undefined;
|
|
440
491
|
stream = reader;
|
|
492
|
+
tail = '';
|
|
441
493
|
return execAgent({
|
|
442
494
|
command,
|
|
443
495
|
prompt,
|
|
444
496
|
timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
|
|
497
|
+
idleTimeoutMs: idleMs,
|
|
445
498
|
cwd: runtime.workdir,
|
|
446
499
|
env: mcp.execEnv,
|
|
447
500
|
spawnImpl: options.spawnImpl,
|
|
@@ -451,7 +504,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
451
504
|
reader.push(chunk);
|
|
452
505
|
observe(reader);
|
|
453
506
|
}
|
|
454
|
-
:
|
|
507
|
+
: runtime.brain === 'plain'
|
|
508
|
+
? (chunk) => {
|
|
509
|
+
tail = (tail + chunk).slice(-MAX_SALVAGE_CHARS);
|
|
510
|
+
}
|
|
511
|
+
: undefined,
|
|
455
512
|
});
|
|
456
513
|
};
|
|
457
514
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
@@ -462,10 +519,28 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
462
519
|
}
|
|
463
520
|
const failure = (result) => {
|
|
464
521
|
// A killed run has no reply, but it usually has most of one. Reporting the
|
|
465
|
-
// tail of what the agent was writing turns "
|
|
522
|
+
// tail of what the agent was writing turns "no output for 15 minutes" into
|
|
466
523
|
// something the person waiting can act on — and it is the only trace left
|
|
467
|
-
// of however many hours of work.
|
|
468
|
-
|
|
524
|
+
// of however many hours of work. A stalled run is the case this matters
|
|
525
|
+
// most for: the last thing it said is the best clue to what it stalled on.
|
|
526
|
+
// A plain command has no stream to salvage a parsed answer from, but its
|
|
527
|
+
// stdout *is* its answer by contract — so the tail of what it managed to
|
|
528
|
+
// print is the same evidence, reached the other way. Without this a stalled
|
|
529
|
+
// plain run reports nothing but the fact that it stalled.
|
|
530
|
+
//
|
|
531
|
+
// Deliberately NOT extended to the shaped brains. Both are run with
|
|
532
|
+
// structured output (`--output-format stream-json`, `--json`), whose
|
|
533
|
+
// stream carries the agent's reasoning and the raw output of every local
|
|
534
|
+
// command and tool call it made — which `readClaudeStream`/
|
|
535
|
+
// `parseCodexOutput` exist to filter out. This string is persisted in the
|
|
536
|
+
// completion and echoed into a workspace thread, so tailing that stream
|
|
537
|
+
// raw would publish local shell output, and whatever was in it, to
|
|
538
|
+
// everyone who can read the conversation. A shaped brain whose parser
|
|
539
|
+
// salvaged nothing salvages nothing.
|
|
540
|
+
const killed = result.timedOut || result.idleTimedOut || result.cancelled;
|
|
541
|
+
const salvaged = killed
|
|
542
|
+
? (stream?.salvage() ?? (runtime.brain === 'plain' ? partialStdout(tail) : undefined))
|
|
543
|
+
: undefined;
|
|
469
544
|
// The stderr tail yields room when there is a salvaged answer to carry: the
|
|
470
545
|
// completion `error` is capped server-side, and of the two, what the agent
|
|
471
546
|
// was saying beats the last 500 characters of its logging.
|
|
@@ -525,7 +600,11 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
|
|
|
525
600
|
result = await exec(build());
|
|
526
601
|
}
|
|
527
602
|
if (!result.ok) {
|
|
528
|
-
|
|
603
|
+
// A run we killed keeps its session, whichever clock killed it: the
|
|
604
|
+
// transcript is real, it holds however long the agent worked, and the next
|
|
605
|
+
// delivery on that surface can resume it. Only a turn that failed on its
|
|
606
|
+
// own — a crash, an unparseable answer — has no continuity worth keeping.
|
|
607
|
+
if (!result.timedOut && !result.idleTimedOut && !result.cancelled)
|
|
529
608
|
forgetRecordedSession();
|
|
530
609
|
return failure(result);
|
|
531
610
|
}
|
|
@@ -598,8 +677,9 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
598
677
|
// it is. See PromptContext.mcpToolPattern.
|
|
599
678
|
mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
|
|
600
679
|
toolsGated: !runtime.skipPermissions,
|
|
601
|
-
// The real
|
|
680
|
+
// The real numbers, not a vague "there is a limit" — see PromptContext.
|
|
602
681
|
timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
682
|
+
idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
|
|
603
683
|
});
|
|
604
684
|
const result = await runTurn(run, prompt, options, runtime, mcp, control, log);
|
|
605
685
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
package/dist/index.js
CHANGED
|
@@ -25,7 +25,7 @@ function printHelp() {
|
|
|
25
25
|
'Usage:',
|
|
26
26
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
27
27
|
' prohost agent pair --code <pairing-code> [--webhook-url <https-url>] [--base-url <url>]',
|
|
28
|
-
" prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
28
|
+
" prohost agent run --exec '<command>' [--idle-timeout <seconds>] [--timeout <seconds>] [--dry-run] [--workdir <dir>]",
|
|
29
29
|
" prohost agent install-daemon --exec '<command>' [--workdir <dir>] Keep the agent running",
|
|
30
30
|
' prohost agent uninstall-daemon',
|
|
31
31
|
'',
|
|
@@ -43,9 +43,12 @@ function printHelp() {
|
|
|
43
43
|
'',
|
|
44
44
|
'agent run flags:',
|
|
45
45
|
' --exec Command to run for each mention; the prompt arrives on stdin (required)',
|
|
46
|
-
' --timeout
|
|
47
|
-
'
|
|
48
|
-
'
|
|
46
|
+
' --idle-timeout Seconds a run may produce NO output at all before it is treated as',
|
|
47
|
+
' stalled and killed. Defaults to 900 (15 minutes). Any byte on stdout or',
|
|
48
|
+
' stderr resets it, so a working agent never trips it. Pass 0 to disable.',
|
|
49
|
+
' --timeout Seconds one run may take before it is killed, however busy it is.',
|
|
50
|
+
' Defaults to 0 — no wall-clock limit, because a long run is not a stuck',
|
|
51
|
+
' one. Set it if you want a hard ceiling as well as the stall watchdog.',
|
|
49
52
|
' --dry-run Print the reply that would be posted; write nothing.',
|
|
50
53
|
' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
|
|
51
54
|
' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
|
|
@@ -71,7 +74,8 @@ function printHelp() {
|
|
|
71
74
|
'',
|
|
72
75
|
'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
|
|
73
76
|
' --exec The same command you would pass to `agent run` (required).',
|
|
74
|
-
' --workdir, --timeout, --machine, --url, --allow-unverified, --safe-tools
|
|
77
|
+
' --workdir, --idle-timeout, --timeout, --machine, --url, --allow-unverified, --safe-tools',
|
|
78
|
+
' — passed to `agent run`.',
|
|
75
79
|
'',
|
|
76
80
|
'Global flags:',
|
|
77
81
|
' --help, -h Show this help and exit.',
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.
|
|
10
|
+
export declare const CLI_VERSION = "0.4.0";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.4.0";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.
|
|
10
|
+
export const CLI_VERSION = '0.4.0';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|