@prohost/cli 0.4.2 → 0.6.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 +99 -0
- package/README.md +24 -0
- package/dist/agent/api.d.ts +45 -2
- package/dist/agent/api.js +67 -8
- package/dist/agent/claude.d.ts +7 -0
- package/dist/agent/claude.js +13 -0
- package/dist/agent/command.js +4 -0
- package/dist/agent/contract.d.ts +2 -0
- package/dist/agent/contract.js +2 -0
- package/dist/agent/heartbeat.js +4 -3
- package/dist/agent/run.d.ts +19 -0
- package/dist/agent/run.js +214 -21
- package/dist/agent/runtime_status.d.ts +191 -0
- package/dist/agent/runtime_status.js +556 -0
- package/dist/listen.d.ts +7 -0
- package/dist/listen.js +25 -2
- package/dist/liveness.d.ts +107 -0
- package/dist/liveness.js +156 -0
- package/dist/upgrade.d.ts +49 -0
- package/dist/upgrade.js +99 -0
- 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,105 @@ 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.6.0
|
|
8
|
+
|
|
9
|
+
ProhostAI can now show which runtime, model and machine a paired AI employee
|
|
10
|
+
runs on, and how much of the operator's Claude or Codex subscription is left.
|
|
11
|
+
|
|
12
|
+
- **A `runtime_status` frame.** When — and only when — the server lists
|
|
13
|
+
`runtime_status` in `auth_ok.features`, `agent run` reports its runtime
|
|
14
|
+
(`claude_code`, `codex`, `goose` or `custom`), the agent CLI's version, the
|
|
15
|
+
model, the plan (`Claude Max`, `ChatGPT Pro`, …) and the 5-hour and weekly
|
|
16
|
+
capacity windows as percentages with reset times. It is sent on connect,
|
|
17
|
+
every five minutes, and promptly when the model or plan changes or capacity
|
|
18
|
+
crosses 75% / 100% — never more than once per 31 s. An older server gets
|
|
19
|
+
nothing new.
|
|
20
|
+
- **Your login never leaves this machine, and the CLI never reads it.** The
|
|
21
|
+
numbers come from the agent CLI answering with its own credentials: Claude
|
|
22
|
+
Code's own `stream-json` output (`system/init`, `rate_limit_event`) and, when
|
|
23
|
+
idle, its `get_usage` control request (no model turn, no quota spent); Codex's
|
|
24
|
+
per-thread rollout file under `$CODEX_HOME/sessions` and, when idle,
|
|
25
|
+
`codex app-server`'s `account/rateLimits/read`. The Claude Code keychain item
|
|
26
|
+
and `~/.codex/auth.json` are never opened, and account ids, emails and org
|
|
27
|
+
names are never forwarded. Anything unknown is omitted, never sent as zero.
|
|
28
|
+
- **The model on completion.** `POST /v1/agent-runs/{id}/complete` now carries
|
|
29
|
+
`model` when the harness knows it (Claude Code's `system/init.model`, Codex's
|
|
30
|
+
rollout `turn_context.model`), so a reply can say which model wrote it. A
|
|
31
|
+
plain `--exec` command's completion body is unchanged.
|
|
32
|
+
|
|
33
|
+
## 0.5.0
|
|
34
|
+
|
|
35
|
+
A paired agent can no longer sit on a dead socket believing it is connected.
|
|
36
|
+
|
|
37
|
+
- **The failure.** One daemon (`agent run` under launchd) was "connected" for
|
|
38
|
+
three weeks and received nothing. The server had closed the socket on
|
|
39
|
+
2026-08-11 — the laptop was almost certainly asleep, the FIN never arrived —
|
|
40
|
+
and the client kernel kept the TCP connection in `ESTABLISHED`. The CLI only
|
|
41
|
+
ever reacted to `close` and `error`, answered pings with pongs, and had no
|
|
42
|
+
deadline of its own, so from where it stood the connection was open, quiet,
|
|
43
|
+
and fine. The server pings every 30 s and reaps after 60 s without a pong;
|
|
44
|
+
that contract only helps when the close actually reaches the client, and on
|
|
45
|
+
a half-open socket it never does. Nothing on either side had a reason to
|
|
46
|
+
say anything.
|
|
47
|
+
- **Client-side liveness watchdog.** Every frame the server sends — pings
|
|
48
|
+
included — is proof of life. Ninety seconds without one (three missed pings,
|
|
49
|
+
comfortably past the server's own reaper) means the connection is dead
|
|
50
|
+
whatever the kernel thinks: the CLI logs
|
|
51
|
+
`… no server ping for 90s — reconnecting`, tears the socket down with
|
|
52
|
+
`terminate()` (not `close()`, whose handshake would wait on a peer that is
|
|
53
|
+
not there), and the ordinary backoff-and-reconnect path runs. `listen` has
|
|
54
|
+
the same deadline; it has no reconnect loop, so it ends the session with
|
|
55
|
+
`… no server ping for 90s — connection lost` rather than a counter that
|
|
56
|
+
quietly stopped moving. The timer dies with the socket, so a reconnect never
|
|
57
|
+
inherits the previous socket's silence.
|
|
58
|
+
- **TCP keepalive.** Switched on for the connection under the websocket, with
|
|
59
|
+
a 30 s idle delay, as a kernel-level probe underneath the application-level
|
|
60
|
+
one. Guarded: a `ws` build that hides its socket, or none at all, simply
|
|
61
|
+
gets no keepalive rather than a crash on `open`.
|
|
62
|
+
- **A heartbeat in the log.** Every five minutes while connected, `agent run`
|
|
63
|
+
writes `● connected — last server ping Ns ago`. A stalled daemon now looks
|
|
64
|
+
different in `agent.log` from a healthy quiet one.
|
|
65
|
+
- **The build reports itself.** The websocket auth frame carries
|
|
66
|
+
`client_version` (this build's version) alongside `api_key` / `machine` /
|
|
67
|
+
`subscription_id`, and the upgrade request sends `User-Agent:
|
|
68
|
+
prohost-cli/<version>` like every HTTP call already did. The failing daemon
|
|
69
|
+
was two minor versions behind npm the whole time and nobody could tell.
|
|
70
|
+
- **An upgrade notice.** At `agent run` startup the CLI asks the npm registry
|
|
71
|
+
for the latest version, with a 3 s budget, and prints
|
|
72
|
+
`⬆ @prohost/cli <latest> is available (running <current>) — npm i -g @prohost/cli`
|
|
73
|
+
when there is one. It is not awaited and cannot fail startup: a slow, down,
|
|
74
|
+
or nonsensical registry prints nothing.
|
|
75
|
+
- **A stalled handshake is bounded too.** The deadline only starts at `open`,
|
|
76
|
+
and `ws` has no handshake timeout by default, so `agent run` now gives the
|
|
77
|
+
handshake 30 s — the same asleep-laptop failure, one step earlier.
|
|
78
|
+
|
|
79
|
+
A slow reply is no longer reported as a failed run.
|
|
80
|
+
|
|
81
|
+
- **The second failure, seen the moment the daemon reconnected.** The reply
|
|
82
|
+
`POST /v1/conversations/{id}/messages` took the server **51,216 ms** and
|
|
83
|
+
returned 202 — the message was saved — but the CLI had abandoned the request
|
|
84
|
+
at its 30 s deadline, logged `✗ run … reply rejected (HTTP 0) This operation
|
|
85
|
+
was aborted`, and completed the run as failed. The server then posted a
|
|
86
|
+
"picked this up but its run failed — posting the reply failed with HTTP 0"
|
|
87
|
+
note directly under an answer that had in fact landed. Two consecutive runs.
|
|
88
|
+
The slow send path is being fixed server-side; this release stops the client
|
|
89
|
+
turning a slow success into a reported failure.
|
|
90
|
+
- **120 s for the reply and the completion.** Cheap calls (heartbeats) keep
|
|
91
|
+
30 s. Four times the observed latency is the margin.
|
|
92
|
+
- **No answer is not a failure — replay with the same key.** The reply POST
|
|
93
|
+
was already keyed on the run id (`Idempotency-Key: prohost-cli-run-<run_id>`),
|
|
94
|
+
which is exactly what makes asking again safe. On a timeout or a network
|
|
95
|
+
error the CLI now logs `… reply POST timed out after 120s — replaying with
|
|
96
|
+
the same idempotency key` and sends the byte-identical request once more.
|
|
97
|
+
The server answers with the cached response of the attempt that landed
|
|
98
|
+
(`✓ reply confirmed on replay`), or with `409 idempotency_key_in_flight` if
|
|
99
|
+
the first attempt is still being processed — in which case the CLI waits
|
|
100
|
+
5 s and replays once more. Only a definitive answer, or a second transport
|
|
101
|
+
failure, reports the run as failed, and it does so exactly once.
|
|
102
|
+
- **`HTTP 0` is gone from the log.** A request with no response now says why:
|
|
103
|
+
`timed out after 120s` or `network error: <cause>`, in the log line and in
|
|
104
|
+
the failure reason the server shows in the thread.
|
|
105
|
+
|
|
7
106
|
## 0.4.2
|
|
8
107
|
|
|
9
108
|
Isolated Codex runs no longer receive the operator's **apps (connectors)**.
|
package/README.md
CHANGED
|
@@ -428,6 +428,20 @@ your agent a second time. That matters because your agent holds a credential:
|
|
|
428
428
|
a second execution could file the same task or send the same email again, which
|
|
429
429
|
no amount of message deduplication would undo.
|
|
430
430
|
|
|
431
|
+
### What ProhostAI sees about your runtime
|
|
432
|
+
|
|
433
|
+
When the server supports it, `agent run` reports which runtime it drives
|
|
434
|
+
(Claude Code, Codex, goose or a custom command), that CLI's version, the model,
|
|
435
|
+
your plan's name, and how much of your 5-hour and weekly capacity is used, as
|
|
436
|
+
percentages with reset times. ProhostAI shows this next to the AI employee.
|
|
437
|
+
|
|
438
|
+
Your Claude or Codex login never leaves your machine, and `prohost` never reads
|
|
439
|
+
it: the numbers come from the agent CLI's own output (Claude Code's
|
|
440
|
+
`stream-json` events, Codex's session rollout files) or from asking the
|
|
441
|
+
unmodified CLI through its own protocol (`get_usage`,
|
|
442
|
+
`codex app-server`), which answers with its own login. Account ids, emails and
|
|
443
|
+
org names are never sent.
|
|
444
|
+
|
|
431
445
|
### The trust model — read this once
|
|
432
446
|
|
|
433
447
|
`agent run` is a standing instruction to execute a command on your machine when
|
|
@@ -525,6 +539,16 @@ On macOS this writes a launchd agent to
|
|
|
525
539
|
`$PROHOST_HOME/agent.log` — the first place to look when the agent is quiet.
|
|
526
540
|
Re-running it with a different `--exec` replaces the old daemon.
|
|
527
541
|
|
|
542
|
+
**It notices when the connection dies, even when the network doesn't say so.**
|
|
543
|
+
The server pings every 30 seconds. If no frame of any kind arrives for 90
|
|
544
|
+
seconds — a laptop that slept through the server closing the socket looks
|
|
545
|
+
exactly like this — the CLI logs `… no server ping for 90s — reconnecting`,
|
|
546
|
+
drops the socket and reconnects with backoff. While connected it writes
|
|
547
|
+
`● connected — last server ping Ns ago` every five minutes, so a healthy quiet
|
|
548
|
+
daemon and a stalled one read differently in `agent.log`. At startup it also
|
|
549
|
+
checks npm and prints one line if a newer `@prohost/cli` exists; the check
|
|
550
|
+
never delays or fails the connection.
|
|
551
|
+
|
|
528
552
|
The daemon is scoped to `$PROHOST_HOME`, because that's where the one credential
|
|
529
553
|
lives: installing twice from the same home replaces the daemon rather than
|
|
530
554
|
adding a second one. To run two agents on one machine, give each its own
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -13,13 +13,23 @@ export interface ApiOptions {
|
|
|
13
13
|
baseUrl: string;
|
|
14
14
|
apiKey: string;
|
|
15
15
|
fetchImpl?: typeof fetch;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Per-request timeout override, applied to every call. Unset, each call
|
|
18
|
+
* uses its own default — {@link DEFAULT_TIMEOUT_MS} for the cheap ones,
|
|
19
|
+
* {@link SEND_TIMEOUT_MS} for the reply and the completion.
|
|
20
|
+
*/
|
|
17
21
|
timeoutMs?: number;
|
|
18
22
|
}
|
|
19
23
|
export interface ApiResult {
|
|
20
24
|
ok: boolean;
|
|
25
|
+
/** HTTP status, or ``0`` when no response arrived at all — see ``failure``. */
|
|
21
26
|
status: number;
|
|
22
27
|
error?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Why ``status`` is ``0``: the request was abandoned at its deadline, or the
|
|
30
|
+
* network failed before any status came back. Absent on every real response.
|
|
31
|
+
*/
|
|
32
|
+
failure?: 'timeout' | 'network';
|
|
23
33
|
/**
|
|
24
34
|
* The response body, when the caller asked for it.
|
|
25
35
|
*
|
|
@@ -28,6 +38,35 @@ export interface ApiResult {
|
|
|
28
38
|
*/
|
|
29
39
|
body?: string;
|
|
30
40
|
}
|
|
41
|
+
/** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
|
|
42
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
43
|
+
/**
|
|
44
|
+
* Deadline for the reply POST and the completion.
|
|
45
|
+
*
|
|
46
|
+
* Thirty seconds was not enough. On 2026-09-01 the server took 51,216 ms to
|
|
47
|
+
* accept a reply (``POST /v1/conversations/{id}/messages`` → 202): the
|
|
48
|
+
* message was saved, but the CLI had already abandoned the request at 30 s,
|
|
49
|
+
* logged the reply as rejected, and completed the run as failed — so the
|
|
50
|
+
* server posted a "run failed" note under an answer that had in fact landed.
|
|
51
|
+
* Twice in a row. The slow send path is being fixed server-side; the client's
|
|
52
|
+
* job is to not turn a slow success into a reported failure, and four times
|
|
53
|
+
* the observed latency is the margin for that.
|
|
54
|
+
*/
|
|
55
|
+
export declare const SEND_TIMEOUT_MS = 120000;
|
|
56
|
+
/**
|
|
57
|
+
* One line a person can act on for a failed result: ``HTTP 503``, ``timed
|
|
58
|
+
* out after 120s``, ``network error: ECONNRESET``. ``HTTP 0`` told nobody
|
|
59
|
+
* anything, and it was the only thing the log said for the whole of the
|
|
60
|
+
* 30-second-timeout incident.
|
|
61
|
+
*/
|
|
62
|
+
export declare function describeFailure(result: ApiResult): string;
|
|
63
|
+
/**
|
|
64
|
+
* The server's "same key, same body, the first attempt is still being
|
|
65
|
+
* processed" answer (``api_public/middleware/idempotency.py``). A replay that
|
|
66
|
+
* gets this should wait and replay again — the original will finish and be
|
|
67
|
+
* cached, and the next replay receives its response.
|
|
68
|
+
*/
|
|
69
|
+
export declare function isIdempotencyInFlight(result: ApiResult): boolean;
|
|
31
70
|
/**
|
|
32
71
|
* Post the agent's reply into the conversation that triggered the run.
|
|
33
72
|
*
|
|
@@ -69,6 +108,7 @@ export type HeartbeatOutcome =
|
|
|
69
108
|
kind: 'error';
|
|
70
109
|
status: number;
|
|
71
110
|
detail?: string;
|
|
111
|
+
failure?: ApiResult['failure'];
|
|
72
112
|
};
|
|
73
113
|
/**
|
|
74
114
|
* Tell the server a run is still being worked on, and ask whether it should be.
|
|
@@ -89,10 +129,13 @@ export declare function heartbeatRun(options: ApiOptions, path: string, progress
|
|
|
89
129
|
* A ``409`` is reported as success: it means the run already reached a terminal
|
|
90
130
|
* state (a redelivery we raced, or the 2h stale-run sweeper got there first).
|
|
91
131
|
* Treating that as a failure would only produce noise about work that is done.
|
|
132
|
+
*
|
|
133
|
+
* ``model`` is the model id the run used, when the harness knows it. Omitted
|
|
134
|
+
* otherwise, so a plain ``--exec`` command's completion body is unchanged.
|
|
92
135
|
*/
|
|
93
136
|
export declare function completeRun(options: ApiOptions, path: string, outcome: {
|
|
94
137
|
ok: true;
|
|
95
138
|
} | {
|
|
96
139
|
ok: false;
|
|
97
140
|
error: string;
|
|
98
|
-
}): Promise<ApiResult>;
|
|
141
|
+
}, model?: string): Promise<ApiResult>;
|
package/dist/agent/api.js
CHANGED
|
@@ -10,14 +10,53 @@
|
|
|
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, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
|
|
14
|
-
|
|
13
|
+
import { MAX_ERROR_CHARS, MAX_MODEL_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
|
|
14
|
+
/** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
|
|
15
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
16
|
+
/**
|
|
17
|
+
* Deadline for the reply POST and the completion.
|
|
18
|
+
*
|
|
19
|
+
* Thirty seconds was not enough. On 2026-09-01 the server took 51,216 ms to
|
|
20
|
+
* accept a reply (``POST /v1/conversations/{id}/messages`` → 202): the
|
|
21
|
+
* message was saved, but the CLI had already abandoned the request at 30 s,
|
|
22
|
+
* logged the reply as rejected, and completed the run as failed — so the
|
|
23
|
+
* server posted a "run failed" note under an answer that had in fact landed.
|
|
24
|
+
* Twice in a row. The slow send path is being fixed server-side; the client's
|
|
25
|
+
* job is to not turn a slow success into a reported failure, and four times
|
|
26
|
+
* the observed latency is the margin for that.
|
|
27
|
+
*/
|
|
28
|
+
export const SEND_TIMEOUT_MS = 120_000;
|
|
15
29
|
/** Cap on a captured response body. Every one we parse is a small object. */
|
|
16
30
|
const MAX_BODY_CHARS = 2_000;
|
|
17
|
-
|
|
31
|
+
/**
|
|
32
|
+
* One line a person can act on for a failed result: ``HTTP 503``, ``timed
|
|
33
|
+
* out after 120s``, ``network error: ECONNRESET``. ``HTTP 0`` told nobody
|
|
34
|
+
* anything, and it was the only thing the log said for the whole of the
|
|
35
|
+
* 30-second-timeout incident.
|
|
36
|
+
*/
|
|
37
|
+
export function describeFailure(result) {
|
|
38
|
+
if (result.failure === 'timeout')
|
|
39
|
+
return result.error ?? 'timed out';
|
|
40
|
+
if (result.failure === 'network')
|
|
41
|
+
return `network error: ${result.error ?? 'unknown'}`;
|
|
42
|
+
if (result.status === 0)
|
|
43
|
+
return `network error: ${result.error ?? 'unknown'}`;
|
|
44
|
+
return `HTTP ${result.status}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The server's "same key, same body, the first attempt is still being
|
|
48
|
+
* processed" answer (``api_public/middleware/idempotency.py``). A replay that
|
|
49
|
+
* gets this should wait and replay again — the original will finish and be
|
|
50
|
+
* cached, and the next replay receives its response.
|
|
51
|
+
*/
|
|
52
|
+
export function isIdempotencyInFlight(result) {
|
|
53
|
+
return result.status === 409 && (result.error ?? '').includes('idempotency_key_in_flight');
|
|
54
|
+
}
|
|
55
|
+
async function post(options, path, body, idempotencyKey, captureBody = false, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
18
56
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
19
57
|
const controller = new AbortController();
|
|
20
|
-
const
|
|
58
|
+
const deadlineMs = options.timeoutMs ?? timeoutMs;
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
21
60
|
try {
|
|
22
61
|
const response = await fetchImpl(`${options.baseUrl.replace(/\/+$/, '')}${path}`, {
|
|
23
62
|
method: 'POST',
|
|
@@ -46,9 +85,22 @@ async function post(options, path, body, idempotencyKey, captureBody = false) {
|
|
|
46
85
|
return { ok, status: response.status, error, body: captureBody ? text : undefined };
|
|
47
86
|
}
|
|
48
87
|
catch (err) {
|
|
88
|
+
// Our own deadline fired: `fetch` rejects with an AbortError, whose
|
|
89
|
+
// message ("This operation was aborted") names neither the cause nor the
|
|
90
|
+
// duration. Say both.
|
|
91
|
+
const abortError = err instanceof Error && err.name === 'AbortError';
|
|
92
|
+
if (controller.signal.aborted || abortError) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 0,
|
|
96
|
+
failure: 'timeout',
|
|
97
|
+
error: `timed out after ${Math.round(deadlineMs / 1000)}s`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
49
100
|
return {
|
|
50
101
|
ok: false,
|
|
51
102
|
status: 0,
|
|
103
|
+
failure: 'network',
|
|
52
104
|
error: err instanceof Error ? err.message : String(err),
|
|
53
105
|
};
|
|
54
106
|
}
|
|
@@ -72,7 +124,9 @@ export async function postReply(options, path, bodyKey, message, runId, extra) {
|
|
|
72
124
|
// `extra` first, so the message can never be clobbered by a `body_extra` key
|
|
73
125
|
// that happens to collide with the body field. The reply text is the one
|
|
74
126
|
// thing in this request that must survive.
|
|
75
|
-
|
|
127
|
+
// Byte-identical on every attempt: the server hashes the body against the
|
|
128
|
+
// key, so a replay must serialise exactly as the original did.
|
|
129
|
+
return post(options, path, { ...extra, [bodyKey]: message }, `prohost-cli-run-${runId}`, false, SEND_TIMEOUT_MS);
|
|
76
130
|
}
|
|
77
131
|
function booleanField(body, key) {
|
|
78
132
|
if (!body)
|
|
@@ -107,7 +161,7 @@ export async function heartbeatRun(options, path, progress) {
|
|
|
107
161
|
return { kind: 'unknown' };
|
|
108
162
|
if (result.status === 409)
|
|
109
163
|
return { kind: 'resolved' };
|
|
110
|
-
return { kind: 'error', status: result.status, detail: result.error };
|
|
164
|
+
return { kind: 'error', status: result.status, detail: result.error, failure: result.failure };
|
|
111
165
|
}
|
|
112
166
|
/**
|
|
113
167
|
* Report a run's terminal state. Called for every run — including one whose
|
|
@@ -116,12 +170,17 @@ export async function heartbeatRun(options, path, progress) {
|
|
|
116
170
|
* A ``409`` is reported as success: it means the run already reached a terminal
|
|
117
171
|
* state (a redelivery we raced, or the 2h stale-run sweeper got there first).
|
|
118
172
|
* Treating that as a failure would only produce noise about work that is done.
|
|
173
|
+
*
|
|
174
|
+
* ``model`` is the model id the run used, when the harness knows it. Omitted
|
|
175
|
+
* otherwise, so a plain ``--exec`` command's completion body is unchanged.
|
|
119
176
|
*/
|
|
120
|
-
export async function completeRun(options, path, outcome) {
|
|
177
|
+
export async function completeRun(options, path, outcome, model) {
|
|
121
178
|
const body = outcome.ok
|
|
122
179
|
? { status: RUN_STATUS_SUCCEEDED }
|
|
123
180
|
: { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
|
|
124
|
-
|
|
181
|
+
if (model)
|
|
182
|
+
body.model = model.slice(0, MAX_MODEL_CHARS);
|
|
183
|
+
const result = await post(options, path, body, undefined, false, SEND_TIMEOUT_MS);
|
|
125
184
|
if (result.status === 409)
|
|
126
185
|
return { ...result, ok: true };
|
|
127
186
|
return result;
|
package/dist/agent/claude.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* happens and still completes. These are enhancements that must fail soft.
|
|
16
16
|
*/
|
|
17
17
|
import { MCP_SERVER_NAME } from './mcp.js';
|
|
18
|
+
import type { RuntimeObservation } from './runtime_status.js';
|
|
18
19
|
export { MCP_SERVER_NAME };
|
|
19
20
|
/**
|
|
20
21
|
* What goes to `--allowedTools` so the ProhostAI tools are callable.
|
|
@@ -151,6 +152,12 @@ export interface ClaudeStream {
|
|
|
151
152
|
activity(): string | undefined;
|
|
152
153
|
/** The tail of the last thing the agent wrote, for a run that was killed. */
|
|
153
154
|
salvage(): string | undefined;
|
|
155
|
+
/**
|
|
156
|
+
* Model, Claude Code version and capacity windows the stream announced —
|
|
157
|
+
* `system/init.{model, claude_code_version}` and the last
|
|
158
|
+
* `rate_limit_event`. Fields the stream never mentioned are absent.
|
|
159
|
+
*/
|
|
160
|
+
observed(): RuntimeObservation;
|
|
154
161
|
/**
|
|
155
162
|
* The turn's outcome. ``stdout`` is the complete captured output, used only
|
|
156
163
|
* when the stream held no `result` event — a crashed binary, a login prompt,
|
package/dist/agent/claude.js
CHANGED
|
@@ -18,6 +18,7 @@ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { prohostHome } from './credentials.js';
|
|
20
20
|
import { MCP_SERVER_NAME } from './mcp.js';
|
|
21
|
+
import { fromClaudeRateLimitInfo } from './runtime_status.js';
|
|
21
22
|
import { SHELL_CONTROL_OPERATORS, shellQuote } from './shell.js';
|
|
22
23
|
export { MCP_SERVER_NAME };
|
|
23
24
|
/**
|
|
@@ -315,10 +316,21 @@ export function readClaudeStream() {
|
|
|
315
316
|
let lastActivity;
|
|
316
317
|
let lastText;
|
|
317
318
|
let result;
|
|
319
|
+
const observed = {};
|
|
318
320
|
const handle = (line) => {
|
|
319
321
|
const event = asObject(line);
|
|
320
322
|
if (!event)
|
|
321
323
|
return;
|
|
324
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
325
|
+
observed.model = text(event.model) ?? observed.model;
|
|
326
|
+
observed.runtime_version = text(event.claude_code_version) ?? observed.runtime_version;
|
|
327
|
+
}
|
|
328
|
+
else if (event.type === 'rate_limit_event') {
|
|
329
|
+
// Last one wins; absent for API-key / Bedrock / Vertex sessions.
|
|
330
|
+
const windows = fromClaudeRateLimitInfo(event.rate_limit_info);
|
|
331
|
+
if (windows.length > 0)
|
|
332
|
+
observed.quota_windows = windows;
|
|
333
|
+
}
|
|
322
334
|
// Present on the `system`/`init` event, and repeated on most others. Taken
|
|
323
335
|
// from whichever arrives first — they all name the same session.
|
|
324
336
|
if (!session)
|
|
@@ -359,6 +371,7 @@ export function readClaudeStream() {
|
|
|
359
371
|
},
|
|
360
372
|
sessionId: () => session,
|
|
361
373
|
activity: () => lastActivity,
|
|
374
|
+
observed: () => ({ ...observed }),
|
|
362
375
|
salvage: () => {
|
|
363
376
|
const trimmed = lastText?.trim();
|
|
364
377
|
if (!trimmed)
|
package/dist/agent/command.js
CHANGED
|
@@ -13,6 +13,7 @@ import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon,
|
|
|
13
13
|
import { PairError, runPair } from './pair.js';
|
|
14
14
|
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
|
|
15
15
|
import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
16
|
+
import { announceUpgradeIfAvailable } from '../upgrade.js';
|
|
16
17
|
function flagString(flags, name) {
|
|
17
18
|
const value = flags[name];
|
|
18
19
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -306,6 +307,9 @@ async function runCommand(flags) {
|
|
|
306
307
|
};
|
|
307
308
|
process.on('SIGINT', onSig);
|
|
308
309
|
process.on('SIGTERM', onSig);
|
|
310
|
+
// Deliberately not awaited: the registry lookup must never delay or fail the
|
|
311
|
+
// connection. It prints one line if a newer build exists and nothing else.
|
|
312
|
+
void announceUpgradeIfAvailable((line) => process.stdout.write(`${line}\n`));
|
|
309
313
|
try {
|
|
310
314
|
const metrics = await runAgentHarness({
|
|
311
315
|
exec,
|
package/dist/agent/contract.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare const RUN_STATUS_SUCCEEDED = "succeeded";
|
|
|
29
29
|
export declare const RUN_STATUS_FAILED = "failed";
|
|
30
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
31
31
|
export declare const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
|
|
33
|
+
export declare const MAX_MODEL_CHARS = 100;
|
|
32
34
|
/**
|
|
33
35
|
* The completion `error` that means "a person asked for this run to stop".
|
|
34
36
|
*
|
package/dist/agent/contract.js
CHANGED
|
@@ -29,6 +29,8 @@ export const RUN_STATUS_SUCCEEDED = 'succeeded';
|
|
|
29
29
|
export const RUN_STATUS_FAILED = 'failed';
|
|
30
30
|
/** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
|
|
31
31
|
export const MAX_ERROR_CHARS = 1000;
|
|
32
|
+
/** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
|
|
33
|
+
export const MAX_MODEL_CHARS = 100;
|
|
32
34
|
/**
|
|
33
35
|
* The completion `error` that means "a person asked for this run to stop".
|
|
34
36
|
*
|
package/dist/agent/heartbeat.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* failure mode — an old server that 404s, a dropped network, a handler that
|
|
22
22
|
* throws — costs at most the beat it happened on.
|
|
23
23
|
*/
|
|
24
|
-
import { heartbeatRun } from './api.js';
|
|
24
|
+
import { describeFailure, heartbeatRun } from './api.js';
|
|
25
25
|
/**
|
|
26
26
|
* How often each open run is reported.
|
|
27
27
|
*
|
|
@@ -154,8 +154,9 @@ export class HeartbeatLoop {
|
|
|
154
154
|
// Transient by assumption: keep the cadence rather than give up on the
|
|
155
155
|
// run. A run whose beats all fail is reaped server-side eventually, which
|
|
156
156
|
// is the correct end state for a machine that cannot reach the API.
|
|
157
|
-
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed
|
|
158
|
-
|
|
157
|
+
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed ` +
|
|
158
|
+
`(${describeFailure({ ok: false, status: outcome.status, error: outcome.detail, failure: outcome.failure })})` +
|
|
159
|
+
`${outcome.status !== 0 && outcome.detail ? ` ${outcome.detail}` : ''}`);
|
|
159
160
|
}
|
|
160
161
|
catch (err) {
|
|
161
162
|
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} threw: ` +
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type { spawn } from 'node:child_process';
|
|
26
26
|
import WebSocket from 'ws';
|
|
27
27
|
import type { AgentCredentials } from './credentials.js';
|
|
28
|
+
import type { QuotaProvider } from './runtime_status.js';
|
|
28
29
|
/**
|
|
29
30
|
* Default ceiling on one agent invocation: none.
|
|
30
31
|
*
|
|
@@ -121,6 +122,22 @@ export interface AgentRunOptions {
|
|
|
121
122
|
* inside a test's lifetime.
|
|
122
123
|
*/
|
|
123
124
|
heartbeatIntervalMs?: number;
|
|
125
|
+
/**
|
|
126
|
+
* How long the server may go silent before the socket is torn down and
|
|
127
|
+
* reconnected. Defaults to {@link PING_LIVENESS_MS}; tests shorten it.
|
|
128
|
+
*/
|
|
129
|
+
livenessTimeoutMs?: number;
|
|
130
|
+
/**
|
|
131
|
+
* How often a connected harness logs that it is still connected. Defaults
|
|
132
|
+
* to {@link HEARTBEAT_LOG_INTERVAL_MS}; tests shorten it.
|
|
133
|
+
*/
|
|
134
|
+
heartbeatLogIntervalMs?: number;
|
|
135
|
+
/**
|
|
136
|
+
* Where idle capacity numbers come from (see `runtime_status.ts`). Defaults
|
|
137
|
+
* to asking the detected agent CLI itself; `null` disables idle refresh.
|
|
138
|
+
* Tests inject a fake so no real `claude` / `codex` is spawned.
|
|
139
|
+
*/
|
|
140
|
+
quotaProvider?: QuotaProvider | null;
|
|
124
141
|
}
|
|
125
142
|
export interface AgentRunMetrics {
|
|
126
143
|
connections: number;
|
|
@@ -134,6 +151,8 @@ export interface AgentRunMetrics {
|
|
|
134
151
|
ignored: number;
|
|
135
152
|
/** Repeat deliveries of a run already handled this session. */
|
|
136
153
|
duplicates: number;
|
|
154
|
+
/** Sockets torn down locally because the server went silent past the liveness window. */
|
|
155
|
+
livenessTimeouts: number;
|
|
137
156
|
/**
|
|
138
157
|
* Set when the harness stopped for a reason retrying can't fix (a rejected
|
|
139
158
|
* credential). The CLI exits nonzero on it, so a service manager doesn't
|