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.
- package/lib/agent_tools.ts +18 -1
- package/lib/bridge_core.ts +76 -17
- package/lib/bridge_spawn.ts +1 -1
- package/lib/control_stream.ts +3 -2
- package/lib/coverage_watch.ts +244 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/hidden_proc.ts +99 -0
- package/lib/parent_watchdog.ts +1 -1
- package/lib/presence_record.ts +358 -0
- package/lib/process_start.ts +132 -10
- package/lib/runner_enroll.ts +26 -4
- package/lib/runner_exec.ts +229 -7
- package/lib/runner_registry.ts +413 -0
- package/lib/runner_relaunch.ts +181 -0
- package/lib/runner_roster.ts +521 -0
- package/lib/runner_stream.ts +66 -35
- package/lib/urls.ts +3 -1
- package/package.json +1 -1
- package/viber-channel.ts +65 -25
- package/viber-codex-bridge.ts +43 -9
package/lib/runner_stream.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
// #
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
// #
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
controlStream.done.finally(() =>
|
|
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);
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
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();
|