@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.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Runtime, model and remaining capacity — what `agent run` tells ProhostAI about itself.
3
+ *
4
+ * ProhostAI shows every paired AI employee with its runtime ("Claude Code"),
5
+ * model ("Opus 5.5"), machine and how much of the operator's Claude or Codex
6
+ * subscription is left. Only this machine can see those things, so the harness
7
+ * reports them in a `runtime_status` frame on the socket it already holds.
8
+ *
9
+ * **Credentials never leave this machine, and this module never reads them.**
10
+ * Every number comes from the agent CLI answering with its own login:
11
+ *
12
+ * - Claude Code: the `rate_limit_event` and `system/init` lines of a run's own
13
+ * `stream-json` output, and — when idle — the `get_usage` control request to
14
+ * an unmodified `claude -p --input-format stream-json` (no model turn).
15
+ * - Codex: the rollout file Codex writes for each run under
16
+ * `$CODEX_HOME/sessions`, and — when idle — `account/rateLimits/read` on
17
+ * `codex app-server`.
18
+ *
19
+ * Nothing here opens the Claude Code keychain item or `~/.codex/auth.json`, and
20
+ * nothing forwards an account id, email or org. Only labels, percentages and
21
+ * reset times go on the wire. Unknown or missing data is omitted, never sent as
22
+ * zero.
23
+ *
24
+ * Every parser here fails soft, in the same spirit as `readClaudeStream`: the
25
+ * shapes belong to other tools and grow without asking us.
26
+ */
27
+ import { spawn as nodeSpawn } from 'node:child_process';
28
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ /** Feature name the server lists in `auth_ok.features` when it accepts the frame. */
32
+ export const RUNTIME_STATUS_FEATURE = 'runtime_status';
33
+ /** Routine report cadence while connected. */
34
+ export const STATUS_INTERVAL_MS = 5 * 60_000;
35
+ /**
36
+ * Minimum spacing between frames. The server accepts one per 30 s per socket
37
+ * and drops the rest; a second of margin keeps clock jitter from costing us
38
+ * the frame that carried a change.
39
+ */
40
+ export const MIN_FRAME_SPACING_MS = 31_000;
41
+ /** Ask the agent CLI for fresh numbers when the last ones are this old. */
42
+ export const QUOTA_REFRESH_AFTER_MS = 15 * 60_000;
43
+ /** Ceiling on one idle quota probe. */
44
+ export const PROVIDER_TIMEOUT_MS = 10_000;
45
+ /** Percent at or above which a window counts as running low / used up. */
46
+ const LOW_PERCENT = 75;
47
+ const EXHAUSTED_PERCENT = 100;
48
+ // ── Small, defensive readers ───────────────────────────────────────────────
49
+ function asObject(value) {
50
+ return value && typeof value === 'object' && !Array.isArray(value)
51
+ ? value
52
+ : undefined;
53
+ }
54
+ function text(value) {
55
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
56
+ }
57
+ function parseLine(line) {
58
+ const trimmed = line.trim();
59
+ if (!trimmed.startsWith('{'))
60
+ return undefined;
61
+ try {
62
+ return asObject(JSON.parse(trimmed));
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /** A percentage on a 0–``scale`` scale, as 0–100 rounded to one decimal. */
69
+ export function percent(value, scale) {
70
+ if (typeof value !== 'number' || !Number.isFinite(value))
71
+ return undefined;
72
+ const pct = value * (100 / scale);
73
+ return Math.min(100, Math.max(0, Math.round(pct * 10) / 10));
74
+ }
75
+ /** Epoch seconds or an ISO string, as ISO-8601; anything else is `undefined`. */
76
+ export function isoTime(value) {
77
+ if (typeof value === 'number' && Number.isFinite(value) && value > 1e9) {
78
+ return new Date(value * 1000).toISOString();
79
+ }
80
+ if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) {
81
+ return new Date(value).toISOString();
82
+ }
83
+ return undefined;
84
+ }
85
+ /** First `X.Y.Z…` in a `--version` line, e.g. `2.1.282 (Claude Code)` or `codex-cli 0.146.0`. */
86
+ export function parseVersion(stdout) {
87
+ return /\b(\d+\.\d+\.\d+[0-9A-Za-z.+-]*)/.exec(stdout)?.[1];
88
+ }
89
+ // ── Claude Code ────────────────────────────────────────────────────────────
90
+ const CLAUDE_WINDOWS = [
91
+ ['five_hour', 'session'],
92
+ ['seven_day', 'weekly'],
93
+ ];
94
+ /**
95
+ * Windows from a stream `rate_limit_event.rate_limit_info`.
96
+ *
97
+ * `unifiedWindows.*.utilization` is a FRACTION (0–1, and it can exceed 1) with
98
+ * epoch-second resets — unlike `get_usage`, which is 0–100 with ISO strings.
99
+ * The field is marked internal upstream, so an unexpected shape yields nothing.
100
+ */
101
+ export function fromClaudeRateLimitInfo(info) {
102
+ const windows = asObject(asObject(info)?.unifiedWindows) ?? {};
103
+ return CLAUDE_WINDOWS.flatMap(([key, kind]) => {
104
+ const window = asObject(windows[key]);
105
+ const used = percent(window?.utilization, 1);
106
+ const at = isoTime(window?.resetsAt);
107
+ return used !== undefined && at ? [{ kind, used_percent: used, resets_at: at }] : [];
108
+ });
109
+ }
110
+ /** Windows from a `get_usage` control response (0–100, ISO strings). */
111
+ export function fromClaudeGetUsage(usage) {
112
+ const record = asObject(usage);
113
+ if (record?.rate_limits_available !== true)
114
+ return [];
115
+ const limits = asObject(record.rate_limits) ?? {};
116
+ return CLAUDE_WINDOWS.flatMap(([key, kind]) => {
117
+ const window = asObject(limits[key]);
118
+ const used = percent(window?.utilization, 100);
119
+ const at = isoTime(window?.resets_at);
120
+ return used !== undefined && at ? [{ kind, used_percent: used, resets_at: at }] : [];
121
+ });
122
+ }
123
+ const CLAUDE_PLANS = {
124
+ pro: 'Claude Pro',
125
+ max: 'Claude Max',
126
+ team: 'Claude Team',
127
+ enterprise: 'Claude Enterprise',
128
+ };
129
+ /** `get_usage.subscription_type` → display label; `undefined` for anything else. */
130
+ export function claudePlanLabel(subscriptionType) {
131
+ const key = text(subscriptionType)?.toLowerCase();
132
+ return key ? CLAUDE_PLANS[key] : undefined;
133
+ }
134
+ // ── Codex ──────────────────────────────────────────────────────────────────
135
+ /**
136
+ * Windows from Codex `rate_limits` (rollout, snake_case) or `rateLimits`
137
+ * (app-server, camelCase).
138
+ *
139
+ * Classified by window LENGTH, never by slot: on some plans `primary` is the
140
+ * weekly window and there is no 5-hour one at all.
141
+ */
142
+ export function fromCodexRateLimits(rateLimits, lineTimestamp) {
143
+ const record = asObject(rateLimits);
144
+ if (!record)
145
+ return [];
146
+ return [record.primary, record.secondary].flatMap((raw) => {
147
+ const window = asObject(raw);
148
+ if (!window)
149
+ return [];
150
+ const minutes = window.window_minutes ?? window.windowDurationMins;
151
+ const kind = typeof minutes !== 'number'
152
+ ? undefined
153
+ : minutes > 0 && minutes <= 360
154
+ ? 'session'
155
+ : minutes >= 8_640 && minutes <= 11_520
156
+ ? 'weekly'
157
+ : undefined;
158
+ const used = percent(window.used_percent ?? window.usedPercent, 100);
159
+ let at = isoTime(window.resets_at ?? window.resetsAt);
160
+ // Older Codex builds said "in N seconds", relative to the line it was on.
161
+ if (!at && typeof window.resets_in_seconds === 'number' && lineTimestamp) {
162
+ const base = Date.parse(lineTimestamp);
163
+ if (!Number.isNaN(base))
164
+ at = new Date(base + window.resets_in_seconds * 1000).toISOString();
165
+ }
166
+ return kind && used !== undefined && at ? [{ kind, used_percent: used, resets_at: at }] : [];
167
+ });
168
+ }
169
+ /** `plan_type` / `planType` → `ChatGPT Pro`; `undefined` when there is no plan. */
170
+ export function codexPlanLabel(planType) {
171
+ const plan = text(planType);
172
+ if (!plan)
173
+ return undefined;
174
+ return `ChatGPT ${plan.charAt(0).toUpperCase()}${plan.slice(1).toLowerCase()}`;
175
+ }
176
+ /** Model named in the exec string itself (`-m gpt-5` / `--model gpt-5` / `--model=gpt-5`). */
177
+ export function modelFromExec(exec) {
178
+ const words = exec.trim().split(/\s+/);
179
+ for (let i = 0; i < words.length; i += 1) {
180
+ const word = words[i] ?? '';
181
+ if (word === '-m' || word === '--model')
182
+ return text(words[i + 1]?.replace(/^['"]|['"]$/g, ''));
183
+ if (word.startsWith('--model='))
184
+ return text(word.slice('--model='.length).replace(/^['"]|['"]$/g, ''));
185
+ }
186
+ return undefined;
187
+ }
188
+ /** `$CODEX_HOME`, defaulting to `~/.codex` exactly as Codex does. */
189
+ export function codexHome(env = process.env) {
190
+ return env.CODEX_HOME || path.join(env.HOME || os.homedir(), '.codex');
191
+ }
192
+ /**
193
+ * The top-level `model = "…"` in `$CODEX_HOME/config.toml` — Codex's default.
194
+ *
195
+ * Only the top level, before the first `[table]`: a `model` inside a profile
196
+ * table is not the default. A first guess only; the rollout's `turn_context`
197
+ * replaces it after the first run.
198
+ */
199
+ export function codexConfiguredModel(env = process.env) {
200
+ let content;
201
+ try {
202
+ content = readFileSync(path.join(codexHome(env), 'config.toml'), 'utf8');
203
+ }
204
+ catch {
205
+ return undefined;
206
+ }
207
+ for (const line of content.split('\n')) {
208
+ const trimmed = line.trim();
209
+ if (trimmed.startsWith('['))
210
+ return undefined;
211
+ const match = /^model\s*=\s*["']([^"']+)["']/.exec(trimmed);
212
+ if (match)
213
+ return text(match[1]);
214
+ }
215
+ return undefined;
216
+ }
217
+ /**
218
+ * Locate the rollout for one thread: `sessions/YYYY/MM/DD/rollout-*-<thread_id>.jsonl`.
219
+ *
220
+ * The date directory is local time and a resumed thread lives under the day it
221
+ * started, so the date is never guessed — days are scanned newest first.
222
+ */
223
+ export function findCodexRollout(threadId, env = process.env) {
224
+ if (!/^[0-9A-Za-z-]+$/.test(threadId))
225
+ return undefined;
226
+ const suffix = `-${threadId}.jsonl`;
227
+ const root = path.join(codexHome(env), 'sessions');
228
+ const children = (dir) => {
229
+ try {
230
+ return readdirSync(dir).sort().reverse();
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ };
236
+ for (const year of children(root)) {
237
+ for (const month of children(path.join(root, year))) {
238
+ for (const day of children(path.join(root, year, month))) {
239
+ const dir = path.join(root, year, month, day);
240
+ const hit = children(dir).find((name) => name.startsWith('rollout-') && name.endsWith(suffix));
241
+ if (hit)
242
+ return path.join(dir, hit);
243
+ }
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+ /**
249
+ * What a Codex rollout says: the model the turn really used (after `-m`,
250
+ * profiles and `-c`), the CLI version, and the last rate-limit snapshot.
251
+ */
252
+ export function parseCodexRollout(content) {
253
+ const observed = {};
254
+ for (const line of content.split('\n')) {
255
+ const entry = parseLine(line);
256
+ if (!entry)
257
+ continue;
258
+ const payload = asObject(entry.payload);
259
+ if (!payload)
260
+ continue;
261
+ if (entry.type === 'session_meta') {
262
+ observed.runtime_version = text(payload.cli_version) ?? observed.runtime_version;
263
+ }
264
+ else if (entry.type === 'turn_context') {
265
+ observed.model = text(payload.model) ?? observed.model;
266
+ }
267
+ else if (entry.type === 'event_msg' && payload.type === 'token_count' && payload.rate_limits) {
268
+ // One per model response — the last one is the freshest.
269
+ observed.quota_windows = fromCodexRateLimits(payload.rate_limits, typeof entry.timestamp === 'string' ? entry.timestamp : undefined);
270
+ observed.plan_label = codexPlanLabel(asObject(payload.rate_limits)?.plan_type) ?? observed.plan_label;
271
+ }
272
+ }
273
+ return observed;
274
+ }
275
+ /** Read the rollout for a finished Codex thread, if it can be found. */
276
+ export function readCodexObservation(threadId, env = process.env) {
277
+ if (!threadId)
278
+ return undefined;
279
+ const file = findCodexRollout(threadId, env);
280
+ if (!file || !existsSync(file))
281
+ return undefined;
282
+ try {
283
+ return parseCodexRollout(readFileSync(file, 'utf8'));
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ }
289
+ /**
290
+ * Spawn ``command args``, write ``lines`` as JSONL, and resolve with the first
291
+ * stdout line ``pick`` accepts. Kills the child on answer, error or timeout.
292
+ */
293
+ function exchangeJsonLines(spawnImpl, command, args, lines, pick, timeoutMs) {
294
+ return new Promise((resolve) => {
295
+ let settled = false;
296
+ let buffer = '';
297
+ let child;
298
+ const finish = (value) => {
299
+ if (settled)
300
+ return;
301
+ settled = true;
302
+ clearTimeout(timer);
303
+ try {
304
+ child?.kill();
305
+ }
306
+ catch {
307
+ /* already gone */
308
+ }
309
+ resolve(value);
310
+ };
311
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
312
+ timer.unref?.();
313
+ try {
314
+ child = spawnImpl(command, args, { stdio: ['pipe', 'pipe', 'ignore'] });
315
+ }
316
+ catch {
317
+ finish(undefined);
318
+ return;
319
+ }
320
+ child.on('error', () => finish(undefined));
321
+ child.on('close', () => finish(undefined));
322
+ child.stdout?.setEncoding('utf8');
323
+ child.stdout?.on('data', (chunk) => {
324
+ buffer += chunk;
325
+ if (buffer.length > 1024 * 1024)
326
+ buffer = buffer.slice(-64 * 1024);
327
+ const parts = buffer.split('\n');
328
+ buffer = parts.pop() ?? '';
329
+ for (const part of parts) {
330
+ const parsed = parseLine(part);
331
+ if (!parsed)
332
+ continue;
333
+ const value = pick(parsed);
334
+ if (value !== undefined) {
335
+ finish(value);
336
+ return;
337
+ }
338
+ }
339
+ });
340
+ child.stdin?.on('error', () => {
341
+ /* child exited before reading; `close` settles */
342
+ });
343
+ child.stdin?.write(lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
344
+ });
345
+ }
346
+ /**
347
+ * Claude Code's `get_usage` control request. No model turn is made, so no
348
+ * quota is spent. The `initialize` response carries account identity; it is
349
+ * never read.
350
+ */
351
+ export function claudeQuotaProvider(options) {
352
+ return {
353
+ refresh: () => exchangeJsonLines(options.spawnImpl ?? nodeSpawn, options.binary, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], [
354
+ { type: 'control_request', request_id: 'prohost-init', request: { subtype: 'initialize' } },
355
+ {
356
+ type: 'control_request',
357
+ request_id: 'prohost-usage',
358
+ request: { subtype: 'get_usage', skip_behaviors: true },
359
+ },
360
+ ], (line) => {
361
+ if (line.type !== 'control_response')
362
+ return undefined;
363
+ const response = asObject(line.response);
364
+ if (response?.request_id !== 'prohost-usage')
365
+ return undefined;
366
+ if (response.subtype !== 'success')
367
+ return {};
368
+ const usage = asObject(response.response) ?? response;
369
+ const windows = fromClaudeGetUsage(usage);
370
+ return {
371
+ plan_label: claudePlanLabel(usage.subscription_type),
372
+ ...(windows.length > 0 ? { quota_windows: windows } : {}),
373
+ };
374
+ }, options.timeoutMs ?? PROVIDER_TIMEOUT_MS),
375
+ };
376
+ }
377
+ /** Codex's `account/rateLimits/read` over `codex app-server` stdio JSON-RPC. */
378
+ export function codexQuotaProvider(options) {
379
+ return {
380
+ refresh: () => exchangeJsonLines(options.spawnImpl ?? nodeSpawn, options.binary, ['app-server'], [
381
+ {
382
+ id: 1,
383
+ method: 'initialize',
384
+ params: { clientInfo: { name: 'prohost-cli', version: options.clientVersion } },
385
+ },
386
+ { method: 'initialized' },
387
+ { id: 2, method: 'account/rateLimits/read' },
388
+ ], (line) => {
389
+ if (line.id !== 2)
390
+ return undefined;
391
+ const rateLimits = asObject(asObject(line.result)?.rateLimits);
392
+ if (!rateLimits)
393
+ return {};
394
+ const windows = fromCodexRateLimits(rateLimits);
395
+ return {
396
+ plan_label: codexPlanLabel(rateLimits.planType),
397
+ ...(windows.length > 0 ? { quota_windows: windows } : {}),
398
+ };
399
+ }, options.timeoutMs ?? PROVIDER_TIMEOUT_MS),
400
+ };
401
+ }
402
+ // ── The reporter ───────────────────────────────────────────────────────────
403
+ /** Coarse capacity band: a change of band is worth a frame straight away. */
404
+ function band(windows) {
405
+ const top = Math.max(0, ...(windows ?? []).map((w) => w.used_percent));
406
+ return top >= EXHAUSTED_PERCENT ? 2 : top >= LOW_PERCENT ? 1 : 0;
407
+ }
408
+ function defaultSchedule(fn, ms) {
409
+ const timer = setTimeout(fn, ms);
410
+ timer.unref?.();
411
+ return () => clearTimeout(timer);
412
+ }
413
+ /**
414
+ * Holds the latest runtime snapshot and decides when to send it.
415
+ *
416
+ * Sends only while attached — i.e. only on a socket whose server advertised
417
+ * {@link RUNTIME_STATUS_FEATURE}. Cadence: once on attach, every
418
+ * {@link STATUS_INTERVAL_MS}, and promptly when the model, plan or runtime
419
+ * version changes or capacity crosses a band — but never closer together than
420
+ * {@link MIN_FRAME_SPACING_MS}.
421
+ */
422
+ export class RuntimeStatusReporter {
423
+ options;
424
+ now;
425
+ schedule;
426
+ state = {};
427
+ quotaObservedAt;
428
+ send;
429
+ lastSentAt;
430
+ cancelPending;
431
+ cancelTick;
432
+ refreshing;
433
+ constructor(options) {
434
+ this.options = options;
435
+ this.now = options.now ?? Date.now;
436
+ this.schedule = options.schedule ?? defaultSchedule;
437
+ }
438
+ /** Merge what a source learned. Absent fields keep their previous value. */
439
+ observe(observation) {
440
+ if (!observation)
441
+ return;
442
+ const before = this.state;
443
+ const next = { ...before };
444
+ if (observation.runtime_version)
445
+ next.runtime_version = observation.runtime_version;
446
+ if (observation.model)
447
+ next.model = observation.model;
448
+ if (observation.plan_label)
449
+ next.plan_label = observation.plan_label;
450
+ if (observation.quota_windows && observation.quota_windows.length > 0) {
451
+ next.quota_windows = observation.quota_windows;
452
+ this.quotaObservedAt = this.now();
453
+ }
454
+ this.state = next;
455
+ const changed = next.runtime_version !== before.runtime_version ||
456
+ next.model !== before.model ||
457
+ next.plan_label !== before.plan_label ||
458
+ band(next.quota_windows) !== band(before.quota_windows);
459
+ if (changed)
460
+ this.sendSoon();
461
+ }
462
+ /** The model to report on a run's completion, when nothing more specific is known. */
463
+ model() {
464
+ return this.state.model;
465
+ }
466
+ /** Start reporting on a socket that advertised the feature. */
467
+ attach(send) {
468
+ this.detach();
469
+ this.send = send;
470
+ this.lastSentAt = undefined;
471
+ void this.tick();
472
+ }
473
+ /** Stop reporting — the socket is gone. */
474
+ detach() {
475
+ this.send = undefined;
476
+ this.cancelPending?.();
477
+ this.cancelPending = undefined;
478
+ this.cancelTick?.();
479
+ this.cancelTick = undefined;
480
+ }
481
+ /** The frame as it would be sent now. */
482
+ frame() {
483
+ const nowMs = this.now();
484
+ // A window whose reset has passed describes a period that is over.
485
+ const windows = (this.state.quota_windows ?? []).filter((w) => Date.parse(w.resets_at) > nowMs);
486
+ return {
487
+ type: 'runtime_status',
488
+ runtime: this.options.runtime,
489
+ runtime_version: this.state.runtime_version ?? null,
490
+ model: this.state.model ?? null,
491
+ plan_label: this.state.plan_label ?? null,
492
+ quota_windows: windows,
493
+ observed_at: new Date(nowMs).toISOString(),
494
+ };
495
+ }
496
+ /** Routine tick: refresh idle quota if it is stale, send, and re-arm. */
497
+ async tick() {
498
+ if (!this.send)
499
+ return;
500
+ const stale = this.quotaObservedAt === undefined || this.now() - this.quotaObservedAt >= QUOTA_REFRESH_AFTER_MS;
501
+ const sentBefore = this.lastSentAt;
502
+ if (stale && this.options.provider && !this.options.isBusy?.()) {
503
+ await this.refresh();
504
+ }
505
+ if (!this.send)
506
+ return;
507
+ // A refresh that changed something has already sent (or queued) a frame.
508
+ if (this.lastSentAt === sentBefore)
509
+ this.sendSoon();
510
+ this.cancelTick?.();
511
+ this.cancelTick = this.schedule(() => void this.tick(), this.options.intervalMs ?? STATUS_INTERVAL_MS);
512
+ }
513
+ refresh() {
514
+ if (!this.refreshing) {
515
+ const provider = this.options.provider;
516
+ this.refreshing = (provider ? provider.refresh() : Promise.resolve(undefined))
517
+ .then((observed) => {
518
+ // `observe` would itself schedule a send on a change; the caller
519
+ // sends right after, which the spacing rule coalesces.
520
+ this.observe(observed);
521
+ })
522
+ .catch(() => {
523
+ /* fail soft — keep what we had */
524
+ })
525
+ .finally(() => {
526
+ this.refreshing = undefined;
527
+ });
528
+ }
529
+ return this.refreshing;
530
+ }
531
+ /** Send now if spacing allows, else once it does. Coalesces. */
532
+ sendSoon() {
533
+ if (!this.send || this.cancelPending)
534
+ return;
535
+ const wait = this.lastSentAt === undefined ? 0 : this.lastSentAt + MIN_FRAME_SPACING_MS - this.now();
536
+ if (wait <= 0) {
537
+ this.flush();
538
+ return;
539
+ }
540
+ this.cancelPending = this.schedule(() => {
541
+ this.cancelPending = undefined;
542
+ this.flush();
543
+ }, wait);
544
+ }
545
+ flush() {
546
+ if (!this.send)
547
+ return;
548
+ try {
549
+ this.send(this.frame());
550
+ this.lastSentAt = this.now();
551
+ }
552
+ catch (err) {
553
+ this.options.log?.(`runtime status not sent: ${err instanceof Error ? err.message : String(err)}`);
554
+ }
555
+ }
556
+ }
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);