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.
@@ -78,6 +78,16 @@ export interface AgentToolsContext {
78
78
  * signal); the Codex bridge returns its per-turn stream signal (#303 parity).
79
79
  */
80
80
  abortSignal?(): AbortSignal | undefined;
81
+ /**
82
+ * #627: why this agent is PAUSED, or null when it may write. A paused agent has no
83
+ * confirmed conversation lease, so it must not post anything; the tool says why.
84
+ */
85
+ pauseReason?(): string | null;
86
+ }
87
+
88
+ function pausedResult(ctx: AgentToolsContext): AgentToolResult | null {
89
+ const reason = ctx.pauseReason?.() ?? null;
90
+ return reason === null ? null : errorText(`Paused — ${reason}. Nothing was sent; it resumes on its own when the runner covers this agent again.`);
81
91
  }
82
92
 
83
93
  function text(s: string): AgentToolResult {
@@ -164,6 +174,8 @@ export async function messageAgent(
164
174
  if (!ctx.instanceToken() || !ctx.voiceBaseUrl()) {
165
175
  return errorText("Channel not ready: no instance identity yet.");
166
176
  }
177
+ const paused = pausedResult(ctx);
178
+ if (paused !== null) return paused;
167
179
  const peerId = args.instance_id;
168
180
  const body = args.text;
169
181
  if (typeof peerId !== "string" || peerId.trim().length === 0) {
@@ -183,7 +195,10 @@ export async function messageAgent(
183
195
  if (!outcome.ok) {
184
196
  let friendly: string;
185
197
  if (outcome.detail === "target_offline") {
186
- friendly = "That agent is offline right now — it must be running to receive a DM.";
198
+ // #627: a just-launched agent comes online with its runner's first beat (≤ 30 s).
199
+ friendly =
200
+ "That agent is offline right now — it must be running to receive a DM. " +
201
+ "If it was just launched, it comes online with its runner's next beat (within 30 s): retry then.";
187
202
  } else if (outcome.detail === "dm_unavailable_retry") {
188
203
  friendly = "Could not open the DM due to a transient conflict — try message_agent again.";
189
204
  } else {
@@ -237,6 +252,8 @@ export async function sendMessage(
237
252
  if (!ctx.sendConversationToken() || !ctx.sendConversationId()) {
238
253
  return errorText("Channel not ready: no conversation yet.");
239
254
  }
255
+ const paused = pausedResult(ctx);
256
+ if (paused !== null) return paused;
240
257
  const body = args.text;
241
258
  if (typeof body !== "string" || body.trim().length === 0) {
242
259
  return errorText("Invalid input: text must be a non-empty string");
@@ -32,12 +32,12 @@ import type { AuthJson } from "./auth.js";
32
32
  import { acquireInstance, registerInstance, instanceKindFromEnv, maybeAttachTeam, type AcquiredInstance } from "./instance.js";
33
33
  import {
34
34
  runPersistentControlStream,
35
- sendInstanceHeartbeat,
36
35
  ControlStreamStopped,
37
36
  ControlStreamAuthError,
38
37
  type PersistentControlStream,
39
38
  } from "./control_stream.js";
40
- import { startInstanceHeartbeat } from "./heartbeat.js";
39
+ import { type PresenceGate, startCoverageWatch } from "./coverage_watch.js";
40
+ import { type PresenceEndReason, type PresenceRecordHandle, startPresenceRecord } from "./presence_record.js";
41
41
 
42
42
  /** A conversation message as it arrives over the conversation SSE / message list. */
43
43
  export interface ConversationMessage {
@@ -71,11 +71,19 @@ export interface ConversationRuntime {
71
71
  scheduler?: TokenRefreshScheduler;
72
72
  }
73
73
 
74
- /** A controlled bridge exit carrying the process exit code to propagate. */
74
+ /**
75
+ * A controlled bridge exit carrying the process exit code to propagate.
76
+ *
77
+ * `endReason` (#627) names the cause for the presence record. It is set ONLY where
78
+ * the cause is known (revocation, lease lost, --once done). The class alone says
79
+ * nothing about intent: it also carries failures (token expired, refresh failed),
80
+ * which must leave the agent relaunchable.
81
+ */
75
82
  export class BridgeShutdownError extends Error {
76
83
  constructor(
77
84
  message: string,
78
85
  public readonly exitCode: number,
86
+ public readonly endReason: PresenceEndReason = "shutdown",
79
87
  ) {
80
88
  super(message);
81
89
  this.name = "BridgeShutdownError";
@@ -308,8 +316,12 @@ export async function postBridgeReply(opts: {
308
316
  logPrefix: string;
309
317
  /** Fetch seam (#606) — see postMessage. Defaults to the global fetch. */
310
318
  fetchImpl?: typeof fetch;
319
+ /** #627: a reply finished during a pause waits for the resume before it is posted. */
320
+ gate?: PresenceGate;
311
321
  }): Promise<void> {
312
322
  const { baseUrl, runtime, text, ownPostedIds, signal, logPrefix } = opts;
323
+ await opts.gate?.waitActive(signal);
324
+ if (signal?.aborted) return;
313
325
  let result;
314
326
  try {
315
327
  result = await postMessage(baseUrl, runtime.id, runtime.token, text, undefined, signal, opts.fetchImpl ?? fetch);
@@ -529,6 +541,8 @@ export interface ConversationStreamDeps {
529
541
  /** Session-handle tag distinguishing runtimes in the handle path (codex/gemma). */
530
542
  sessionTag: string;
531
543
  makeRunTurn: MakeRunTurn;
544
+ /** #627: the pause gate — inbound turns and replies wait while the agent is paused. */
545
+ gate?: PresenceGate;
532
546
  }
533
547
 
534
548
  /**
@@ -598,7 +612,7 @@ export async function runConversationStream(
598
612
  watermark = { lastId: 0 };
599
613
  watermarks.set(convId, watermark);
600
614
  }
601
- await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark });
615
+ await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark, gate: deps.gate });
602
616
  } finally {
603
617
  parentSignal.removeEventListener("abort", onParentAbort);
604
618
  runtime.scheduler?.cancel();
@@ -651,8 +665,11 @@ export async function sseLoop(opts: {
651
665
  fetchImpl?: typeof fetch;
652
666
  /** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
653
667
  fetchMessagesImpl?: typeof fetchMessages;
668
+ /** #627: the pause gate (see ConversationStreamDeps.gate). */
669
+ gate?: PresenceGate;
654
670
  }): Promise<void> {
655
671
  const { minted, runtime, fingerprint, ownInstanceKey, options, signal, getBaseUrl, logPrefix, runTurn, watermark } = opts;
672
+ const deps = { gate: opts.gate };
656
673
  const baseUrl = getBaseUrl; // called at each use below
657
674
  const fetchImpl = opts.fetchImpl ?? fetch;
658
675
  const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
@@ -722,7 +739,7 @@ export async function sseLoop(opts: {
722
739
  rearmFailureNotice();
723
740
  return false;
724
741
  case "auto-post":
725
- await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl });
742
+ await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix, fetchImpl, gate: deps.gate });
726
743
  rearmFailureNotice();
727
744
  return false;
728
745
  }
@@ -801,6 +818,7 @@ export async function sseLoop(opts: {
801
818
  signal,
802
819
  logPrefix,
803
820
  fetchImpl,
821
+ gate: deps.gate,
804
822
  });
805
823
  lastAnnouncedCause = key;
806
824
  } catch (notifyErr) {
@@ -837,6 +855,15 @@ export async function sseLoop(opts: {
837
855
  // AT-MOST-ONCE: mark handled BEFORE the reply so a runtime failure does not
838
856
  // re-feed the same message on reconnect (no reply storm). A failed reply is
839
857
  // dropped for this run — deliberately asymmetric with dm_stream's conduit.
858
+ // #627: while paused, the message is DEFERRED (never dropped): the turn starts
859
+ // once the runner covers this agent again. The wait comes BEFORE the message is
860
+ // marked handled: a stream stopped during the pause leaves it unhandled, and the
861
+ // persistent watermark un-advanced, so the re-join replays it (Codex, step-06).
862
+ if (deps.gate?.isPaused()) {
863
+ process.stderr.write(`${logPrefix} paused (${deps.gate.reason()}): message deferred\n`);
864
+ await deps.gate.waitActive(signal);
865
+ if (signal.aborted) return false;
866
+ }
840
867
  if (idStr) handledIds.add(idStr);
841
868
  process.stderr.write(`${logPrefix} message received: ${msg.content!.slice(0, 80)}\n`);
842
869
  try {
@@ -956,7 +983,8 @@ export async function sseLoop(opts: {
956
983
 
957
984
  if (eventType === "stop") {
958
985
  process.stderr.write(`${logPrefix} received stop signal, exiting\n`);
959
- throw new BridgeShutdownError("received stop signal", 0);
986
+ // #627: the same server stop as claude's `stop` event → the same cause.
987
+ throw new BridgeShutdownError("received stop signal", 0, "revoked");
960
988
  }
961
989
 
962
990
  if (eventType === "transcription" && data) {
@@ -1344,6 +1372,8 @@ export interface BridgeHelpers {
1344
1372
  parentSignal: AbortSignal;
1345
1373
  /** Request a controlled bridge shutdown (e.g. codex's app-server died). */
1346
1374
  requestShutdown: (err: BridgeShutdownError) => void;
1375
+ /** #627: why the agent is paused (null = may write) — for the tool host. */
1376
+ pauseReason: () => string | null;
1347
1377
  }
1348
1378
 
1349
1379
  /** A runtime's adapter for the shared await-invite bridge. */
@@ -1396,6 +1426,10 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1396
1426
  const streamTasks = new Set<Promise<void>>();
1397
1427
  let voiceBaseUrl = "";
1398
1428
  let instanceKey = "";
1429
+ // #627: the local presence record read by the machine's runner.
1430
+ let presence: PresenceRecordHandle | undefined;
1431
+ // #627: the pause gate, fed by the runner's local coverage (no network heartbeat).
1432
+ let gate: PresenceGate | undefined;
1399
1433
 
1400
1434
  const startConversation = (minted: ConversationMintResponse): void => {
1401
1435
  // A pushed join is the only announcement a bridge that reuses a persisted
@@ -1419,11 +1453,12 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1419
1453
  logPrefix,
1420
1454
  sessionTag,
1421
1455
  makeRunTurn,
1456
+ gate,
1422
1457
  })
1423
1458
  .then(() => {
1424
1459
  // --once: one handled message completes the bridge cleanly.
1425
1460
  if (options.once && !shutdown.signal.aborted) {
1426
- shutdown.abort(new BridgeShutdownError("once: completed", 0));
1461
+ shutdown.abort(new BridgeShutdownError("once: completed", 0, "once-completed"));
1427
1462
  }
1428
1463
  })
1429
1464
  .catch((err) => {
@@ -1441,6 +1476,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1441
1476
  });
1442
1477
  bridgeLock = identity.bridgeLock;
1443
1478
  instanceKey = identity.instanceKey;
1479
+ // Await-invite bridges may hold several conversations; the beat sends none,
1480
+ // so the record carries none either.
1481
+ presence = startPresenceRecord({ identityBaseUrl, instanceId: identity.instanceKey, label, projectId: auth.project_id, getConversationId: () => null, lockDir });
1482
+ // #627: no network heartbeat — the runner vouches for this bridge. An await-invite
1483
+ // bridge follows no single conversation lease, only its runner's freshness.
1484
+ const watch = startCoverageWatch({
1485
+ identityBaseUrl,
1486
+ instanceId: identity.instanceKey,
1487
+ getConversationId: () => null,
1488
+ onLeaseLost: () => shutdown.abort(new BridgeShutdownError("conversation lease lost (taken over)", 0, "lease-lost")),
1489
+ lockDir,
1490
+ log: (m) => process.stderr.write(`${logPrefix} ${m}`),
1491
+ });
1492
+ gate = watch.gate;
1493
+ shutdown.signal.addEventListener("abort", () => watch.stop(), { once: true });
1444
1494
  // #480: adopt BEFORE the control stream and the heartbeat start — they are
1445
1495
  // this bridge's two longest-lived flows and the whole point of the change.
1446
1496
  api.offer(identity.apiBaseUrlAnnounced);
@@ -1452,6 +1502,7 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1452
1502
  requestShutdown: (err) => {
1453
1503
  if (!shutdown.signal.aborted) shutdown.abort(err);
1454
1504
  },
1505
+ pauseReason: () => gate?.reason() ?? null,
1455
1506
  };
1456
1507
  await onStart?.({ identity: { instanceKey: identity.instanceKey, instanceToken: identity.instanceToken }, helpers });
1457
1508
 
@@ -1468,25 +1519,21 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1468
1519
  shutdown.abort(
1469
1520
  reason === "unauthorized"
1470
1521
  ? new BridgeShutdownError("instance token revoked or invalid", 3)
1471
- : new BridgeShutdownError("instance revoked", 0),
1522
+ : new BridgeShutdownError("instance revoked", 0, "revoked"),
1472
1523
  );
1473
1524
  },
1474
- onBeatNow: () => {
1475
- void sendInstanceHeartbeat(baseUrl(), identity.instanceKey, identity.instanceToken);
1476
- },
1525
+ // #627: an old server may still probe; this agent never beats on the network —
1526
+ // its runner answers for it (Python no longer probes a runner-covered agent).
1527
+ onBeatNow: () => {},
1477
1528
  },
1478
1529
  );
1479
- const ihb = startInstanceHeartbeat({
1480
- getBaseUrl: baseUrl,
1481
- instanceId: identity.instanceKey,
1482
- getInstanceToken: () => identity.instanceToken,
1483
- });
1484
- shutdown.signal.addEventListener("abort", () => ihb.stop(), { once: true });
1485
1530
  controlStream.firstJoin.catch(() => {});
1486
1531
  await controlStream.done;
1487
1532
  const reason = shutdown.signal.reason;
1488
1533
  if (reason instanceof BridgeShutdownError) throw reason;
1489
1534
  } finally {
1535
+ // Read BEFORE the bare abort below replaces an unset reason.
1536
+ endBridgePresence(presence, shutdown.signal.aborted ? shutdown.signal.reason : undefined);
1490
1537
  if (!shutdown.signal.aborted) shutdown.abort();
1491
1538
  const pending = [...streamTasks];
1492
1539
  for (const ctrl of activeStreams.values()) ctrl.abort(new BridgeShutdownError("bridge shutdown", 0));
@@ -1499,3 +1546,15 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
1499
1546
 
1500
1547
  // Re-export control-stream error types so runtimes can map them if needed.
1501
1548
  export { ControlStreamStopped, ControlStreamAuthError };
1549
+
1550
+ /**
1551
+ * #627 — record how a bridge ended. A `BridgeShutdownError` is an end the bridge
1552
+ * went through → `ended` with its `endReason` (failures default to "shutdown").
1553
+ * Anything else — an unexpected throw — leaves the record active, like a crash.
1554
+ * The runner, not this function, decides what either means.
1555
+ */
1556
+ export function endBridgePresence(presence: PresenceRecordHandle | undefined, reason: unknown): void {
1557
+ if (presence === undefined) return;
1558
+ if (reason instanceof BridgeShutdownError) presence.markEnded(reason.endReason);
1559
+ else presence.stop();
1560
+ }
@@ -14,7 +14,7 @@
14
14
  * the bridge's default "register a fresh instance" path.
15
15
  */
16
16
 
17
- import { spawn as nodeSpawn } from "node:child_process";
17
+ import { spawn as nodeSpawn } from "./hidden_proc.js";
18
18
  import type { AgentSpec, ChildHandle, SpawnFn } from "./supervisor.ts";
19
19
 
20
20
  /** Minimal child surface the adapter consumes (matches node's ChildProcess). */
@@ -26,9 +26,10 @@ export interface ControlEvent {
26
26
  data: string;
27
27
  }
28
28
 
29
- /** Build the instance control-stream SSE URL. */
29
+ /** Build the instance control-stream SSE URL. `presence=runner` (#627): this agent's
30
+ * runner vouches for it, so its connect must not count as liveness on the server. */
30
31
  export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
31
- return `${baseUrl}/api/instances/${instanceId}/events`;
32
+ return `${baseUrl}/api/instances/${instanceId}/events?presence=runner`;
32
33
  }
33
34
 
34
35
  /**
@@ -0,0 +1,244 @@
1
+ /**
2
+ * coverage_watch.ts — the agent side of runner-owned presence (#627 step-06).
3
+ *
4
+ * JP's decision: no per-session heartbeat any more. An agent of this version never
5
+ * beats on the network; its machine's RUNNER vouches for it and relays, after each
6
+ * acknowledged beat, a local COVERAGE file (`runner_roster.ts`):
7
+ *
8
+ * <presenceDir>/<instance_id>.coverage.json
9
+ * { lease_active: true|false|null, beat_id, conversation_id, written_at }
10
+ *
11
+ * Every 15 s this module re-reads it (a local read, zero network) and decides:
12
+ *
13
+ * - fresh coverage, lease true → ACTIVE;
14
+ * - lease false → the conversation was taken over: the
15
+ * channel closes, as before (#400);
16
+ * - lease null → the existing tolerance: 3 indeterminate
17
+ * answers from 3 DISTINCT beats, then close;
18
+ * the same file read twice counts once;
19
+ * - coverage older than 120 s, absent,
20
+ * or for another conversation → PAUSED: nothing is sent, nothing is
21
+ * delivered, until a fresh coverage returns.
22
+ *
23
+ * Why 120 s (plan 627, table of delays): pause (120) + one reread (15) = 135 s <
24
+ * the 210 s lease — so an old holder has stopped writing before anyone else can
25
+ * take its conversation. The conversation writes carry no fencing token; this
26
+ * ordering IS the guard.
27
+ *
28
+ * An agent STARTS paused, until its first coverage. An agent in await-invite has no
29
+ * conversation: there is no lease to follow, only the runner's freshness.
30
+ *
31
+ * Freshness is judged on the LOCAL clock of the coverage write (`written_at`),
32
+ * never on the server's beat id.
33
+ */
34
+ import { readFileSync } from "node:fs";
35
+ import { DEFAULT_MAX_INDETERMINATE_LEASE_BEATS } from "./heartbeat.js";
36
+ import { runnerStatePath, readRunnerState } from "./runner_registry.js";
37
+ import { coveragePath } from "./runner_roster.js";
38
+
39
+ export const COVERAGE_REREAD_MS = 15_000;
40
+ export const PAUSE_AFTER_MS = 120_000;
41
+ /** The #400 tolerance, ONE definition (Opus M3). */
42
+ export const MAX_INDETERMINATE_LEASES = DEFAULT_MAX_INDETERMINATE_LEASE_BEATS;
43
+
44
+ export const REASON_NO_RUNNER = "aucun runner connecté pour ce backend";
45
+ export const REASON_LEASE_UNCONFIRMED = "bail non confirmé par le runner";
46
+
47
+ /**
48
+ * The pause gate the channel consults before sending or delivering anything.
49
+ * `waitActive()` resolves at once when active; while paused it resolves on resume.
50
+ */
51
+ /**
52
+ * #627 — the channel's pause decision when it may have NO instance identity. An
53
+ * agent the server gave no instance id has no presence record, so no runner can
54
+ * ever cover it: pausing it would be a silent, permanent wait. It then runs as
55
+ * before #627 (no pause, and no lease either), which the channel logs LOUDLY once.
56
+ * With an identity, the gate decides; before the gate exists, it is starting.
57
+ */
58
+ export function channelPauseReason(identity: "pending" | "none" | PresenceGate): string | null {
59
+ if (identity === "none") return null;
60
+ if (identity === "pending") return REASON_STARTING;
61
+ return identity.reason();
62
+ }
63
+
64
+ export const REASON_STARTING = "canal en démarrage";
65
+
66
+ export class PresenceGate {
67
+ private pauseReason: string | null;
68
+ private waiters: Array<() => void> = [];
69
+
70
+ /** Pending waiters (tests). */
71
+ pendingWaiters(): number {
72
+ return this.waiters.length;
73
+ }
74
+
75
+ constructor(initialReason: string | null) {
76
+ this.pauseReason = initialReason;
77
+ }
78
+
79
+ /** null when active; otherwise why the agent is paused. */
80
+ reason(): string | null {
81
+ return this.pauseReason;
82
+ }
83
+
84
+ isPaused(): boolean {
85
+ return this.pauseReason !== null;
86
+ }
87
+
88
+ /** Resolves when active — or when `signal` aborts, so a paused agent can still shut down. */
89
+ waitActive(signal?: AbortSignal): Promise<void> {
90
+ if (this.pauseReason === null || signal?.aborted) return Promise.resolve();
91
+ return new Promise((resolve) => {
92
+ // The abort listener is REMOVED on a normal resume, so repeated pauses do not
93
+ // pile listeners on one long-lived signal (Codex, step-06 review).
94
+ const onAbort = (): void => {
95
+ // An aborted waiter leaves the list too: streams stopped during a long pause
96
+ // must not pile up (Codex, step-06 review).
97
+ const i = this.waiters.indexOf(done);
98
+ if (i >= 0) this.waiters.splice(i, 1);
99
+ done();
100
+ };
101
+ const done = (): void => {
102
+ signal?.removeEventListener("abort", onAbort);
103
+ resolve();
104
+ };
105
+ this.waiters.push(done);
106
+ signal?.addEventListener("abort", onAbort, { once: true });
107
+ });
108
+ }
109
+
110
+ set(reason: string | null): void {
111
+ this.pauseReason = reason;
112
+ if (reason === null) for (const w of this.waiters.splice(0)) w();
113
+ }
114
+ }
115
+
116
+ interface CoverageFile {
117
+ lease_active: boolean | null;
118
+ beat_id: string;
119
+ conversation_id: string | null;
120
+ written_at: number;
121
+ }
122
+
123
+ function readCoverage(path: string): CoverageFile | null {
124
+ try {
125
+ const v = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
126
+ const lease = v.lease_active;
127
+ if (!(lease === true || lease === false || lease === null)) return null;
128
+ if (typeof v.beat_id !== "string" || v.beat_id === "") return null;
129
+ if (!(v.conversation_id === null || typeof v.conversation_id === "string")) return null;
130
+ if (typeof v.written_at !== "number") return null;
131
+ return { lease_active: lease, beat_id: v.beat_id, conversation_id: v.conversation_id, written_at: v.written_at };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ export interface CoverageWatchOptions {
138
+ identityBaseUrl: string;
139
+ instanceId: string;
140
+ /** Read on every tick: the agent may be re-bound to another conversation. */
141
+ getConversationId: () => string | null;
142
+ /** The conversation was definitely lost: close, exactly as the old heartbeat did. */
143
+ onLeaseLost: () => void;
144
+ lockDir?: string;
145
+ now?: () => number;
146
+ rereadMs?: number;
147
+ pauseAfterMs?: number;
148
+ maxIndeterminate?: number;
149
+ log?: (line: string) => void;
150
+ setTimer?: (fn: () => void, ms: number) => unknown;
151
+ clearTimer?: (handle: unknown) => void;
152
+ }
153
+
154
+ export interface CoverageWatch {
155
+ gate: PresenceGate;
156
+ /** One evaluation, now (the timer calls it; tests too). */
157
+ tick: () => void;
158
+ stop: () => void;
159
+ }
160
+
161
+ export function startCoverageWatch(opts: CoverageWatchOptions): CoverageWatch {
162
+ const now = opts.now ?? Date.now;
163
+ const pauseAfter = opts.pauseAfterMs ?? PAUSE_AFTER_MS;
164
+ const maxIndeterminate = opts.maxIndeterminate ?? MAX_INDETERMINATE_LEASES;
165
+ const log = opts.log ?? ((line: string) => process.stderr.write(line));
166
+ const setTimer =
167
+ opts.setTimer ??
168
+ ((fn: () => void, ms: number) => {
169
+ const h = setInterval(fn, ms);
170
+ (h as { unref?: () => void }).unref?.();
171
+ return h as unknown;
172
+ });
173
+ const clearTimer = opts.clearTimer ?? ((h: unknown) => clearInterval(h as ReturnType<typeof setInterval>));
174
+
175
+ const gate = new PresenceGate(REASON_NO_RUNNER);
176
+ const covPath = coveragePath(opts.identityBaseUrl, opts.instanceId.toLowerCase(), opts.lockDir);
177
+ const runnerPath = runnerStatePath(opts.identityBaseUrl, opts.lockDir);
178
+ const countedNullBeats = new Set<string>();
179
+ let lost = false;
180
+
181
+ const pause = (reason: string): void => {
182
+ if (gate.reason() !== reason) log(`[presence] paused: ${reason}\n`);
183
+ gate.set(reason);
184
+ };
185
+ const resume = (): void => {
186
+ if (gate.isPaused()) log("[presence] resumed: covered by the runner\n");
187
+ gate.set(null);
188
+ };
189
+ const loseLease = (why: string): void => {
190
+ if (lost) return;
191
+ lost = true;
192
+ log(`[presence] conversation lease lost (${why}) — closing\n`);
193
+ gate.set(REASON_LEASE_UNCONFIRMED);
194
+ opts.onLeaseLost();
195
+ };
196
+
197
+ const tick = (): void => {
198
+ if (lost) return;
199
+ const t = now();
200
+ const cov = readCoverage(covPath);
201
+ const fresh = cov !== null && t - cov.written_at <= pauseAfter;
202
+ if (!fresh) {
203
+ const runner = readRunnerState(runnerPath);
204
+ const runnerFresh = runner?.last_beat_ok_at !== null && runner !== null && t - (runner.last_beat_ok_at as number) <= pauseAfter;
205
+ pause(runnerFresh ? REASON_LEASE_UNCONFIRMED : REASON_NO_RUNNER);
206
+ return;
207
+ }
208
+ const conversation = opts.getConversationId();
209
+ if (conversation === null) {
210
+ // await-invite: no lease to follow — the runner's word is enough.
211
+ resume();
212
+ return;
213
+ }
214
+ if (cov.conversation_id !== conversation) {
215
+ pause(REASON_LEASE_UNCONFIRMED); // a lease renewed for ANOTHER conversation proves nothing
216
+ return;
217
+ }
218
+ if (cov.lease_active === false) {
219
+ loseLease("taken over");
220
+ return;
221
+ }
222
+ if (cov.lease_active === null) {
223
+ if (!countedNullBeats.has(cov.beat_id)) {
224
+ countedNullBeats.add(cov.beat_id);
225
+ if (countedNullBeats.size >= maxIndeterminate) {
226
+ loseLease(`${countedNullBeats.size} indeterminate beats`);
227
+ return;
228
+ }
229
+ }
230
+ resume(); // tolerated, as the old heartbeat did
231
+ return;
232
+ }
233
+ countedNullBeats.clear();
234
+ resume();
235
+ };
236
+
237
+ tick();
238
+ const timer = setTimer(tick, opts.rereadMs ?? COVERAGE_REREAD_MS);
239
+ return {
240
+ gate,
241
+ tick,
242
+ stop: () => clearTimer(timer),
243
+ };
244
+ }
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execSync } from "./hidden_proc.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { realpathSync } from "node:fs";
4
4
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * hidden_proc.ts — the ONLY door to `node:child_process` and `Bun.spawn*` in this package (#638).
3
+ * vibe-master and viber-gateway re-export it, so there is ONE `hide()` for the three packages.
4
+ *
5
+ * The Viber app (`viber-gateway.exe`) is a GUI-subsystem binary: it has no console. On Windows a
6
+ * console child of a console-less parent gets a NEW, VISIBLE console unless the spawn passes
7
+ * `windowsHide: true`. The runner heartbeat (every 30 s) enumerates processes through PowerShell,
8
+ * so each beat flashed a window.
9
+ *
10
+ * Measured (#638 step-01, bun 1.3.14, compiled `--windows-hide-console` exe started through WMI):
11
+ *
12
+ * ```
13
+ * execFileSync / execSync / exec / Bun.spawnSync hide=false -> child console visible=True
14
+ * hide=true -> child hwnd=0 (NO console at all)
15
+ * ```
16
+ *
17
+ * `hwnd=0` is a console WITHOUT a window (CREATE_NO_WINDOW), and it is inherited: the `exec` rows are
18
+ * already a GRANDCHILD (`cmd.exe /c powershell`) and read `hwnd=0` too. So hiding the first edge may be
19
+ * enough for console descendants — but not for a Bun/Node parent that spawns with its own flags.
20
+ * Measured again for a Bun parent (step-03): compiled GUI exe -> Bun console child spawned with
21
+ * hide=true -> its grandchild spawned WITHOUT hide reads `hwnd=0 visible=False`; child hide=false ->
22
+ * grandchild `visible=True`. So hiding the first edge is enough for console descendants.
23
+ * Every spawn still goes through this helper BY DECISION (plan 638, defence in depth): the next first
24
+ * edge is written by someone who has not read this.
25
+ * No-op outside Windows.
26
+ */
27
+ import * as cp from "node:child_process";
28
+
29
+ /** Every option object that reaches `child_process` passes here. Exported for its witness. */
30
+ export function hide<T extends object>(opts: T | undefined): T & { windowsHide: true } {
31
+ return { ...(opts ?? ({} as T)), windowsHide: true };
32
+ }
33
+
34
+ type Backend = Pick<typeof cp, "exec" | "execSync" | "execFile" | "execFileSync" | "spawn">;
35
+
36
+ /**
37
+ * `execFile` / `execFileSync` / `spawn` accept `args` OR options in 2nd place (and `execFile` a
38
+ * callback anywhere). Split them so a call without `args` still carries its options through `hide`.
39
+ */
40
+ function splitArgs(rest: unknown[]): { args: readonly string[]; tail: unknown[] } {
41
+ return Array.isArray(rest[0]) ? { args: rest[0] as readonly string[], tail: rest.slice(1) } : { args: [], tail: rest };
42
+ }
43
+
44
+ function optsAndCallback(tail: unknown[]): { opts: object | undefined; cb: unknown } {
45
+ const cb = tail.find((t) => typeof t === "function");
46
+ const opts = tail.find((t) => t !== null && typeof t === "object") as object | undefined;
47
+ return { opts, cb };
48
+ }
49
+
50
+ /** The wrappers over a given backend. Exported so the witness can hand a recording fake (a
51
+ * `mock.module` of `node:child_process` leaks into every other test file of the run). */
52
+ export function hiddenOver(b: Backend): Backend {
53
+ return {
54
+ exec: ((command: string, ...rest: unknown[]) => {
55
+ const { opts, cb } = optsAndCallback(rest);
56
+ return b.exec(command, hide(opts as cp.ExecOptions | undefined), cb as Parameters<typeof cp.exec>[2]);
57
+ }) as typeof cp.exec,
58
+ execSync: ((command: string, options?: cp.ExecSyncOptions) => b.execSync(command, hide(options))) as typeof cp.execSync,
59
+ execFile: ((file: string, ...rest: unknown[]) => {
60
+ const { args, tail } = splitArgs(rest);
61
+ const { opts, cb } = optsAndCallback(tail);
62
+ return b.execFile(file, args, hide(opts as cp.ExecFileOptions | undefined), cb as Parameters<typeof cp.execFile>[3]);
63
+ }) as typeof cp.execFile,
64
+ execFileSync: ((file: string, ...rest: unknown[]) => {
65
+ const { args, tail } = splitArgs(rest);
66
+ return b.execFileSync(file, args, hide(tail[0] as cp.ExecFileSyncOptions | undefined));
67
+ }) as typeof cp.execFileSync,
68
+ spawn: ((command: string, ...rest: unknown[]) => {
69
+ const { args, tail } = splitArgs(rest);
70
+ return b.spawn(command, args, hide(tail[0] as cp.SpawnOptions | undefined));
71
+ }) as typeof cp.spawn,
72
+ };
73
+ }
74
+
75
+ export const { exec, execSync, execFile, execFileSync, spawn } = hiddenOver(cp);
76
+
77
+ // ── Bun.spawn* (the channel, vibe-master and the gateway all run on Bun) ──
78
+
79
+ type BunSpawn = typeof Bun.spawn;
80
+ type BunSpawnSync = typeof Bun.spawnSync;
81
+
82
+ /**
83
+ * Both forms of `Bun.spawn*`: `(cmd[], opts)` and `({ cmd, ...opts })` — in the second the options live
84
+ * in the FIRST argument, so hiding only the second would silently leave it visible.
85
+ */
86
+ function hiddenArgs(cmd: unknown, opts: unknown): [unknown, unknown?] {
87
+ return Array.isArray(cmd) ? [cmd, hide(opts as object | undefined)] : [hide(cmd as object)];
88
+ }
89
+
90
+ /** The wrappers over a given `Bun`. Exported so the witness can hand a recording fake. */
91
+ export function hiddenBunOver(b: { spawn: BunSpawn; spawnSync: BunSpawnSync }): { bunSpawn: BunSpawn; bunSpawnSync: BunSpawnSync } {
92
+ const call = (f: (...a: unknown[]) => unknown) => (cmd: unknown, opts?: unknown) => f(...hiddenArgs(cmd, opts));
93
+ return {
94
+ bunSpawn: call(b.spawn as (...a: unknown[]) => unknown) as BunSpawn,
95
+ bunSpawnSync: call(b.spawnSync as (...a: unknown[]) => unknown) as BunSpawnSync,
96
+ };
97
+ }
98
+
99
+ export const { bunSpawn, bunSpawnSync } = hiddenBunOver(Bun);
@@ -20,7 +20,7 @@
20
20
  * still alive". `signal 0` probes existence without delivering a signal.
21
21
  */
22
22
 
23
- import { execSync } from "node:child_process";
23
+ import { execSync } from "./hidden_proc.js";
24
24
 
25
25
  /**
26
26
  * Shim processes that sit BETWEEN the real agent (e.g. claude.exe) and us in the