@prohost/cli 0.4.1 → 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.
@@ -524,6 +524,67 @@ export function listMcpServers(binary, runner, overrides, cwd) {
524
524
  return null;
525
525
  }
526
526
  }
527
+ /**
528
+ * The Codex feature that supplies the `codex_apps` MCP.
529
+ *
530
+ * Its own prompt text is the definition, and it is worth quoting because
531
+ * nothing else in the CLI says it: *"An app is equivalent to a set of MCP tools
532
+ * within the `codex_apps` MCP."* Apps are the operator's **connectors** —
533
+ * account-level, not config-level.
534
+ */
535
+ export const APPS_FEATURE = 'apps';
536
+ /**
537
+ * Read one feature's *effective* state, under the overrides the run will carry.
538
+ *
539
+ * This is {@link listMcpServers}'s counterpart for the thing that listing cannot
540
+ * see, and it exists because `codex_apps` appears in **no** MCP inventory:
541
+ * `codex mcp list --json` reports the operator's servers and says nothing about
542
+ * apps, so the entire 0.4.1 verification path is blind to 28 tools the run
543
+ * actually gets.
544
+ *
545
+ * `codex features list` answers it — it prints `<name> <stage> <state>` per
546
+ * feature and honours `-c`, so asking it under the run's own overrides turns
547
+ * "did the switch take" into a measurement rather than an assumption. Measured
548
+ * at ~0.14s with no network, so it is cheap enough to run every time.
549
+ *
550
+ * Only the one requested row is read, deliberately unlike `listMcpServers`,
551
+ * which fails the whole answer on a single malformed entry. There the *set* is
552
+ * the boundary, so an unparsed member is a hole; here the question is about one
553
+ * named key, and refusing to answer because some unrelated future row gained a
554
+ * column would break every run to protect nothing. The last whitespace-separated
555
+ * token is the state — the stage column is sometimes two words (`under
556
+ * development`), so the row is read from both ends rather than by field index.
557
+ *
558
+ * Returns `null` when the answer is unknown: the command failed (a malformed
559
+ * override makes `features list` exit 1 and print nothing), the feature has no
560
+ * row, or its state is not a boolean. A renamed or retired feature therefore
561
+ * reads as unknown rather than as "off", and the caller fails the run closed.
562
+ */
563
+ export function featureState(binary, runner, feature, overrides, cwd) {
564
+ const list = overrides === undefined ? [] : typeof overrides === 'string' ? [overrides] : overrides;
565
+ const flags = list.map((value) => ` -c ${shellQuote(value, process.platform)}`).join('');
566
+ let result;
567
+ try {
568
+ result = runner(`${binary} features list${flags}`, cwd);
569
+ }
570
+ catch {
571
+ return null;
572
+ }
573
+ if (result.status !== 0)
574
+ return null;
575
+ for (const line of result.stdout.split('\n')) {
576
+ const words = line.trim().split(/\s+/);
577
+ if (words.length < 2 || words[0] !== feature)
578
+ continue;
579
+ const state = words[words.length - 1];
580
+ if (state === 'true')
581
+ return true;
582
+ if (state === 'false')
583
+ return false;
584
+ return null;
585
+ }
586
+ return null;
587
+ }
527
588
  /**
528
589
  * Disable entries, checked against the binary before they are used.
529
590
  *
@@ -544,11 +605,12 @@ export function listMcpServers(binary, runner, overrides, cwd) {
544
605
  * reachable from the run, and the caller has to say so out loud rather than
545
606
  * report an isolation it only partly achieved.
546
607
  *
547
- * `baseOverrides` are config overrides the run will also carry — in practice
548
- * {@link PLUGINS_DISABLED_OVERRIDE}. They have to be present on every probe
549
- * here, because "does this entry disable this server" has a different answer
550
- * under a different config, and the answer that matters is the one for the
551
- * config the run actually gets.
608
+ * `baseOverrides` are config overrides the run will also carry —
609
+ * {@link APPS_DISABLED_OVERRIDE} always, and {@link PLUGINS_DISABLED_OVERRIDE}
610
+ * when a plugin supplies a server. They have to be present on every probe here,
611
+ * because "does this entry disable this server" has a different answer under a
612
+ * different config, and the answer that matters is the one for the config the
613
+ * run actually gets.
552
614
  */
