@prohost/cli 0.4.2 → 0.5.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 +73 -0
- package/README.md +10 -0
- package/dist/agent/api.d.ts +41 -1
- package/dist/agent/api.js +60 -6
- package/dist/agent/command.js +4 -0
- package/dist/agent/heartbeat.js +4 -3
- package/dist/agent/run.d.ts +12 -0
- package/dist/agent/run.js +115 -11
- 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,79 @@ 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.5.0
|
|
8
|
+
|
|
9
|
+
A paired agent can no longer sit on a dead socket believing it is connected.
|
|
10
|
+
|
|
11
|
+
- **The failure.** One daemon (`agent run` under launchd) was "connected" for
|
|
12
|
+
three weeks and received nothing. The server had closed the socket on
|
|
13
|
+
2026-08-11 — the laptop was almost certainly asleep, the FIN never arrived —
|
|
14
|
+
and the client kernel kept the TCP connection in `ESTABLISHED`. The CLI only
|
|
15
|
+
ever reacted to `close` and `error`, answered pings with pongs, and had no
|
|
16
|
+
deadline of its own, so from where it stood the connection was open, quiet,
|
|
17
|
+
and fine. The server pings every 30 s and reaps after 60 s without a pong;
|
|
18
|
+
that contract only helps when the close actually reaches the client, and on
|
|
19
|
+
a half-open socket it never does. Nothing on either side had a reason to
|
|
20
|
+
say anything.
|
|
21
|
+
- **Client-side liveness watchdog.** Every frame the server sends — pings
|
|
22
|
+
included — is proof of life. Ninety seconds without one (three missed pings,
|
|
23
|
+
comfortably past the server's own reaper) means the connection is dead
|
|
24
|
+
whatever the kernel thinks: the CLI logs
|
|
25
|
+
`… no server ping for 90s — reconnecting`, tears the socket down with
|
|
26
|
+
`terminate()` (not `close()`, whose handshake would wait on a peer that is
|
|
27
|
+
not there), and the ordinary backoff-and-reconnect path runs. `listen` has
|
|
28
|
+
the same deadline; it has no reconnect loop, so it ends the session with
|
|
29
|
+
`… no server ping for 90s — connection lost` rather than a counter that
|
|
30
|
+
quietly stopped moving. The timer dies with the socket, so a reconnect never
|
|
31
|
+
inherits the previous socket's silence.
|
|
32
|
+
- **TCP keepalive.** Switched on for the connection under the websocket, with
|
|
33
|
+
a 30 s idle delay, as a kernel-level probe underneath the application-level
|
|
34
|
+
one. Guarded: a `ws` build that hides its socket, or none at all, simply
|
|
35
|
+
gets no keepalive rather than a crash on `open`.
|
|
36
|
+
- **A heartbeat in the log.** Every five minutes while connected, `agent run`
|
|
37
|
+
writes `● connected — last server ping Ns ago`. A stalled daemon now looks
|
|
38
|
+
different in `agent.log` from a healthy quiet one.
|
|
39
|
+
- **The build reports itself.** The websocket auth frame carries
|
|
40
|
+
`client_version` (this build's version) alongside `api_key` / `machine` /
|
|
41
|
+
`subscription_id`, and the upgrade request sends `User-Agent:
|
|
42
|
+
prohost-cli/<version>` like every HTTP call already did. The failing daemon
|
|
43
|
+
was two minor versions behind npm the whole time and nobody could tell.
|
|
44
|
+
- **An upgrade notice.** At `agent run` startup the CLI asks the npm registry
|
|
45
|
+
for the latest version, with a 3 s budget, and prints
|
|
46
|
+
`⬆ @prohost/cli <latest> is available (running <current>) — npm i -g @prohost/cli`
|
|
47
|
+
when there is one. It is not awaited and cannot fail startup: a slow, down,
|
|
48
|
+
or nonsensical registry prints nothing.
|
|
49
|
+
- **A stalled handshake is bounded too.** The deadline only starts at `open`,
|
|
50
|
+
and `ws` has no handshake timeout by default, so `agent run` now gives the
|
|
51
|
+
handshake 30 s — the same asleep-laptop failure, one step earlier.
|
|
52
|
+
|
|
53
|
+
A slow reply is no longer reported as a failed run.
|
|
54
|
+
|
|
55
|
+
- **The second failure, seen the moment the daemon reconnected.** The reply
|
|
56
|
+
`POST /v1/conversations/{id}/messages` took the server **51,216 ms** and
|
|
57
|
+
returned 202 — the message was saved — but the CLI had abandoned the request
|
|
58
|
+
at its 30 s deadline, logged `✗ run … reply rejected (HTTP 0) This operation
|
|
59
|
+
was aborted`, and completed the run as failed. The server then posted a
|
|
60
|
+
"picked this up but its run failed — posting the reply failed with HTTP 0"
|
|
61
|
+
note directly under an answer that had in fact landed. Two consecutive runs.
|
|
62
|
+
The slow send path is being fixed server-side; this release stops the client
|
|
63
|
+
turning a slow success into a reported failure.
|
|
64
|
+
- **120 s for the reply and the completion.** Cheap calls (heartbeats) keep
|
|
65
|
+
30 s. Four times the observed latency is the margin.
|
|
66
|
+
- **No answer is not a failure — replay with the same key.** The reply POST
|
|
67
|
+
was already keyed on the run id (`Idempotency-Key: prohost-cli-run-<run_id>`),
|
|
68
|
+
which is exactly what makes asking again safe. On a timeout or a network
|
|
69
|
+
error the CLI now logs `… reply POST timed out after 120s — replaying with
|
|
70
|
+
the same idempotency key` and sends the byte-identical request once more.
|
|
71
|
+
The server answers with the cached response of the attempt that landed
|
|
72
|
+
(`✓ reply confirmed on replay`), or with `409 idempotency_key_in_flight` if
|
|
73
|
+
the first attempt is still being processed — in which case the CLI waits
|
|
74
|
+
5 s and replays once more. Only a definitive answer, or a second transport
|
|
75
|
+
failure, reports the run as failed, and it does so exactly once.
|
|
76
|
+
- **`HTTP 0` is gone from the log.** A request with no response now says why:
|
|
77
|
+
`timed out after 120s` or `network error: <cause>`, in the log line and in
|
|
78
|
+
the failure reason the server shows in the thread.
|
|
79
|
+
|
|
7
80
|
## 0.4.2
|
|
8
81
|
|
|
9
82
|
Isolated Codex runs no longer receive the operator's **apps (connectors)**.
|
package/README.md
CHANGED
|
@@ -525,6 +525,16 @@ On macOS this writes a launchd agent to
|
|
|
525
525
|
`$PROHOST_HOME/agent.log` — the first place to look when the agent is quiet.
|
|
526
526
|
Re-running it with a different `--exec` replaces the old daemon.
|
|
527
527
|
|
|
528
|
+
**It notices when the connection dies, even when the network doesn't say so.**
|
|
529
|
+
The server pings every 30 seconds. If no frame of any kind arrives for 90
|
|
530
|
+
seconds — a laptop that slept through the server closing the socket looks
|
|
531
|
+
exactly like this — the CLI logs `… no server ping for 90s — reconnecting`,
|
|
532
|
+
drops the socket and reconnects with backoff. While connected it writes
|
|
533
|
+
`● connected — last server ping Ns ago` every five minutes, so a healthy quiet
|
|
534
|
+
daemon and a stalled one read differently in `agent.log`. At startup it also
|
|
535
|
+
checks npm and prints one line if a newer `@prohost/cli` exists; the check
|
|
536
|
+
never delays or fails the connection.
|
|
537
|
+
|
|
528
538
|
The daemon is scoped to `$PROHOST_HOME`, because that's where the one credential
|
|
529
539
|
lives: installing twice from the same home replaces the daemon rather than
|
|
530
540
|
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.
|
package/dist/agent/api.js
CHANGED
|
@@ -11,13 +11,52 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { USER_AGENT } from '../version.js';
|
|
13
13
|
import { MAX_ERROR_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
|
|
14
|
-
|
|
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
|
|
@@ -121,7 +175,7 @@ export async function completeRun(options, path, outcome) {
|
|
|
121
175
|
const body = outcome.ok
|
|
122
176
|
? { status: RUN_STATUS_SUCCEEDED }
|
|
123
177
|
: { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
|
|
124
|
-
const result = await post(options, path, body);
|
|
178
|
+
const result = await post(options, path, body, undefined, false, SEND_TIMEOUT_MS);
|
|
125
179
|
if (result.status === 409)
|
|
126
180
|
return { ...result, ok: true };
|
|
127
181
|
return result;
|
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/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
|
@@ -121,6 +121,16 @@ export interface AgentRunOptions {
|
|
|
121
121
|
* inside a test's lifetime.
|
|
122
122
|
*/
|
|
123
123
|
heartbeatIntervalMs?: number;
|
|
124
|
+
/**
|
|
125
|
+
* How long the server may go silent before the socket is torn down and
|
|
126
|
+
* reconnected. Defaults to {@link PING_LIVENESS_MS}; tests shorten it.
|
|
127
|
+
*/
|
|
128
|
+
livenessTimeoutMs?: number;
|
|
129
|
+
/**
|
|
130
|
+
* How often a connected harness logs that it is still connected. Defaults
|
|
131
|
+
* to {@link HEARTBEAT_LOG_INTERVAL_MS}; tests shorten it.
|
|
132
|
+
*/
|
|
133
|
+
heartbeatLogIntervalMs?: number;
|
|
124
134
|
}
|
|
125
135
|
export interface AgentRunMetrics {
|
|
126
136
|
connections: number;
|
|
@@ -134,6 +144,8 @@ export interface AgentRunMetrics {
|
|
|
134
144
|
ignored: number;
|
|
135
145
|
/** Repeat deliveries of a run already handled this session. */
|
|
136
146
|
duplicates: number;
|
|
147
|
+
/** Sockets torn down locally because the server went silent past the liveness window. */
|
|
148
|
+
livenessTimeouts: number;
|
|
137
149
|
/**
|
|
138
150
|
* Set when the harness stopped for a reason retrying can't fix (a rejected
|
|
139
151
|
* credential). The CLI exits nonzero on it, so a service manager doesn't
|
package/dist/agent/run.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import WebSocket from 'ws';
|
|
28
|
-
import { completeRun, postReply } from './api.js';
|
|
28
|
+
import { completeRun, describeFailure, isIdempotencyInFlight, postReply } from './api.js';
|
|
29
29
|
import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
|
|
30
30
|
import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
31
31
|
import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
|
|
@@ -40,6 +40,8 @@ import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
|
40
40
|
import { verifySignature } from './signature.js';
|
|
41
41
|
import { isWebhookEvent } from '../forward.js';
|
|
42
42
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
43
|
+
import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from '../liveness.js';
|
|
44
|
+
import { CLI_VERSION, USER_AGENT } from '../version.js';
|
|
43
45
|
/**
|
|
44
46
|
* Default ceiling on one agent invocation: none.
|
|
45
47
|
*
|
|
@@ -74,6 +76,14 @@ export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
|
|
|
74
76
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
75
77
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
76
78
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
79
|
+
/**
|
|
80
|
+
* Longest the websocket handshake may take before `ws` gives up on it.
|
|
81
|
+
*
|
|
82
|
+
* The liveness deadline only starts at `open`; without this, a connect that
|
|
83
|
+
* stalls mid-handshake — the same asleep-laptop shape, one step earlier —
|
|
84
|
+
* would hang with neither `open` nor `close` to move the loop along.
|
|
85
|
+
*/
|
|
86
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
77
87
|
/** Ack status codes reported back over the socket for each frame. */
|
|
78
88
|
const ACK_ACCEPTED = 200;
|
|
79
89
|
const ACK_IGNORED = 204;
|
|
@@ -88,6 +98,7 @@ function newMetrics() {
|
|
|
88
98
|
signatureRejections: 0,
|
|
89
99
|
ignored: 0,
|
|
90
100
|
duplicates: 0,
|
|
101
|
+
livenessTimeouts: 0,
|
|
91
102
|
};
|
|
92
103
|
}
|
|
93
104
|
function timestamp() {
|
|
@@ -130,12 +141,62 @@ async function completeWithRetry(api, path, outcome, log, sleep) {
|
|
|
130
141
|
const retryable = result.status === 0 || result.status === 429 || result.status >= 500;
|
|
131
142
|
if (!retryable)
|
|
132
143
|
return result;
|
|
133
|
-
log(` … completion failed (
|
|
144
|
+
log(` … completion failed (${describeFailure(result)}); retrying in ${delay / 1000}s`);
|
|
134
145
|
await sleep(delay);
|
|
135
146
|
result = await completeRun(api, path, outcome);
|
|
136
147
|
}
|
|
137
148
|
return result;
|
|
138
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* How long to wait before replaying a reply whose first attempt the server
|
|
152
|
+
* reports as still in flight. The original request is being processed at
|
|
153
|
+
* that moment; a few seconds is enough for it to finish and be cached.
|
|
154
|
+
*/
|
|
155
|
+
const REPLAY_IN_FLIGHT_DELAY_MS = 5_000;
|
|
156
|
+
/**
|
|
157
|
+
* Post the reply, and if the request dies in transit, replay it once with
|
|
158
|
+
* the same idempotency key before believing it failed.
|
|
159
|
+
*
|
|
160
|
+
* A request abandoned at its deadline is not a request that failed — it is
|
|
161
|
+
* a request whose answer we did not wait for. On 2026-09-01 the server took
|
|
162
|
+
* 51 s to accept a reply that the CLI had given up on at 30 s; the message
|
|
163
|
+
* was in the conversation, and the CLI reported the run as failed anyway.
|
|
164
|
+
* The key (``prohost-cli-run-<run_id>``) already made the send idempotent
|
|
165
|
+
* server-side, so the correct reaction to "no answer" is to ask again with
|
|
166
|
+
* the same key and let the server say what happened:
|
|
167
|
+
*
|
|
168
|
+
* - a 2xx (fresh, or the cached response of the attempt that did land) —
|
|
169
|
+
* the reply is durable, carry on as a success;
|
|
170
|
+
* - ``409 idempotency_key_in_flight`` — the first attempt is still being
|
|
171
|
+
* processed; wait, then replay once more;
|
|
172
|
+
* - anything else — a real decision or a second transport failure — is the
|
|
173
|
+
* answer, and the run is reported failed exactly once.
|
|
174
|
+
*/
|
|
175
|
+
async function postReplyWithReplay(api, send, label, log, sleep) {
|
|
176
|
+
let posted = await send();
|
|
177
|
+
// A real answer — success or a decision — is final. Only "no answer at all"
|
|
178
|
+
// earns a replay.
|
|
179
|
+
if (posted.ok || posted.status !== 0)
|
|
180
|
+
return posted;
|
|
181
|
+
const why = posted.failure === 'timeout'
|
|
182
|
+
? `reply POST ${posted.error}`
|
|
183
|
+
: `reply POST failed (${describeFailure(posted)})`;
|
|
184
|
+
log(`${timestamp()} … ${label} ${why} — replaying with the same idempotency key`);
|
|
185
|
+
posted = await send();
|
|
186
|
+
if (posted.ok) {
|
|
187
|
+
log(`${timestamp()} ✓ ${label} reply confirmed on replay`);
|
|
188
|
+
return posted;
|
|
189
|
+
}
|
|
190
|
+
if (isIdempotencyInFlight(posted)) {
|
|
191
|
+
log(`${timestamp()} … ${label} first reply attempt is still in flight server-side — ` +
|
|
192
|
+
`replaying again in ${REPLAY_IN_FLIGHT_DELAY_MS / 1000}s`);
|
|
193
|
+
await sleep(REPLAY_IN_FLIGHT_DELAY_MS);
|
|
194
|
+
posted = await send();
|
|
195
|
+
if (posted.ok)
|
|
196
|
+
log(`${timestamp()} ✓ ${label} reply confirmed on replay`);
|
|
197
|
+
}
|
|
198
|
+
return posted;
|
|
199
|
+
}
|
|
139
200
|
/**
|
|
140
201
|
* Decide where the agent command runs.
|
|
141
202
|
*
|
|
@@ -728,7 +789,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
728
789
|
// it, and anything wrapped around it reads as a crash instead of a stop.
|
|
729
790
|
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
730
791
|
if (!closed.ok) {
|
|
731
|
-
log(` ! could not report the cancellation (
|
|
792
|
+
log(` ! could not report the cancellation (${describeFailure(closed)})` +
|
|
793
|
+
`${closed.status !== 0 && closed.error ? ` ${closed.error}` : ''}`);
|
|
732
794
|
return 'unreported';
|
|
733
795
|
}
|
|
734
796
|
return 'cancelled';
|
|
@@ -745,7 +807,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
745
807
|
}
|
|
746
808
|
const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep);
|
|
747
809
|
if (!completed.ok) {
|
|
748
|
-
log(` ! could not report failure (
|
|
810
|
+
log(` ! could not report failure (${describeFailure(completed)})` +
|
|
811
|
+
`${completed.status !== 0 && completed.error ? ` ${completed.error}` : ''}`);
|
|
749
812
|
return 'unreported';
|
|
750
813
|
}
|
|
751
814
|
return 'failed';
|
|
@@ -772,7 +835,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
772
835
|
log(` │ ${line}`);
|
|
773
836
|
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
|
|
774
837
|
if (!completed.ok) {
|
|
775
|
-
log(` ! could not mark the run complete (
|
|
838
|
+
log(` ! could not mark the run complete (${describeFailure(completed)})`);
|
|
776
839
|
return 'unreported';
|
|
777
840
|
}
|
|
778
841
|
if (result.sessionId) {
|
|
@@ -780,16 +843,20 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
780
843
|
}
|
|
781
844
|
return 'succeeded';
|
|
782
845
|
}
|
|
783
|
-
const
|
|
846
|
+
const replyPath = run.reply_path;
|
|
847
|
+
const replyBodyKey = run.reply_body_key;
|
|
848
|
+
const posted = await postReplyWithReplay(api, () => postReply(api, replyPath, replyBodyKey, reply, run.run_id, run.reply_body_extra), label, log, sleep);
|
|
784
849
|
if (!posted.ok) {
|
|
785
|
-
|
|
850
|
+
const failure = describeFailure(posted);
|
|
851
|
+
const detail = posted.status === 0 ? '' : ` ${posted.error ?? ''}`;
|
|
852
|
+
log(`${timestamp()} ✗ ${label} reply rejected (${failure})${detail}`);
|
|
786
853
|
// Undo the mid-run recording. The session exists and the agent's transcript
|
|
787
854
|
// holds this turn, but the conversation never received its answer, so
|
|
788
855
|
// continuing it would skip reasoning nobody can see — the same rule that
|
|
789
856
|
// used to be expressed by simply not recording until the reply landed.
|
|
790
857
|
if (result.sessionRecorded)
|
|
791
858
|
runtime.sessions.forget(sessionKeyFor(run));
|
|
792
|
-
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed
|
|
859
|
+
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep);
|
|
793
860
|
return reported.ok ? 'failed' : 'unreported';
|
|
794
861
|
}
|
|
795
862
|
// Advance continuity only after the answer is durable on its reply surface.
|
|
@@ -804,7 +871,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
804
871
|
// failure, not a lost answer — say so precisely. Returning false lets the
|
|
805
872
|
// caller forget the run so a redelivery can retry the completion; the
|
|
806
873
|
// reply's idempotency key stops that from posting twice.
|
|
807
|
-
log(`${timestamp()} ! ${label} replied, but marking it complete failed (
|
|
874
|
+
log(`${timestamp()} ! ${label} replied, but marking it complete failed (${describeFailure(completed)})`);
|
|
808
875
|
return 'unreported';
|
|
809
876
|
}
|
|
810
877
|
log(`${timestamp()} ✓ ${label} replied in ${seconds}s (${reply.length} chars)`);
|
|
@@ -945,7 +1012,11 @@ export async function runAgentHarness(options) {
|
|
|
945
1012
|
return metrics;
|
|
946
1013
|
/** One socket lifetime. Resolves ``true`` when the session ever authed. */
|
|
947
1014
|
function connectOnce() {
|
|
948
|
-
|
|
1015
|
+
// The `User-Agent` names the build server-side, the same as every HTTP
|
|
1016
|
+
// call this CLI makes; `client_version` in the auth frame is the copy the
|
|
1017
|
+
// server reads, because a header on an upgrade request is easy to lose in
|
|
1018
|
+
// proxies and logs and a frame field is not.
|
|
1019
|
+
const ws = new Ctor(url, { headers: { 'User-Agent': USER_AGENT }, handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
|
|
949
1020
|
let authed = false;
|
|
950
1021
|
return new Promise((resolve) => {
|
|
951
1022
|
const close = () => {
|
|
@@ -962,7 +1033,33 @@ export async function runAgentHarness(options) {
|
|
|
962
1033
|
else
|
|
963
1034
|
options.signal.addEventListener('abort', close, { once: true });
|
|
964
1035
|
}
|
|
1036
|
+
// The server pings every 30 s and reaps after 60 s without a pong — but
|
|
1037
|
+
// that close never reaches a client whose kernel still believes the
|
|
1038
|
+
// connection is up (a laptop that slept through the FIN). Keep our own
|
|
1039
|
+
// deadline: no frame of any kind for the window means the socket is
|
|
1040
|
+
// dead, and tearing it down here is what makes the reconnect loop run.
|
|
1041
|
+
const liveness = new ServerLiveness({
|
|
1042
|
+
timeoutMs: options.livenessTimeoutMs,
|
|
1043
|
+
heartbeatIntervalMs: options.heartbeatLogIntervalMs,
|
|
1044
|
+
onSilence: (silentMs) => {
|
|
1045
|
+
metrics.livenessTimeouts += 1;
|
|
1046
|
+
log(`${timestamp()} … no server ping for ${seconds(silentMs)}s — reconnecting`);
|
|
1047
|
+
terminateSocket(ws);
|
|
1048
|
+
},
|
|
1049
|
+
onHeartbeat: (state) => {
|
|
1050
|
+
if (!authed)
|
|
1051
|
+
return;
|
|
1052
|
+
const since = state.sinceLastPingMs === undefined
|
|
1053
|
+
? `no server ping yet (connected ${seconds(state.sinceLastFrameMs)}s ago)`
|
|
1054
|
+
: `last server ping ${seconds(state.sinceLastPingMs)}s ago`;
|
|
1055
|
+
log(`${timestamp()} ● connected — ${since}`);
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
965
1058
|
ws.on('open', () => {
|
|
1059
|
+
// Kernel-level probe underneath the application-level one: a dead
|
|
1060
|
+
// peer is detected by the TCP stack even while the socket is idle.
|
|
1061
|
+
enableTcpKeepAlive(ws);
|
|
1062
|
+
liveness.start();
|
|
966
1063
|
// `subscription_id` is a presence signal, not a scoping one: the server
|
|
967
1064
|
// derives which subscription's events this socket receives from the
|
|
968
1065
|
// connection itself. Sending it stamps `connected_from` with this
|
|
@@ -972,6 +1069,7 @@ export async function runAgentHarness(options) {
|
|
|
972
1069
|
type: 'auth',
|
|
973
1070
|
api_key: options.credentials.api_key,
|
|
974
1071
|
machine,
|
|
1072
|
+
client_version: CLI_VERSION,
|
|
975
1073
|
...(options.credentials.subscription_id
|
|
976
1074
|
? { subscription_id: options.credentials.subscription_id }
|
|
977
1075
|
: {}),
|
|
@@ -983,11 +1081,16 @@ export async function runAgentHarness(options) {
|
|
|
983
1081
|
frame = JSON.parse(data.toString());
|
|
984
1082
|
}
|
|
985
1083
|
catch {
|
|
1084
|
+
// Unparseable, but the server sent it: still proof it is there.
|
|
1085
|
+
liveness.observe(undefined);
|
|
986
1086
|
return;
|
|
987
1087
|
}
|
|
988
|
-
if (!frame || typeof frame !== 'object')
|
|
1088
|
+
if (!frame || typeof frame !== 'object') {
|
|
1089
|
+
liveness.observe(undefined);
|
|
989
1090
|
return;
|
|
1091
|
+
}
|
|
990
1092
|
const f = frame;
|
|
1093
|
+
liveness.observe(f.type);
|
|
991
1094
|
if (f.type === 'auth_ok') {
|
|
992
1095
|
authed = true;
|
|
993
1096
|
const name = options.credentials.agent?.name ?? 'agent';
|
|
@@ -1161,6 +1264,7 @@ export async function runAgentHarness(options) {
|
|
|
1161
1264
|
});
|
|
1162
1265
|
});
|
|
1163
1266
|
ws.on('close', () => {
|
|
1267
|
+
liveness.stop();
|
|
1164
1268
|
if (options.signal)
|
|
1165
1269
|
options.signal.removeEventListener('abort', close);
|
|
1166
1270
|
resolve(authed);
|
package/dist/listen.d.ts
CHANGED
|
@@ -31,12 +31,19 @@ export interface ListenOptions {
|
|
|
31
31
|
* and the final status has been emitted.
|
|
32
32
|
*/
|
|
33
33
|
signal?: AbortSignal;
|
|
34
|
+
/**
|
|
35
|
+
* How long the server may go silent before the socket is torn down.
|
|
36
|
+
* Defaults to {@link PING_LIVENESS_MS}; tests shorten it.
|
|
37
|
+
*/
|
|
38
|
+
livenessTimeoutMs?: number;
|
|
34
39
|
}
|
|
35
40
|
export interface ListenMetrics {
|
|
36
41
|
connected: boolean;
|
|
37
42
|
eventsReceived: number;
|
|
38
43
|
eventsForwarded: number;
|
|
39
44
|
forwardErrors: number;
|
|
45
|
+
/** Set when the listener ended because the server went silent, not because it closed. */
|
|
46
|
+
livenessTimeout?: boolean;
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
42
49
|
* `/ws/webhooks` is served by **api_public**, which is `connect.prohost.ai` —
|
package/dist/listen.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { forwardEvent, isWebhookEvent } from './forward.js';
|
|
8
|
+
import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from './liveness.js';
|
|
9
|
+
import { CLI_VERSION, USER_AGENT } from './version.js';
|
|
8
10
|
/**
|
|
9
11
|
* `/ws/webhooks` is served by **api_public**, which is `connect.prohost.ai` —
|
|
10
12
|
* the same host as {@link DEFAULT_API_BASE_URL}. It is NOT on `api.prohost.ai`
|
|
@@ -35,7 +37,7 @@ export async function runListen(options) {
|
|
|
35
37
|
eventsForwarded: 0,
|
|
36
38
|
forwardErrors: 0,
|
|
37
39
|
};
|
|
38
|
-
const ws = new Ctor(url);
|
|
40
|
+
const ws = new Ctor(url, { headers: { 'User-Agent': USER_AGENT } });
|
|
39
41
|
return new Promise((resolve) => {
|
|
40
42
|
const status = () => {
|
|
41
43
|
const dot = metrics.connected ? '\u25CF' : '\u25CB';
|
|
@@ -55,11 +57,27 @@ export async function runListen(options) {
|
|
|
55
57
|
else
|
|
56
58
|
options.signal.addEventListener('abort', closeWith, { once: true });
|
|
57
59
|
}
|
|
60
|
+
// Same contract as the agent harness (see `liveness.ts`): the server's
|
|
61
|
+
// 60 s reaper cannot close a socket whose FIN never reached us, so keep a
|
|
62
|
+
// client-side deadline. `listen` has no reconnect loop \u2014 it resolves on
|
|
63
|
+
// close and the operator restarts it \u2014 so this ends the session with a
|
|
64
|
+
// reason on screen instead of a counter that silently stopped moving.
|
|
65
|
+
const liveness = new ServerLiveness({
|
|
66
|
+
timeoutMs: options.livenessTimeoutMs,
|
|
67
|
+
onSilence: (silentMs) => {
|
|
68
|
+
metrics.livenessTimeout = true;
|
|
69
|
+
onStatus(`\n\u2026 no server ping for ${seconds(silentMs)}s \u2014 connection lost\n`);
|
|
70
|
+
terminateSocket(ws);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
58
73
|
ws.on('open', () => {
|
|
74
|
+
enableTcpKeepAlive(ws);
|
|
75
|
+
liveness.start();
|
|
59
76
|
const authFrame = {
|
|
60
77
|
type: 'auth',
|
|
61
78
|
api_key: options.apiKey,
|
|
62
79
|
machine,
|
|
80
|
+
client_version: CLI_VERSION,
|
|
63
81
|
...(options.subscriptionId ? { subscription_id: options.subscriptionId } : {}),
|
|
64
82
|
};
|
|
65
83
|
ws.send(JSON.stringify(authFrame));
|
|
@@ -70,11 +88,15 @@ export async function runListen(options) {
|
|
|
70
88
|
frame = JSON.parse(data.toString());
|
|
71
89
|
}
|
|
72
90
|
catch {
|
|
91
|
+
liveness.observe(undefined);
|
|
73
92
|
return;
|
|
74
93
|
}
|
|
75
|
-
if (!frame || typeof frame !== 'object')
|
|
94
|
+
if (!frame || typeof frame !== 'object') {
|
|
95
|
+
liveness.observe(undefined);
|
|
76
96
|
return;
|
|
97
|
+
}
|
|
77
98
|
const f = frame;
|
|
99
|
+
liveness.observe(f.type);
|
|
78
100
|
if (f.type === 'auth_ok') {
|
|
79
101
|
metrics.connected = true;
|
|
80
102
|
status();
|
|
@@ -114,6 +136,7 @@ export async function runListen(options) {
|
|
|
114
136
|
}
|
|
115
137
|
});
|
|
116
138
|
ws.on('close', () => {
|
|
139
|
+
liveness.stop();
|
|
117
140
|
metrics.connected = false;
|
|
118
141
|
status();
|
|
119
142
|
resolve(metrics);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side liveness for the event-stream socket.
|
|
3
|
+
*
|
|
4
|
+
* The server pings every 30 seconds and closes a socket that has not answered
|
|
5
|
+
* for 60. That contract only helps when the close actually arrives. A laptop
|
|
6
|
+
* that sleeps loses the server's FIN, the kernel keeps the TCP connection in
|
|
7
|
+
* ESTABLISHED, and `ws` — which reacts to `close` and `error` alone — sees a
|
|
8
|
+
* socket that is open, quiet, and never coming back. One paired agent sat on
|
|
9
|
+
* exactly that for three weeks: connected as far as the client could tell,
|
|
10
|
+
* unreachable as far as the server was concerned, and nothing on either side
|
|
11
|
+
* with a reason to say so.
|
|
12
|
+
*
|
|
13
|
+
* So the client keeps its own deadline. Every frame the server sends — pings
|
|
14
|
+
* included — is a proof of life; ninety seconds without one (three missed
|
|
15
|
+
* pings, comfortably past the server's own 60 s reaper) means the connection
|
|
16
|
+
* is dead whatever the kernel thinks, and the socket is torn down locally so
|
|
17
|
+
* the ordinary reconnect path runs. TCP keepalive is switched on as a second,
|
|
18
|
+
* kernel-level probe, and a periodic heartbeat line makes a stalled daemon
|
|
19
|
+
* visible in its log instead of indistinguishable from a healthy quiet one.
|
|
20
|
+
*/
|
|
21
|
+
/** Silence tolerated before the socket is declared dead: three missed pings. */
|
|
22
|
+
export declare const PING_LIVENESS_MS = 90000;
|
|
23
|
+
/** Delay before the kernel starts sending TCP keepalive probes on an idle socket. */
|
|
24
|
+
export declare const TCP_KEEPALIVE_DELAY_MS = 30000;
|
|
25
|
+
/** How often a connected harness writes its "still here" line. */
|
|
26
|
+
export declare const HEARTBEAT_LOG_INTERVAL_MS = 300000;
|
|
27
|
+
/**
|
|
28
|
+
* The slice of a `ws` client this module touches — narrow so that the test
|
|
29
|
+
* doubles, which implement only what the harness calls, satisfy it too.
|
|
30
|
+
*/
|
|
31
|
+
export interface LivenessSocket {
|
|
32
|
+
close(): void;
|
|
33
|
+
/** `ws` has it; the handwritten test doubles may not. */
|
|
34
|
+
terminate?(): void;
|
|
35
|
+
}
|
|
36
|
+
export interface LivenessOptions {
|
|
37
|
+
/** Silence window before {@link ServerLiveness.onSilence} fires. */
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
/** Cadence of {@link ServerLiveness.onHeartbeat}. Tests shorten it. */
|
|
40
|
+
heartbeatIntervalMs?: number;
|
|
41
|
+
/** Clock, injectable so a test can age the connection without waiting. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
/** Called once when the window elapses with no frame. Receives the silence in ms. */
|
|
44
|
+
onSilence: (silentMs: number) => void;
|
|
45
|
+
/** Called on each heartbeat tick with the liveness state of the moment. */
|
|
46
|
+
onHeartbeat?: (state: LivenessState) => void;
|
|
47
|
+
}
|
|
48
|
+
export interface LivenessState {
|
|
49
|
+
/** Milliseconds since the last frame of any kind from the server. */
|
|
50
|
+
sinceLastFrameMs: number;
|
|
51
|
+
/** Milliseconds since the last `ping` — `undefined` before the first one. */
|
|
52
|
+
sinceLastPingMs: number | undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Tracks the server's frames for one socket lifetime and fires when they stop.
|
|
56
|
+
*
|
|
57
|
+
* Owns two timers: the silence deadline, re-armed on every frame, and the
|
|
58
|
+
* heartbeat interval. Both are cleared by {@link stop}, which the socket's
|
|
59
|
+
* `close` handler must call — a timer left running after a reconnect would
|
|
60
|
+
* tear down the *next* socket for the previous one's silence.
|
|
61
|
+
*/
|
|
62
|
+
export declare class ServerLiveness {
|
|
63
|
+
private readonly timeoutMs;
|
|
64
|
+
private readonly heartbeatIntervalMs;
|
|
65
|
+
private readonly now;
|
|
66
|
+
private readonly onSilence;
|
|
67
|
+
private readonly onHeartbeat;
|
|
68
|
+
private lastFrameAt;
|
|
69
|
+
private lastPingAt;
|
|
70
|
+
private deadline;
|
|
71
|
+
private heartbeat;
|
|
72
|
+
private stopped;
|
|
73
|
+
constructor(options: LivenessOptions);
|
|
74
|
+
/**
|
|
75
|
+
* Arm the deadline. Called on `open`: the handshake completing is itself the
|
|
76
|
+
* first proof of life, and `ws` has its own timeout for a handshake that
|
|
77
|
+
* never completes.
|
|
78
|
+
*/
|
|
79
|
+
start(): void;
|
|
80
|
+
/** Record a frame from the server. Any frame counts; a `ping` is also remembered as one. */
|
|
81
|
+
observe(frameType: unknown): void;
|
|
82
|
+
/** Release both timers. Idempotent; safe to call from `close` however the socket ended. */
|
|
83
|
+
stop(): void;
|
|
84
|
+
state(): LivenessState;
|
|
85
|
+
private arm;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Tear a socket down without the closing handshake.
|
|
89
|
+
*
|
|
90
|
+
* `close()` sends a close frame and then waits for the peer's — on a half-open
|
|
91
|
+
* connection that never comes, and `ws` only gives up on it after its own 30 s
|
|
92
|
+
* timer. `terminate()` destroys the underlying socket immediately, which is
|
|
93
|
+
* the right thing for a peer we have already decided is not there. The test
|
|
94
|
+
* doubles have no `terminate`, so fall back to `close`.
|
|
95
|
+
*/
|
|
96
|
+
export declare function terminateSocket(ws: LivenessSocket): void;
|
|
97
|
+
/**
|
|
98
|
+
* Switch on TCP keepalive for the connection under a `ws` client.
|
|
99
|
+
*
|
|
100
|
+
* Reaches into `ws`'s private `_socket` — there is no public accessor — so
|
|
101
|
+
* everything is guarded: a build of `ws` that renames it, or a test double
|
|
102
|
+
* that has none, simply gets no keepalive rather than a crash on `open`.
|
|
103
|
+
* Returns whether the kernel-level probe was actually enabled.
|
|
104
|
+
*/
|
|
105
|
+
export declare function enableTcpKeepAlive(ws: unknown, delayMs?: number): boolean;
|
|
106
|
+
/** Whole seconds, for log lines. */
|
|
107
|
+
export declare function seconds(ms: number): number;
|
package/dist/liveness.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side liveness for the event-stream socket.
|
|
3
|
+
*
|
|
4
|
+
* The server pings every 30 seconds and closes a socket that has not answered
|
|
5
|
+
* for 60. That contract only helps when the close actually arrives. A laptop
|
|
6
|
+
* that sleeps loses the server's FIN, the kernel keeps the TCP connection in
|
|
7
|
+
* ESTABLISHED, and `ws` — which reacts to `close` and `error` alone — sees a
|
|
8
|
+
* socket that is open, quiet, and never coming back. One paired agent sat on
|
|
9
|
+
* exactly that for three weeks: connected as far as the client could tell,
|
|
10
|
+
* unreachable as far as the server was concerned, and nothing on either side
|
|
11
|
+
* with a reason to say so.
|
|
12
|
+
*
|
|
13
|
+
* So the client keeps its own deadline. Every frame the server sends — pings
|
|
14
|
+
* included — is a proof of life; ninety seconds without one (three missed
|
|
15
|
+
* pings, comfortably past the server's own 60 s reaper) means the connection
|
|
16
|
+
* is dead whatever the kernel thinks, and the socket is torn down locally so
|
|
17
|
+
* the ordinary reconnect path runs. TCP keepalive is switched on as a second,
|
|
18
|
+
* kernel-level probe, and a periodic heartbeat line makes a stalled daemon
|
|
19
|
+
* visible in its log instead of indistinguishable from a healthy quiet one.
|
|
20
|
+
*/
|
|
21
|
+
/** Silence tolerated before the socket is declared dead: three missed pings. */
|
|
22
|
+
export const PING_LIVENESS_MS = 90_000;
|
|
23
|
+
/** Delay before the kernel starts sending TCP keepalive probes on an idle socket. */
|
|
24
|
+
export const TCP_KEEPALIVE_DELAY_MS = 30_000;
|
|
25
|
+
/** How often a connected harness writes its "still here" line. */
|
|
26
|
+
export const HEARTBEAT_LOG_INTERVAL_MS = 300_000;
|
|
27
|
+
/**
|
|
28
|
+
* Tracks the server's frames for one socket lifetime and fires when they stop.
|
|
29
|
+
*
|
|
30
|
+
* Owns two timers: the silence deadline, re-armed on every frame, and the
|
|
31
|
+
* heartbeat interval. Both are cleared by {@link stop}, which the socket's
|
|
32
|
+
* `close` handler must call — a timer left running after a reconnect would
|
|
33
|
+
* tear down the *next* socket for the previous one's silence.
|
|
34
|
+
*/
|
|
35
|
+
export class ServerLiveness {
|
|
36
|
+
timeoutMs;
|
|
37
|
+
heartbeatIntervalMs;
|
|
38
|
+
now;
|
|
39
|
+
onSilence;
|
|
40
|
+
onHeartbeat;
|
|
41
|
+
lastFrameAt;
|
|
42
|
+
lastPingAt;
|
|
43
|
+
deadline;
|
|
44
|
+
heartbeat;
|
|
45
|
+
stopped = false;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.timeoutMs = options.timeoutMs ?? PING_LIVENESS_MS;
|
|
48
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_LOG_INTERVAL_MS;
|
|
49
|
+
this.now = options.now ?? Date.now;
|
|
50
|
+
this.onSilence = options.onSilence;
|
|
51
|
+
this.onHeartbeat = options.onHeartbeat;
|
|
52
|
+
this.lastFrameAt = this.now();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Arm the deadline. Called on `open`: the handshake completing is itself the
|
|
56
|
+
* first proof of life, and `ws` has its own timeout for a handshake that
|
|
57
|
+
* never completes.
|
|
58
|
+
*/
|
|
59
|
+
start() {
|
|
60
|
+
if (this.stopped)
|
|
61
|
+
return;
|
|
62
|
+
this.lastFrameAt = this.now();
|
|
63
|
+
this.arm();
|
|
64
|
+
if (this.onHeartbeat && this.heartbeat === undefined) {
|
|
65
|
+
this.heartbeat = setInterval(() => this.onHeartbeat?.(this.state()), this.heartbeatIntervalMs);
|
|
66
|
+
this.heartbeat.unref?.();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Record a frame from the server. Any frame counts; a `ping` is also remembered as one. */
|
|
70
|
+
observe(frameType) {
|
|
71
|
+
if (this.stopped)
|
|
72
|
+
return;
|
|
73
|
+
this.lastFrameAt = this.now();
|
|
74
|
+
if (frameType === 'ping')
|
|
75
|
+
this.lastPingAt = this.lastFrameAt;
|
|
76
|
+
if (this.deadline !== undefined)
|
|
77
|
+
this.arm();
|
|
78
|
+
}
|
|
79
|
+
/** Release both timers. Idempotent; safe to call from `close` however the socket ended. */
|
|
80
|
+
stop() {
|
|
81
|
+
this.stopped = true;
|
|
82
|
+
if (this.deadline !== undefined)
|
|
83
|
+
clearTimeout(this.deadline);
|
|
84
|
+
if (this.heartbeat !== undefined)
|
|
85
|
+
clearInterval(this.heartbeat);
|
|
86
|
+
this.deadline = undefined;
|
|
87
|
+
this.heartbeat = undefined;
|
|
88
|
+
}
|
|
89
|
+
state() {
|
|
90
|
+
const now = this.now();
|
|
91
|
+
return {
|
|
92
|
+
sinceLastFrameMs: now - this.lastFrameAt,
|
|
93
|
+
sinceLastPingMs: this.lastPingAt === undefined ? undefined : now - this.lastPingAt,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
arm() {
|
|
97
|
+
if (this.deadline !== undefined)
|
|
98
|
+
clearTimeout(this.deadline);
|
|
99
|
+
this.deadline = setTimeout(() => {
|
|
100
|
+
this.deadline = undefined;
|
|
101
|
+
if (this.stopped)
|
|
102
|
+
return;
|
|
103
|
+
// Stop before firing: the callback tears the socket down, and the close
|
|
104
|
+
// handler that follows calls stop() again — harmless, but the deadline
|
|
105
|
+
// must not be re-armed by a frame that races the teardown.
|
|
106
|
+
this.stop();
|
|
107
|
+
this.onSilence(this.now() - this.lastFrameAt);
|
|
108
|
+
}, this.timeoutMs);
|
|
109
|
+
this.deadline.unref?.();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Tear a socket down without the closing handshake.
|
|
114
|
+
*
|
|
115
|
+
* `close()` sends a close frame and then waits for the peer's — on a half-open
|
|
116
|
+
* connection that never comes, and `ws` only gives up on it after its own 30 s
|
|
117
|
+
* timer. `terminate()` destroys the underlying socket immediately, which is
|
|
118
|
+
* the right thing for a peer we have already decided is not there. The test
|
|
119
|
+
* doubles have no `terminate`, so fall back to `close`.
|
|
120
|
+
*/
|
|
121
|
+
export function terminateSocket(ws) {
|
|
122
|
+
try {
|
|
123
|
+
if (typeof ws.terminate === 'function')
|
|
124
|
+
ws.terminate();
|
|
125
|
+
else
|
|
126
|
+
ws.close();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* already gone */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Switch on TCP keepalive for the connection under a `ws` client.
|
|
134
|
+
*
|
|
135
|
+
* Reaches into `ws`'s private `_socket` — there is no public accessor — so
|
|
136
|
+
* everything is guarded: a build of `ws` that renames it, or a test double
|
|
137
|
+
* that has none, simply gets no keepalive rather than a crash on `open`.
|
|
138
|
+
* Returns whether the kernel-level probe was actually enabled.
|
|
139
|
+
*/
|
|
140
|
+
export function enableTcpKeepAlive(ws, delayMs = TCP_KEEPALIVE_DELAY_MS) {
|
|
141
|
+
try {
|
|
142
|
+
const socket = ws
|
|
143
|
+
._socket;
|
|
144
|
+
if (!socket || typeof socket.setKeepAlive !== 'function')
|
|
145
|
+
return false;
|
|
146
|
+
socket.setKeepAlive(true, delayMs);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Whole seconds, for log lines. */
|
|
154
|
+
export function seconds(ms) {
|
|
155
|
+
return Math.round(ms / 1000);
|
|
156
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort "a newer build exists" notice for `agent run`.
|
|
3
|
+
*
|
|
4
|
+
* A daemon installed with `npm i -g` never upgrades itself, and nothing in the
|
|
5
|
+
* product can tell an operator which build their machine is on: the one that
|
|
6
|
+
* sat on a dead socket for three weeks was two minor versions behind the
|
|
7
|
+
* registry the whole time. The auth frame now reports the version server-side;
|
|
8
|
+
* this is the client-side half — one registry lookup at startup, printed as a
|
|
9
|
+
* single line when it finds something newer, and silent on every other
|
|
10
|
+
* outcome. It never delays the connection and cannot fail startup: the
|
|
11
|
+
* registry being slow, down, or answering nonsense is not the operator's
|
|
12
|
+
* problem and must not become one.
|
|
13
|
+
*/
|
|
14
|
+
export declare const NPM_LATEST_URL = "https://registry.npmjs.org/@prohost/cli/latest";
|
|
15
|
+
/** Longest the registry gets. The socket does not wait on this. */
|
|
16
|
+
export declare const UPGRADE_CHECK_TIMEOUT_MS = 3000;
|
|
17
|
+
export interface UpgradeCheckOptions {
|
|
18
|
+
/** The running build. Defaults to {@link CLI_VERSION}; tests pin it. */
|
|
19
|
+
currentVersion?: string;
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
registryUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compare two `MAJOR.MINOR.PATCH[-prerelease]` strings the way npm orders them
|
|
26
|
+
* for the cases this check meets: numeric identifiers compared as numbers,
|
|
27
|
+
* missing ones as zero, and a prerelease sorting *before* its release. Returns
|
|
28
|
+
* a negative number when `a` is older, positive when newer, `0` when equal.
|
|
29
|
+
* Not a full semver implementation — build metadata and prerelease ordering
|
|
30
|
+
* are not needed to answer "is the registry ahead of me".
|
|
31
|
+
*/
|
|
32
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
33
|
+
/**
|
|
34
|
+
* Ask the registry for the latest published version.
|
|
35
|
+
*
|
|
36
|
+
* Resolves to the version string when it is strictly newer than the running
|
|
37
|
+
* build, and `undefined` for everything else — up to date, ahead of the
|
|
38
|
+
* registry (a local build), a timeout, a non-2xx, a malformed body. It never
|
|
39
|
+
* rejects.
|
|
40
|
+
*/
|
|
41
|
+
export declare function newerVersionAvailable(options?: UpgradeCheckOptions): Promise<string | undefined>;
|
|
42
|
+
/** The one line printed when a newer build exists. */
|
|
43
|
+
export declare function upgradeNotice(latest: string, current?: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Run the check without waiting for it: prints the notice if and when one is
|
|
46
|
+
* due, and otherwise does nothing. The returned promise is for tests; callers
|
|
47
|
+
* on the startup path drop it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function announceUpgradeIfAvailable(write: (line: string) => void, options?: UpgradeCheckOptions): Promise<void>;
|
package/dist/upgrade.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort "a newer build exists" notice for `agent run`.
|
|
3
|
+
*
|
|
4
|
+
* A daemon installed with `npm i -g` never upgrades itself, and nothing in the
|
|
5
|
+
* product can tell an operator which build their machine is on: the one that
|
|
6
|
+
* sat on a dead socket for three weeks was two minor versions behind the
|
|
7
|
+
* registry the whole time. The auth frame now reports the version server-side;
|
|
8
|
+
* this is the client-side half — one registry lookup at startup, printed as a
|
|
9
|
+
* single line when it finds something newer, and silent on every other
|
|
10
|
+
* outcome. It never delays the connection and cannot fail startup: the
|
|
11
|
+
* registry being slow, down, or answering nonsense is not the operator's
|
|
12
|
+
* problem and must not become one.
|
|
13
|
+
*/
|
|
14
|
+
import { CLI_VERSION, USER_AGENT } from './version.js';
|
|
15
|
+
export const NPM_LATEST_URL = 'https://registry.npmjs.org/@prohost/cli/latest';
|
|
16
|
+
/** Longest the registry gets. The socket does not wait on this. */
|
|
17
|
+
export const UPGRADE_CHECK_TIMEOUT_MS = 3_000;
|
|
18
|
+
/**
|
|
19
|
+
* Compare two `MAJOR.MINOR.PATCH[-prerelease]` strings the way npm orders them
|
|
20
|
+
* for the cases this check meets: numeric identifiers compared as numbers,
|
|
21
|
+
* missing ones as zero, and a prerelease sorting *before* its release. Returns
|
|
22
|
+
* a negative number when `a` is older, positive when newer, `0` when equal.
|
|
23
|
+
* Not a full semver implementation — build metadata and prerelease ordering
|
|
24
|
+
* are not needed to answer "is the registry ahead of me".
|
|
25
|
+
*/
|
|
26
|
+
export function compareVersions(a, b) {
|
|
27
|
+
const parse = (v) => {
|
|
28
|
+
const [core = '', prerelease] = v.trim().replace(/^v/, '').split('-', 2);
|
|
29
|
+
const parts = core.split('.').map((p) => {
|
|
30
|
+
const n = Number.parseInt(p, 10);
|
|
31
|
+
return Number.isFinite(n) ? n : 0;
|
|
32
|
+
});
|
|
33
|
+
return { parts, prerelease: prerelease !== undefined && prerelease !== '' };
|
|
34
|
+
};
|
|
35
|
+
const left = parse(a);
|
|
36
|
+
const right = parse(b);
|
|
37
|
+
for (let i = 0; i < Math.max(left.parts.length, right.parts.length); i += 1) {
|
|
38
|
+
const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0);
|
|
39
|
+
if (diff !== 0)
|
|
40
|
+
return diff;
|
|
41
|
+
}
|
|
42
|
+
if (left.prerelease === right.prerelease)
|
|
43
|
+
return 0;
|
|
44
|
+
return left.prerelease ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Ask the registry for the latest published version.
|
|
48
|
+
*
|
|
49
|
+
* Resolves to the version string when it is strictly newer than the running
|
|
50
|
+
* build, and `undefined` for everything else — up to date, ahead of the
|
|
51
|
+
* registry (a local build), a timeout, a non-2xx, a malformed body. It never
|
|
52
|
+
* rejects.
|
|
53
|
+
*/
|
|
54
|
+
export async function newerVersionAvailable(options = {}) {
|
|
55
|
+
const current = options.currentVersion ?? CLI_VERSION;
|
|
56
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? UPGRADE_CHECK_TIMEOUT_MS);
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetchImpl(options.registryUrl ?? NPM_LATEST_URL, {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
});
|
|
66
|
+
if (response.status < 200 || response.status >= 300)
|
|
67
|
+
return undefined;
|
|
68
|
+
const body = await response.json();
|
|
69
|
+
const latest = body && typeof body === 'object' ? body.version : undefined;
|
|
70
|
+
if (typeof latest !== 'string' || !/^\d+\.\d+\.\d+/.test(latest))
|
|
71
|
+
return undefined;
|
|
72
|
+
return compareVersions(latest, current) > 0 ? latest : undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The one line printed when a newer build exists. */
|
|
82
|
+
export function upgradeNotice(latest, current = CLI_VERSION) {
|
|
83
|
+
return `⬆ @prohost/cli ${latest} is available (running ${current}) — npm i -g @prohost/cli`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Run the check without waiting for it: prints the notice if and when one is
|
|
87
|
+
* due, and otherwise does nothing. The returned promise is for tests; callers
|
|
88
|
+
* on the startup path drop it.
|
|
89
|
+
*/
|
|
90
|
+
export function announceUpgradeIfAvailable(write, options = {}) {
|
|
91
|
+
return newerVersionAvailable(options)
|
|
92
|
+
.then((latest) => {
|
|
93
|
+
if (latest)
|
|
94
|
+
write(upgradeNotice(latest, options.currentVersion ?? CLI_VERSION));
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {
|
|
97
|
+
/* never surfaces — see the module comment */
|
|
98
|
+
});
|
|
99
|
+
}
|
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.5.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.5.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.5.0';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|