viber-channel 0.8.19 → 0.8.21

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.
@@ -19,7 +19,8 @@ import { randomBytes } from "node:crypto";
19
19
  import { join } from "node:path";
20
20
  import { cfAccessHeaders } from "./cfAccess.js";
21
21
  import { isTrustedViberOrigin } from "./urls.js";
22
- import { reconcileOnce } from "./runner_exec.js";
22
+ import { type ExecDeps, reconcileOnce } from "./runner_exec.js";
23
+ import { runRosterBeat } from "./runner_roster.js";
23
24
 
24
25
  /** Remove leftover temp spec files (`.viber/runner-specs/`) from a prior crash
25
26
  * at daemon start — they may hold sensitive rolePrompts (Codex P1-1). The
@@ -37,10 +38,25 @@ interface RunnerAuth {
37
38
  runner_id: string;
38
39
  runner_token: string;
39
40
  machine_fingerprint: string;
41
+ /** #627: the enrolled web host (absent before #627). */
42
+ identity_base_url?: string;
43
+ }
44
+
45
+ /** #632: what the app's notification-area icon shows. */
46
+ export interface RunnerHooks {
47
+ /** true once the control stream is open, false whenever it closes. */
48
+ onConnection?: (up: boolean) => void;
40
49
  }
41
50
 
42
51
  const HEARTBEAT_INTERVAL_MS = 30_000;
43
- const RECONCILE_INTERVAL_MS = 60_000; // periodic safety-net pull (C6)
52
+ /**
53
+ * Periodic safety-net pull (C6). #627 (JP): commands travel by SSE — the runner already
54
+ * gets a `spawn_pending` wake-poke, and it pulls at EVERY (re)connect. What is left for
55
+ * this timer is the one case the stream cannot report: an SSE that LOOKS open but is
56
+ * dead behind the tunnel or a proxy. 10 min bounds that case for almost nothing, where
57
+ * 60 s cost a Worker request a minute per machine.
58
+ */
59
+ export const RECONCILE_INTERVAL_MS = 10 * 60_000;
44
60
  const RECONNECT_BASE_MS = 1_000;
45
61
  const RECONNECT_MAX_MS = 30_000;
46
62
 
@@ -92,11 +108,11 @@ let reconciling = false;
92
108
  * executor. Coalesced + self-contained error handling so a reconcile failure
93
109
  * NEVER propagates into the SSE stream's reconnect/backoff logic.
94
110
  */