553
615
  export function disableEntriesThatLoad(binary, runner, targets, cwd, baseOverrides = []) {
554
616
  if (targets.length === 0)
@@ -618,6 +680,42 @@ export function disableEntriesThatLoad(binary, runner, targets, cwd, baseOverrid
618
680
  * an install whose plugins contribute no MCP servers keeps all of that.
619
681
  */
620
682
  export const PLUGINS_DISABLED_OVERRIDE = 'features.plugins=false';
683
+ /**
684
+ * The one override that keeps the operator's **apps (connectors)** out of a run.
685
+ *
686
+ * Applied to every isolated run, unconditionally, which is the whole point of
687
+ * it. Measured against `codex-cli 0.146.0`:
688
+ *
689
+ * - `codex_apps` is the {@link APPS_FEATURE} surface, and `apps` is a *stable*
690
+ * Codex feature that is **on by default**. Turning it off removes the
691
+ * `<apps_instructions>` block from the prompt and all 28 `mcp__codex_apps__*`
692
+ * tools from the run, while leaving `plugins` — and so the operator's plugin
693
+ * skills and hooks — switched on.
694
+ * - Those tools are not read-only. They include `sites_create_site`,
695
+ * `sites_deploy_site_version`, `sites_add_custom_domain`,
696
+ * `sites_update_environment_variables`, `sites_generate_siwc_bypass_token`,
697
+ * `plugin_management_update_app_permissions` and
698
+ * `plugin_management_uninstall_app`: publishing sites and rewriting app
699
+ * permissions on the operator's own ChatGPT account.
700
+ * - **Nothing in `codex mcp list --json` mentions any of it.** Apps are
701
+ * account-level and cached under `$CODEX_HOME/cache/codex_apps_tools`, not
702
+ * declared in `mcp_servers`, so the entire server-enumeration path this module
703
+ * is built on cannot see them. That is why this is unconditional rather than
704
+ * applied on evidence, as {@link PLUGINS_DISABLED_OVERRIDE} is: there is no
705
+ * listing to take evidence from.
706
+ * - It is not enough to have no plugins and no configured servers. A
707
+ * `CODEX_HOME` holding **only `auth.json`** — 0.4.1's own suggested remedy —
708
+ * fetches the account's apps on its first run, writes them to that cache, and
709
+ * serves all 28 tools from the second run onwards. Measured, not reasoned.
710
+ *
711
+ * `features.plugins=false` also happens to remove the tools on installs where
712
+ * apps arrive through the plugin pipeline, which is why 0.4.1 closed this by
713
+ * accident on plugin-bearing machines. It is the wrong lever regardless: it
714
+ * leaves `<apps_instructions>` in the prompt, it costs the operator their plugin
715
+ * skills and hooks, and it does nothing at all on a classic-only install —
716
+ * exactly the shape where 0.4.1 applies no override.
717
+ */
718
+ export const APPS_DISABLED_OVERRIDE = `features.${APPS_FEATURE}=false`;
621
719
  /**
622
720
  * Shut off every server the operator's install has, whatever supplies it.
623
721
  *
@@ -630,42 +728,64 @@ export const PLUGINS_DISABLED_OVERRIDE = 'features.plugins=false';
630
728
  * *measured* rather than read: list again with plugins off, and the names that
631
729
  * vanished were the plugin's.
632
730
  *
633
- * The classic path is tried first and, when it succeeds, returned untouched
634
- * with no extra override. So an install with no plugin-backed servers issues
635
- * byte-identical commands to the version before plugins were handled at all,
636
- * and pays exactly one probe as before.
731
+ * A **third** mechanism sits underneath both and is not a server at all: the
732
+ * operator's apps (connectors), which reach the run as the `codex_apps` MCP and
733
+ * appear in no listing this module can read. {@link APPS_DISABLED_OVERRIDE} is
734
+ * therefore applied to every run unconditionally — there is no inventory to
735
+ * decide from — and every probe below carries it, so each disable is proven
736
+ * against the config the run is actually handed.
737
+ *
738
+ * The classic path is still tried first and, when it succeeds, returned with no
739
+ * *plugins* override, so an install with no plugin-backed servers keeps its
740
+ * plugin skills and hooks and pays the same one probe it did before.
637
741
  *
638
742
  * Failing to shut a server off is reported, never worked around. A plugin
639
743
  * server that survives even `features.plugins=false` is a mechanism we do not
640
- * understand yet, and a run that cannot prove the boundary must not start.
744
+ * understand yet, and a run that cannot prove the boundary must not start. The
745
+ * apps switch is held to the same standard: it is *measured* off through
746
+ * {@link featureState} under the final override set, because an override that
747
+ * silently does nothing is the failure mode this whole module exists to catch.
641
748
  */
642
749
  export function isolateConfiguredServers(binary, runner, targets, cwd) {
643
- const direct = disableEntriesThatLoad(binary, runner, targets, cwd);
750
+ // Present on every probe from here down, and on the run itself.
751
+ const base = [APPS_DISABLED_OVERRIDE];
752
+ const prove = (isolation) => {
753
+ if (featureState(binary, runner, APPS_FEATURE, isolation.configOverrides, cwd) === false) {
754
+ return isolation;
755
+ }
756
+ return {
757
+ ...isolation,
758
+ unproven: `Codex would not confirm its "${APPS_FEATURE}" feature is off, so the operator's apps ` +
759
+ '(connectors) and their tools cannot be kept out of this run',
760
+ };
761
+ };
762
+ const direct = disableEntriesThatLoad(binary, runner, targets, cwd, base);
644
763
  if (direct.failed.length === 0)
645
- return { ...direct, configOverrides: [] };
764
+ return prove({ ...direct, configOverrides: base });
646
765
  // Something resisted. Ask whether turning the plugin system off accounts for
647
766
  // it, rather than assuming the survivors are plugin-backed because they look
648
767
  // like it.
649
- const withPluginsOff = listMcpServers(binary, runner, PLUGINS_DISABLED_OVERRIDE, cwd);
768
+ const withPluginsOff = listMcpServers(binary, runner, [...base, PLUGINS_DISABLED_OVERRIDE], cwd);
650
769
  // The override does not load, so it disables nothing and we know nothing.
651
770
  if (withPluginsOff === null)
652
- return { ...direct, configOverrides: [] };
771
+ return prove({ ...direct, configOverrides: base });
653
772
  const remaining = new Set(withPluginsOff.map((server) => server.name));
654
773
  const removedByPluginsOff = direct.failed.filter((name) => !remaining.has(name));
655
774
  // Plugins were not the problem. Report what the classic path found, so the
656
775
  // operator is told about the real survivor instead of a plugin red herring.
657
776
  if (removedByPluginsOff.length === 0)
658
- return { ...direct, configOverrides: [] };
777
+ return prove({ ...direct, configOverrides: base });
659
778
  // Re-verify the classic disables *under* the plugins-off override. They were
660
779
  // proven against a different config, and a proof about a config the run does
661
780
  // not get is not a proof.
662
781
  const classic = targets.filter((name) => remaining.has(name));
663
- const verified = disableEntriesThatLoad(binary, runner, classic, cwd, [PLUGINS_DISABLED_OVERRIDE]);
664
- return {
782
+ const configOverrides = [...base, PLUGINS_DISABLED_OVERRIDE];
783
+ const verified = disableEntriesThatLoad(binary, runner, classic, cwd, configOverrides);
784
+ return prove({
665
785
  entries: verified.entries,
666
- configOverrides: [PLUGINS_DISABLED_OVERRIDE],
786
+ configOverrides,
667
787
  failed: verified.failed,
668
- };
788
+ });
669
789
  }
670
790
  /**
671
791
  * The `codex` binary out of the host's `--exec` command.
@@ -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,
@@ -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 (HTTP ${outcome.status})` +
158
- `${outcome.detail ? ` ${outcome.detail}` : ''}`);
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: ` +
@@ -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,9 +25,9 @@
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
- import { MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
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';
32
32
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
33
33
  import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.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 (HTTP ${result.status}); retrying in ${delay / 1000}s`);
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
  *
@@ -329,7 +390,15 @@ function codexMcpEntries(options, extraServers, log, workdir) {
329
390
  if (acceptedExtraEntries.length === 0)
330
391
  acceptedExtraNames.clear();
331
392
  const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
332
- const { entries: disabled, configOverrides, failed } = isolateConfiguredServers(binary, probe, targets, workdir);
393
+ const { entries: disabled, configOverrides, failed, unproven } = isolateConfiguredServers(binary, probe, targets, workdir);
394
+ if (unproven) {
395
+ // Not an `mcp_servers` problem, so it does not get the `mcp_servers`
396
+ // remedy. Upgrading Codex is the honest advice: the switch is read back
397
+ // from `codex features list`, so this fires when that command, the feature,
398
+ // or its name is not what this harness was measured against.
399
+ return fail(`${unproven} — upgrade Codex, or run the daemon with a Codex build that reports ` +
400
+ `"${APPS_FEATURE}" in \`codex features list\``);
401
+ }
333
402
  if (failed.length > 0) {
334
403
  // The remedy belongs in the message. This string is echoed into the thread
335
404
  // the teammate is waiting in, and "could not be disabled" on its own leaves
@@ -341,6 +410,11 @@ function codexMcpEntries(options, extraServers, log, workdir) {
341
410
  if (targets.length > 0) {
342
411
  log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
343
412
  }
413
+ // Unconditional, so it is stated every run rather than only when something
414
+ // was found: the operator's connectors are account-level and invisible to
415
+ // `codex mcp list`, and "nothing was listed" is exactly the wrong thing for
416
+ // them to conclude from silence here.
417
+ log(`${timestamp()} ⚙ Codex apps (connectors) are switched off for agent runs`);
344
418
  if (configOverrides.includes(PLUGINS_DISABLED_OVERRIDE)) {
345
419
  log(`${timestamp()} ⚙ Codex plugins are switched off for agent runs — a plugin supplies an MCP ` +
346
420
  'server that cannot be disabled on its own');
@@ -715,7 +789,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
715
789
  // it, and anything wrapped around it reads as a crash instead of a stop.
716
790
  const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
717
791
  if (!closed.ok) {
718
- log(` ! could not report the cancellation (HTTP ${closed.status}) ${closed.error ?? ''}`);
792
+ log(` ! could not report the cancellation (${describeFailure(closed)})` +
793
+ `${closed.status !== 0 && closed.error ? ` ${closed.error}` : ''}`);
719
794
  return 'unreported';
720
795
  }
721
796
  return 'cancelled';
@@ -732,7 +807,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
732
807
  }
733
808
  const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep);
734
809
  if (!completed.ok) {
735
- log(` ! could not report failure (HTTP ${completed.status}) ${completed.error ?? ''}`);
810
+ log(` ! could not report failure (${describeFailure(completed)})` +
811
+ `${completed.status !== 0 && completed.error ? ` ${completed.error}` : ''}`);
736
812
  return 'unreported';
737
813
  }
738
814
  return 'failed';
@@ -759,7 +835,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
759
835
  log(` │ ${line}`);
760
836
  const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
761
837
  if (!completed.ok) {
762
- log(` ! could not mark the run complete (HTTP ${completed.status})`);
838
+ log(` ! could not mark the run complete (${describeFailure(completed)})`);
763
839
  return 'unreported';
764
840
  }
765
841
  if (result.sessionId) {
@@ -767,16 +843,20 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
767
843
  }
768
844
  return 'succeeded';
769
845
  }
770
- const posted = await postReply(api, run.reply_path, run.reply_body_key, reply, run.run_id, run.reply_body_extra);
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);
771
849
  if (!posted.ok) {
772
- log(`${timestamp()} ✗ ${label} reply rejected (HTTP ${posted.status}) ${posted.error ?? ''}`);
850
+ const failure = describeFailure(posted);
851
+ const detail = posted.status === 0 ? '' : ` ${posted.error ?? ''}`;
852
+ log(`${timestamp()} ✗ ${label} reply rejected (${failure})${detail}`);
773
853
  // Undo the mid-run recording. The session exists and the agent's transcript
774
854
  // holds this turn, but the conversation never received its answer, so
775
855
  // continuing it would skip reasoning nobody can see — the same rule that
776
856
  // used to be expressed by simply not recording until the reply landed.
777
857
  if (result.sessionRecorded)
778
858
  runtime.sessions.forget(sessionKeyFor(run));
779
- const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed with HTTP ${posted.status}` }, log, sleep);
859
+ const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep);
780
860
  return reported.ok ? 'failed' : 'unreported';
781
861
  }
782
862
  // Advance continuity only after the answer is durable on its reply surface.
@@ -791,7 +871,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
791
871
  // failure, not a lost answer — say so precisely. Returning false lets the
792
872
  // caller forget the run so a redelivery can retry the completion; the
793
873
  // reply's idempotency key stops that from posting twice.
794
- log(`${timestamp()} ! ${label} replied, but marking it complete failed (HTTP ${completed.status})`);
874
+ log(`${timestamp()} ! ${label} replied, but marking it complete failed (${describeFailure(completed)})`);
795
875
  return 'unreported';
796
876
  }
797
877
  log(`${timestamp()} ✓ ${label} replied in ${seconds}s (${reply.length} chars)`);
@@ -932,7 +1012,11 @@ export async function runAgentHarness(options) {
932
1012
  return metrics;
933
1013
  /** One socket lifetime. Resolves ``true`` when the session ever authed. */
934
1014
  function connectOnce() {
935
- const ws = new Ctor(url);
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 });
936
1020
  let authed = false;
937
1021
  return new Promise((resolve) => {
938
1022
  const close = () => {
@@ -949,7 +1033,33 @@ export async function runAgentHarness(options) {
949
1033
  else
950
1034
  options.signal.addEventListener('abort', close, { once: true });
951
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
+ });
952
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();
953
1063
  // `subscription_id` is a presence signal, not a scoping one: the server
954
1064
  // derives which subscription's events this socket receives from the
955
1065
  // connection itself. Sending it stamps `connected_from` with this
@@ -959,6 +1069,7 @@ export async function runAgentHarness(options) {
959
1069
  type: 'auth',
960
1070
  api_key: options.credentials.api_key,
961
1071
  machine,
1072
+ client_version: CLI_VERSION,
962
1073
  ...(options.credentials.subscription_id
963
1074
  ? { subscription_id: options.credentials.subscription_id }
964
1075
  : {}),
@@ -970,11 +1081,16 @@ export async function runAgentHarness(options) {
970
1081
  frame = JSON.parse(data.toString());
971
1082
  }
972
1083
  catch {
1084
+ // Unparseable, but the server sent it: still proof it is there.
1085
+ liveness.observe(undefined);
973
1086
  return;
974
1087
  }
975
- if (!frame || typeof frame !== 'object')
1088
+ if (!frame || typeof frame !== 'object') {
1089
+ liveness.observe(undefined);
976
1090
  return;
1091
+ }
977
1092
  const f = frame;
1093
+ liveness.observe(f.type);
978
1094
  if (f.type === 'auth_ok') {
979
1095
  authed = true;
980
1096
  const name = options.credentials.agent?.name ?? 'agent';
@@ -1148,6 +1264,7 @@ export async function runAgentHarness(options) {
1148
1264
  });
1149
1265
  });
1150
1266
  ws.on('close', () => {
1267
+ liveness.stop();
1151
1268
  if (options.signal)
1152
1269
  options.signal.removeEventListener('abort', close);
1153
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);