95
- async function safeReconcile(auth: RunnerAuth): Promise<void> {
111
+ async function safeReconcile(auth: RunnerAuth, deps: ExecDeps = {}): Promise<void> {
96
112
  if (reconciling) return;
97
113
  reconciling = true;
98
114
  try {
99
- const handled = await reconcileOnce(auth);
115
+ const handled = await reconcileOnce(auth, deps);
100
116
  if (handled > 0) process.stderr.write(`[runner] reconcile: handled ${handled} command(s)\n`);
101
117
  } catch (err) {
102
118
  process.stderr.write(`[runner] reconcile error (non-fatal): ${String(err)}\n`);
@@ -107,32 +123,31 @@ async function safeReconcile(auth: RunnerAuth): Promise<void> {
107
123
 
108
124
  /** Heartbeat. Returns "revoked" on a 401 (the runner was revoked) so the caller
109
125
  * can tear down mid-stream (revocation is otherwise only seen at reconnect —
110
- * Opus/Codex review). Any other failure is swallowed (best-effort presence). */
126
+ * Opus/Codex review). Any other failure is swallowed (best-effort presence).
127
+ *
128
+ * #627: the beat now carries the machine's ROSTER — the only presence signal of
129
+ * the agents this runner vouches for (`runner_roster.ts`). */
111
130
  async function sendHeartbeat(auth: RunnerAuth): Promise<"ok" | "revoked"> {
112
- try {
113
- const resp = await fetch(`${auth.base_url}/api/runners/${auth.runner_id}/heartbeat`, {
114
- method: "POST",
115
- // redirect:"error" — never follow a redirect while carrying the bearer, so
116
- // a tampered base_url / hostile redirect can't exfiltrate it (Codex review).
117
- redirect: "error",
118
- headers: {
119
- Authorization: `Bearer ${auth.runner_token}`,
120
- "Content-Type": "application/json",
121
- ...cfAccessHeaders(),
122
- },
123
- body: JSON.stringify({
124
- capabilities: { runtimes: ["claude-code", "codex", "gemma"], platform: process.platform, version: 1 },
125
- }),
126
- });
127
- return resp.status === 401 ? "revoked" : "ok";
128
- } catch {
129
- /* best-effort — durable presence is the server's last_seen; a missed beat is fine */
130
- return "ok";
131
- }
131
+ return runRosterBeat(auth, { headers: cfAccessHeaders });
132
132
  }
133
133
 
134
- function sleep(ms: number): Promise<void> {
135
- return new Promise((resolve) => setTimeout(resolve, ms));
134
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
135
+ // #632: abortable, so the app's Quit does not wait out a reconnect backoff of up to 30 s
136
+ // (`632-review-codex`).
137
+ // The abort listener is REMOVED when the sleep ends normally: otherwise every reconnect of a
138
+ // long-lived runner would leave one more on the signal (`632-review-codex`).
139
+ return new Promise((resolve) => {
140
+ if (signal?.aborted) return resolve();
141
+ const onAbort = (): void => {
142
+ clearTimeout(t);
143
+ resolve();
144
+ };
145
+ const t = setTimeout(() => {
146
+ signal?.removeEventListener("abort", onAbort);
147
+ resolve();
148
+ }, ms);
149
+ signal?.addEventListener("abort", onAbort, { once: true });
150
+ });
136
151
  }
137
152
 
138
153
  /**
@@ -142,6 +157,9 @@ function sleep(ms: number): Promise<void> {
142
157
  */
143
158
  export async function runPersistentRunnerStream(
144
159
  cwd: string = process.cwd(),
160
+ deps: ExecDeps = {},
161
+ signal?: AbortSignal,
162
+ hooks: RunnerHooks = {},
145
163
  ): Promise<void> {
146
164
  const auth = loadRunnerAuth(cwd);
147
165
  cleanStaleSpecFiles(cwd); // drop crash-leftover temp specs before we start
@@ -168,11 +186,16 @@ export async function runPersistentRunnerStream(
168
186
  let reconcileTimer: ReturnType<typeof setTimeout>;
169
187
  const scheduleReconcile = (): void => {
170
188
  reconcileTimer = setTimeout(() => {
171
- void safeReconcile(auth);
189
+ void safeReconcile(auth, deps);
172
190
  if (!stopped) scheduleReconcile();
173
191
  }, jitter(RECONCILE_INTERVAL_MS));
174
192
  };
175
193
  scheduleReconcile();
194
+ // #632: the app's Quit. Unwinds the loop the way a revocation does, minus the message.
195
+ signal?.addEventListener("abort", () => {
196
+ stopped = true;
197
+ currentAbort?.abort();
198
+ });
176
199
 
177
200
  let backoff = RECONNECT_BASE_MS;
178
201
  try {
@@ -181,13 +204,14 @@ export async function runPersistentRunnerStream(
181
204
  // Reconcile UNCONDITIONALLY before/at each (re)connect — a poke missed
182
205
  // while disconnected is recovered here (C6). safeReconcile is coalesced +
183
206
  // self-contained (its errors never trigger the SSE backoff below).
184
- await safeReconcile(auth);
207
+ await safeReconcile(auth, deps);
185
208
  // Close the tiny window where a heartbeat-detected revocation fired
186
209
  // against the PREVIOUS (now-null) controller: bail before opening a new
187
210
  // stream (Opus nit).
188
211
  if (stopped) break;
189
212
  currentAbort = new AbortController();
190
- const terminal = await streamOnce(auth, currentAbort.signal);
213
+ const terminal = await streamOnce(auth, currentAbort.signal, deps, hooks);
214
+ hooks.onConnection?.(false);
191
215
  if (terminal || revoked) {
192
216
  process.stderr.write("[runner] runner revoked (401) — re-enroll. Stopping.\n");
193
217
  stopped = true;
@@ -196,11 +220,12 @@ export async function runPersistentRunnerStream(
196
220
  backoff = RECONNECT_BASE_MS; // clean close → reset backoff
197
221
  // Floor between reconnections on a CLEAN close, so a server that
198
222
  // accepts-then-immediately-closes (200 → done) can't spin a tight loop.
199
- if (!stopped) await sleep(RECONNECT_BASE_MS);
223
+ if (!stopped) await sleep(RECONNECT_BASE_MS, signal);
200
224
  } catch (err) {
201
- if (revoked) break; // abort was our own revocation teardown, not an error
225
+ hooks.onConnection?.(false);
226
+ if (revoked || stopped) break; // abort was our own teardown (revocation or Quit), not an error
202
227
  process.stderr.write(`[runner] stream error: ${String(err)} — reconnecting in ${backoff}ms\n`);
203
- await sleep(backoff); // error path: single backoff sleep (no double floor)
228
+ await sleep(backoff, signal); // error path: single backoff sleep (no double floor)
204
229
  backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
205
230
  }
206
231
  }
@@ -215,7 +240,12 @@ export async function runPersistentRunnerStream(
215
240
  * terminal), false on a clean/recoverable close (caller reconnects). Each `spawn`
216
241
  * wake-poke triggers a reconcile-pull; the event itself carries no authority.
217
242
  */
218
- async function streamOnce(auth: RunnerAuth, signal: AbortSignal): Promise<boolean> {
243
+ async function streamOnce(
244
+ auth: RunnerAuth,
245
+ signal: AbortSignal,
246
+ deps: ExecDeps = {},
247
+ hooks: RunnerHooks = {},
248
+ ): Promise<boolean> {
219
249
  const resp = await fetch(`${auth.base_url}/api/runners/${auth.runner_id}/events`, {
220
250
  method: "GET",
221
251
  redirect: "error", // never follow a redirect carrying the bearer (Codex review)
@@ -231,6 +261,7 @@ async function streamOnce(auth: RunnerAuth, signal: AbortSignal): Promise<boolea
231
261
  if (!resp.ok || !resp.body) {
232
262
  throw new Error(`control stream HTTP ${resp.status}`);
233
263
  }
264
+ hooks.onConnection?.(true);
234
265
 
235
266
  const reader = resp.body.getReader();
236
267
  const decoder = new TextDecoder();
@@ -252,7 +283,7 @@ async function streamOnce(auth: RunnerAuth, signal: AbortSignal): Promise<boolea
252
283
  // event is a HINT; the durable table is the truth. safeReconcile is
253
284
  // coalesced + self-contained (never throws into this stream).
254
285
  if (event && event !== "ping" && event !== "connected") {
255
- await safeReconcile(auth);
286
+ await safeReconcile(auth, deps);
256
287
  }
257
288
  }
258
289
  }
package/lib/urls.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  * Introduced in #221 step-05 to replace the legacy /api/voice-events endpoint.
9
9
  */
10
10
  export function buildSseUrl(voiceBaseUrl: string, conversationId: string): string {
11
- return `${voiceBaseUrl}/api/conversations/${conversationId}/events`;
11
+ // #627: `presence=runner` — the connect is no liveness proof for an agent of this
12
+ // version; only its runner's roster brings it online.
13
+ return `${voiceBaseUrl}/api/conversations/${conversationId}/events?presence=runner`;
12
14
  }
13
15
 
14
16
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.19",
3
+ "version": "0.8.21",
4
4
  "description": "Voice + text MCP channel between a Claude Code session and the Viber UI (https://viber.dgypx.dev). Push transcripts to Claude; send_message tool delivers text back to the UI.",
5
5
  "type": "module",
6
6
  "bin": {
package/viber-channel.ts CHANGED
@@ -51,13 +51,11 @@ import {
51
51
  ExplicitTargetUnavailableError,
52
52
  type ConversationMintResponse,
53
53
  } from "./lib/conversation.ts";
54
- import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
54
+ import { channelPauseReason, type PresenceGate, startCoverageWatch } from "./lib/coverage_watch.ts";
55
+ import { type PresenceEndReason, type PresenceRecordHandle, startPresenceRecord } from "./lib/presence_record.ts";
55
56
  import { startParentWatchdog } from "./lib/parent_watchdog.ts";
56
57
  import { acquireInstance, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
57
- import {
58
- runPersistentControlStream,
59
- sendInstanceHeartbeat,
60
- } from "./lib/control_stream.ts";
58
+ import { runPersistentControlStream } from "./lib/control_stream.ts";
61
59
  import { type DmWatermark, runDmStream } from "./lib/dm_stream.ts";
62
60
  import { isOwnMessage } from "./lib/self_echo.ts";
63
61
  import {
@@ -329,6 +327,32 @@ let scheduler: TokenRefreshScheduler | null = null;
329
327
  // the gate can observe an in-flight shutdown and abort conversation creation.
330
328
  let shuttingDown = false;
331
329
 
330
+ // #627: this agent's local presence record, read by the machine's runner. Each
331
+ // exit path records HOW it ended; the runner alone decides what that means
332
+ // (closed or down). A death that skips these paths leaves the record active.
333
+ let presence: PresenceRecordHandle | undefined;
334
+ function endPresence(reason: PresenceEndReason): void {
335
+ presence?.markEnded(reason);
336
+ }
337
+ // #627: the pause gate. Until the runner covers this agent, it neither sends nor
338
+ // delivers anything (it STARTS paused); see lib/coverage_watch.ts.
339
+ let presenceIdentity: "pending" | "none" | PresenceGate = "pending";
340
+ function pauseReason(): string | null {
341
+ return channelPauseReason(presenceIdentity);
342
+ }
343
+ let resolveGateReady: () => void = () => {};
344
+ const gateReady = new Promise<void>((r) => {
345
+ resolveGateReady = r;
346
+ });
347
+ /** Inbound delivery waits while paused: DEFERRED, never dropped (#627). */
348
+ async function waitDeliverable(what: string): Promise<void> {
349
+ if (pauseReason() !== null) {
350
+ process.stderr.write(`[viber-channel] paused (${pauseReason()}): ${what} deferred\n`);
351
+ }
352
+ await gateReady;
353
+ if (presenceIdentity !== "pending" && presenceIdentity !== "none") await presenceIdentity.waitActive();
354
+ }
355
+
332
356
  // Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
333
357
  process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
334
358
  // NOTE: shuttingDown is NOT set here. SIGINT/SIGTERM call process.exit(0) synchronously,
@@ -336,8 +360,8 @@ process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
336
360
  // Only the stdin `end` handler sets shuttingDown=true, because it does NOT call
337
361
  // process.exit() synchronously — it fires before the MCP transport's read loop begins,
338
362
  // giving the gate's setTimeout a chance to run and check the flag.
339
- process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
340
- process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
363
+ process.on("SIGINT", () => { endPresence("signal"); scheduler?.cancel(); releaseLock(); process.exit(0); });
364
+ process.on("SIGTERM", () => { endPresence("signal"); scheduler?.cancel(); releaseLock(); process.exit(0); });
341
365
 
342
366
  // stdin EOF — Claude Code closes its end of the pipe when the session
343
367
  // terminates (or the parent process is killed via TerminateProcess on
@@ -355,6 +379,7 @@ process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0
355
379
  process.stdin.on("end", () => {
356
380
  shuttingDown = true;
357
381
  process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
382
+ endPresence("stdin-end");
358
383
  scheduler?.cancel();
359
384
  releaseLock();
360
385
  process.exit(0);
@@ -452,6 +477,7 @@ const channelToolsCtx: AgentToolsContext = {
452
477
  // send_message posts into the main conversation for the Claude MCP.
453
478
  sendConversationId: () => CONVERSATION_ID,
454
479
  sendConversationToken: () => CONVERSATION_TOKEN,
480
+ pauseReason: () => pauseReason(),
455
481
  // Stream the opened DM so the peer's reply surfaces here (deduped by the Map;
456
482
  // a re-join replaces the stream). ws_url is the main conversation's voice host.
457
483
  startDmStream: (dm) =>
@@ -782,6 +808,13 @@ try {
782
808
  // conversation stays bound to its own SSE, option A). A `stop` (revoke) is
783
809
  // logged only: the conversation-token path already fails closed.
784
810
  if (OWN_INSTANCE_ID !== "") {
811
+ presence = startPresenceRecord({
812
+ identityBaseUrl: WEB_URL,
813
+ instanceId: OWN_INSTANCE_ID,
814
+ label: process.env.VIBER_CHANNEL_LABEL ?? null,
815
+ projectId: auth.project_id,
816
+ getConversationId: () => CONVERSATION_ID || null,
817
+ });
785
818
  const controlStream = runPersistentControlStream(
786
819
  { getBaseUrl: apiBase, instanceId: OWN_INSTANCE_ID, instanceToken },
787
820
  {
@@ -789,38 +822,42 @@ try {
789
822
  onJoin: (pushed) => startDmStream(pushed),
790
823
  onStop: (reason) =>
791
824
  process.stderr.write(`[viber-channel] control stream stopped (${reason})\n`),
792
- // #311: the server is probing — answer with an immediate instance heartbeat.
793
- onBeatNow: () => {
794
- void sendInstanceHeartbeat(apiBase(), OWN_INSTANCE_ID, INSTANCE_TOKEN);
795
- },
825
+ // #627: this agent never beats on the network — its runner answers for it
826
+ // (Python no longer probes a runner-covered agent; an old server may still).
827
+ onBeatNow: () => {},
796
828
  },
797
829
  );
798
830
  // Presence-only consumer: firstJoin ALWAYS settles (rejected at loop end) —
799
831
  // observe it so an ignored join/stop never becomes an unhandled rejection.
800
832
  controlStream.firstJoin.catch(() => {});
801
833
  controlStream.done.catch(() => {});
802
- // #311: beat the INSTANCE control-stream liveness so the server's sweeper
803
- // never keeps this agent "online" after it dies (the control SSE can stay
804
- // half-open behind the tunnel). The instance_token is durable (#269/#274),
805
- // read via closure so a re-register is picked up. unref'd internally.
806
- const instanceHeartbeat = startInstanceHeartbeat({
807
- getBaseUrl: apiBase,
834
+ // #627: NO network heartbeat any more (JP's decision). The machine's runner
835
+ // vouches for this agent and relays its conversation lease through a local
836
+ // coverage file; this watch re-reads it every 15 s, PAUSES the agent when it goes
837
+ // stale (no write without a confirmed lease), and closes the channel on a lost
838
+ // lease exactly as the old heartbeat did (#400).
839
+ const watch = startCoverageWatch({
840
+ identityBaseUrl: WEB_URL,
808
841
  instanceId: OWN_INSTANCE_ID,
809
- getInstanceToken: () => INSTANCE_TOKEN,
810
- // #400 (recadrage): renew this agent's conversation lease each beat. If the
811
- // server reports the lease was taken over (a new holder), or too many
812
- // consecutive indeterminate beats, close the channel — never stay a second
813
- // live agent on a conversation another agent now owns.
814
842
  getConversationId: () => CONVERSATION_ID || null,
815
843
  onLeaseLost: () => {
844
+ endPresence("lease-lost");
816
845
  scheduler?.cancel();
817
846
  releaseLock();
818
847
  process.exit(0);
819
848
  },
820
849
  });
821
- // Stop beating once the control stream ends (revoke / 401 / abort) so a
822
- // doomed instance doesn't keep beating (Opus review).
823
- controlStream.done.finally(() => instanceHeartbeat.stop());
850
+ presenceIdentity = watch.gate;
851
+ resolveGateReady();
852
+ controlStream.done.finally(() => watch.stop());
853
+ } else {
854
+ // #627: no instance identity → no presence record → no runner can cover this
855
+ // channel. Never a silent, permanent pause: it runs as before #627, and says so.
856
+ presenceIdentity = "none";
857
+ resolveGateReady();
858
+ process.stderr.write(
859
+ "[viber-channel] WARNING: no instance id from the server — this channel cannot be covered by a runner; running WITHOUT presence or lease (#627)\n",
860
+ );
824
861
  }
825
862
 
826
863
  // Schedule silent token refresh ahead of expiry (#237 step-03). On success
@@ -972,6 +1009,7 @@ function sleep(ms: number): Promise<void> {
972
1009
 
973
1010
  async function pushTranscript(text: string, lang: string): Promise<void> {
974
1011
  process.stderr.write(`[viber-channel] Speech detected: "${text}"\n`);
1012
+ await waitDeliverable("transcript");
975
1013
  await mcp.notification({
976
1014
  method: "notifications/claude/channel",
977
1015
  params: {
@@ -1047,6 +1085,7 @@ async function pushMessage(
1047
1085
  // conversation's voice/text flow.
1048
1086
  origin?: { conversationId: string },
1049
1087
  ): Promise<void> {
1088
+ await waitDeliverable("message");
1050
1089
  process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
1051
1090
  // Never echo this channel's OWN posted messages back to Claude (#280 step-16):
1052
1091
  // send_message posts as this instance, and the conversation SSE replays it.
@@ -1204,6 +1243,7 @@ async function sseLoop(): Promise<void> {
1204
1243
  case "stop":
1205
1244
  process.stderr.write(`[viber-channel] Received stop signal, clearing session handle and exiting.\n`);
1206
1245
  clearHandle(SESSION_FILE);
1246
+ endPresence("revoked");
1207
1247
  scheduler?.cancel();
1208
1248
  releaseLock();
1209
1249
  process.exit(0);
@@ -6,7 +6,8 @@
6
6
  * messages into a bridge-owned persistent Codex app-server thread, and posts
7
7
  * Codex's final assistant reply back into Viber.
8
8
  */
9
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
9
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
10
+ import { spawn } from "./lib/hidden_proc.js";
10
11
  import { createHash } from "node:crypto";
11
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
13
  import { dirname, join } from "node:path";
@@ -28,6 +29,7 @@ import {
28
29
  acquireBridgeLock as coreAcquireBridgeLock,
29
30
  agentIdentityFromEnv,
30
31
  BridgeShutdownError,
32
+ endBridgePresence,
31
33
  type BridgeLock,
32
34
  type BridgeWatermark,
33
35
  type ConversationMessage,
@@ -115,6 +117,8 @@ import {
115
117
  ControlStreamAuthError,
116
118
  } from "./lib/control_stream.ts";
117
119
  import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
120
+ import { type PresenceGate, REASON_NO_RUNNER as REASON_STARTING, startCoverageWatch } from "./lib/coverage_watch.ts";
121
+ import { startPresenceRecord } from "./lib/presence_record.ts";
118
122
  import { startBridgeToolHost, type BridgeToolHost } from "./lib/bridge_tool_host.ts";
119
123
  import type { AgentToolsContext } from "./lib/agent_tools.ts";
120
124
  import type { OpenDmResult } from "./lib/peers.ts";
@@ -1277,6 +1281,9 @@ async function acquireBridgeIdentity(
1277
1281
  * main() no longer uses it — it drives MULTI-conversation joins via its own
1278
1282
  * control stream (acquireBridgeIdentity + startConversationStream per join).
1279
1283
  */
1284
+ // ⚠ #627: this REFERENCE path still starts the pre-#627 network heartbeat. main() has
1285
+ // not used it since #303. Do NOT wire it back without replacing that heartbeat by
1286
+ // `startCoverageWatch` — a per-session beat is exactly what #627 removed.
1280
1287
  export async function acquireConversationViaInvite(
1281
1288
  options: BridgeOptions,
1282
1289
  auth: AuthJson,
@@ -1314,7 +1321,7 @@ export async function acquireConversationViaInvite(
1314
1321
  shutdown.abort(
1315
1322
  reason === "unauthorized"
1316
1323
  ? new BridgeShutdownError("instance token revoked or invalid", 3)
1317
- : new BridgeShutdownError("instance revoked", 0),
1324
+ : new BridgeShutdownError("instance revoked", 0, "revoked"),
1318
1325
  );
1319
1326
  },
1320
1327
  // #311: answer the server's liveness probe with an immediate heartbeat.
@@ -1348,7 +1355,7 @@ export async function acquireConversationViaInvite(
1348
1355
  // promise, then release the lock (the success path returns it to main()).
1349
1356
  await controlStream.done.catch(() => {});
1350
1357
  bridgeLock.release();
1351
- if (err instanceof ControlStreamStopped) throw new BridgeShutdownError("instance revoked", 0);
1358
+ if (err instanceof ControlStreamStopped) throw new BridgeShutdownError("instance revoked", 0, "revoked");
1352
1359
  if (err instanceof ControlStreamAuthError) {
1353
1360
  throw new BridgeShutdownError("instance token revoked or invalid", 3);
1354
1361
  }
@@ -1371,6 +1378,8 @@ function setupCodexRuntime(deps: {
1371
1378
  getVoiceBaseUrl: () => string;
1372
1379
  startConversation: (minted: ConversationMintResponse) => void;
1373
1380
  requestShutdown: (err: BridgeShutdownError) => void;
1381
+ /** #627: why the bridge is paused (null = may write). */
1382
+ pauseReason: () => string | null;
1374
1383
  }): { codex: CodexAppServer; toolHost: BridgeToolHost } {
1375
1384
  const { tracker } = deps;
1376
1385
  const bridgeToolsCtx: AgentToolsContext = {
@@ -1381,6 +1390,7 @@ function setupCodexRuntime(deps: {
1381
1390
  sendConversationId: () => tracker.conversationId(),
1382
1391
  sendConversationToken: () => tracker.conversationToken(),
1383
1392
  abortSignal: () => tracker.signalForPost(),
1393
+ pauseReason: () => deps.pauseReason(),
1384
1394
  // message_agent opened a DM — stream it so the peer's reply drives a turn.
1385
1395
  startDmStream: (dm: OpenDmResult) => {
1386
1396
  deps.startConversation({
@@ -1499,6 +1509,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1499
1509
  getVoiceBaseUrl: helpers.getVoiceBaseUrl,
1500
1510
  startConversation: helpers.startConversation,
1501
1511
  requestShutdown: helpers.requestShutdown,
1512
+ pauseReason: helpers.pauseReason,
1502
1513
  });
1503
1514
  codex = rt.codex;
1504
1515
  toolHost = rt.toolHost;
@@ -1521,6 +1532,8 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1521
1532
  let codex: CodexAppServer | null = null;
1522
1533
  let toolHost: BridgeToolHost | null = null;
1523
1534
  const res = await acquireTargetConversation(options.conversationId, options, auth, fingerprint);
1535
+ // #627: filled once the coverage watch starts (below); until then the bridge is paused.
1536
+ const gateRef: { gate?: PresenceGate } = {};
1524
1537
  // `satisfies` (not `as const`) so an omitted field is a COMPILE error here,
1525
1538
  // at construction, instead of surfacing as `undefined` inside the stream.
1526
1539
  // #480 found this the hard way: `identityBaseUrl` was missing and the session
@@ -1554,6 +1567,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1554
1567
  requestShutdown: (err) => {
1555
1568
  if (!shutdown.signal.aborted) shutdown.abort(err);
1556
1569
  },
1570
+ pauseReason: () => gateRef.gate?.reason() ?? REASON_STARTING,
1557
1571
  });
1558
1572
  codex = rt.codex;
1559
1573
  toolHost = rt.toolHost;
@@ -1566,22 +1580,42 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1566
1580
  // it expires after the TTL while the agent is alive and the conversation
1567
1581
  // falsely frees → another agent could load onto it. On a definite loss (a new
1568
1582
  // holder took over), shut down so we never double-attach.
1569
- const ihb = startInstanceHeartbeat({
1570
- getBaseUrl: apiBase,
1583
+ // #627: the local presence record read by the machine's runner.
1584
+ let streamEnd: unknown;
1585
+ const presence = startPresenceRecord({
1586
+ identityBaseUrl: IDENTITY_URL,
1587
+ instanceId: res.instanceKey,
1588
+ label: process.env.VIBER_CODEX_BRIDGE_LABEL ?? null,
1589
+ projectId: auth.project_id,
1590
+ getConversationId: () => res.minted.conversation_id,
1591
+ lockDir: LOCK_DIR(),
1592
+ });
1593
+ // #627: no network heartbeat — the runner vouches for this bridge and relays the
1594
+ // lease of its ONE conversation through the local coverage file.
1595
+ const watch = startCoverageWatch({
1596
+ identityBaseUrl: IDENTITY_URL,
1571
1597
  instanceId: res.instanceKey,
1572
- getInstanceToken: () => res.instanceToken,
1573
1598
  getConversationId: () => res.minted.conversation_id,
1574
1599
  onLeaseLost: () =>
1575
1600
  shutdown.abort(
1576
- new BridgeShutdownError("conversation lease lost (taken over)", 0),
1601
+ new BridgeShutdownError("conversation lease lost (taken over)", 0, "lease-lost"),
1577
1602
  ),
1603
+ lockDir: LOCK_DIR(),
1604
+ log: (m) => process.stderr.write(`${LOG_PREFIX} ${m}`),
1578
1605
  });
1579
- shutdown.signal.addEventListener("abort", () => ihb.stop(), { once: true });
1606
+ gateRef.gate = watch.gate;
1607
+ shutdown.signal.addEventListener("abort", () => watch.stop(), { once: true });
1580
1608
  try {
1581
1609
  // Await the one conversation directly so a fatal error (token expiry → exit 3)
1582
1610
  // propagates, preserving the pre-#303 exit semantics.
1583
- await coreRunConversationStream(res.minted, { ...runTurnDeps, makeRunTurn: makeCodexAdapter(codex, res.instanceKey) });
1611
+ await coreRunConversationStream(res.minted, { ...runTurnDeps, gate: watch.gate, makeRunTurn: makeCodexAdapter(codex, res.instanceKey) });
1612
+ } catch (err) {
1613
+ // #627: on this path the one conversation's end IS the bridge's end, so a
1614
+ // stop thrown by the stream (not an abort) must reach the presence record.
1615
+ streamEnd = err;
1616
+ throw err;
1584
1617
  } finally {
1618
+ endBridgePresence(presence, shutdown.signal.aborted ? shutdown.signal.reason : streamEnd);
1585
1619
  if (!shutdown.signal.aborted) shutdown.abort();
1586
1620
  for (const ctrl of activeStreams.values()) ctrl.abort(new BridgeShutdownError("bridge shutdown", 0));
1587
1621
  codex?.close();