viber-channel 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/lib/auth.ts +69 -21
- package/lib/bridge_spawn.ts +105 -0
- package/lib/channel_errors.ts +2 -2
- package/lib/channel_session.ts +107 -0
- package/lib/connect.ts +34 -9
- package/lib/control_stream.ts +307 -0
- package/lib/conversation.ts +158 -21
- package/lib/instance.ts +137 -0
- package/lib/lockfile.ts +35 -10
- package/lib/message_dedup.ts +47 -0
- package/lib/messages.ts +24 -0
- package/lib/self_echo.ts +17 -0
- package/lib/startup_gate.ts +19 -0
- package/lib/supervisor.ts +292 -0
- package/lib/supervisor_config.ts +121 -0
- package/package.json +7 -2
- package/viber-channel.ts +280 -47
- package/viber-codex-bridge.ts +1247 -0
- package/viber-codex-supervisor.ts +96 -0
package/viber-channel.ts
CHANGED
|
@@ -28,13 +28,27 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
28
28
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
29
29
|
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
30
30
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
31
32
|
import { join } from "node:path";
|
|
32
33
|
import { runConnect } from "./lib/connect.ts";
|
|
33
34
|
import { lockFilePath } from "./lib/lockfile.ts";
|
|
35
|
+
import { sessionFilePath, clearHandle } from "./lib/channel_session.ts";
|
|
36
|
+
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
37
|
+
import { MessageDedup } from "./lib/message_dedup.ts";
|
|
38
|
+
import {
|
|
39
|
+
acquireConversation,
|
|
40
|
+
ConversationMintError,
|
|
41
|
+
DEFAULT_REATTACH_WINDOW_SECONDS,
|
|
42
|
+
type ConversationMintResponse,
|
|
43
|
+
} from "./lib/conversation.ts";
|
|
44
|
+
import { acquireInstance, registerInstance } from "./lib/instance.ts";
|
|
45
|
+
import { runPersistentControlStream } from "./lib/control_stream.ts";
|
|
46
|
+
import { isOwnMessage } from "./lib/self_echo.ts";
|
|
34
47
|
import {
|
|
35
48
|
createTokenRefreshScheduler,
|
|
36
49
|
type TokenRefreshScheduler,
|
|
37
50
|
} from "./lib/token_refresh.ts";
|
|
51
|
+
import { awaitStableStartup } from "./lib/startup_gate.ts";
|
|
38
52
|
|
|
39
53
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
40
54
|
//
|
|
@@ -42,6 +56,22 @@ import {
|
|
|
42
56
|
// exits — it does not start the MCP server. Any other invocation (no args, or
|
|
43
57
|
// the unknown subcommand) falls through to the MCP server below.
|
|
44
58
|
{
|
|
59
|
+
// #280 step-29: in an AGENT session (vibe-master terminal), the channel must
|
|
60
|
+
// be the PLUGIN copy (`--channels plugin:viber-channel@…` — only it can
|
|
61
|
+
// inject inbound messages). The manually-registered viber-dev-channel MCP
|
|
62
|
+
// loads in the same session with the same env; instead of racing it for the
|
|
63
|
+
// channel lock, it bows out deterministically here. Normal sessions
|
|
64
|
+
// (no VIBER_CHANNEL_AGENT_SESSION) are unaffected.
|
|
65
|
+
if (
|
|
66
|
+
process.env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
|
|
67
|
+
process.env.VIBER_CHANNEL_PLUGIN !== "1"
|
|
68
|
+
) {
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
"[viber-channel] agent session: yielding to the plugin channel copy (manual MCP registration exits)\n",
|
|
71
|
+
);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
const sub = process.argv[2];
|
|
46
76
|
if (sub === "--help" || sub === "-h") {
|
|
47
77
|
process.stdout.write(
|
|
@@ -74,15 +104,84 @@ import {
|
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
// Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
|
|
77
|
-
// Namespaced by VIBER_BASE_URL
|
|
78
|
-
// (staging + dev) coexist
|
|
79
|
-
//
|
|
107
|
+
// Namespaced by (VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID) so:
|
|
108
|
+
// - channels at different backends (staging + dev) coexist (different base_url);
|
|
109
|
+
// - two DIFFERENT projects on the same backend never collide — the fingerprint
|
|
110
|
+
// (deterministic per machine + folder) is the only axis that isolates them
|
|
111
|
+
// for a normal bunx client, where the session id is always "" (#267);
|
|
112
|
+
// - two concurrent launches of the SAME project coexist when each launcher
|
|
113
|
+
// supplies a per-launch session id (#259);
|
|
114
|
+
// - channel respawns within one launch inherit the same session id and
|
|
115
|
+
// re-acquire the same lock — accidental duplicates still blocked (#166).
|
|
116
|
+
//
|
|
117
|
+
// LOCK_FINGERPRINT is computed from the current folder (process.cwd()) rather
|
|
118
|
+
// than read from auth.json so the lock paths are available before loadAuth();
|
|
119
|
+
// it is verified equal to auth.client_fingerprint below — a mismatch exits.
|
|
80
120
|
const LOCK_DIR =
|
|
81
121
|
process.env.APPDATA
|
|
82
122
|
? join(process.env.APPDATA, "viber")
|
|
83
123
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
84
124
|
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
85
|
-
|
|
125
|
+
// #269: a fleet's parallel agents each carry their own VIBER_INSTANCE_TOKEN but
|
|
126
|
+
// often no VIBER_CHANNEL_SESSION_ID. Fold a HASH of the instance token into the
|
|
127
|
+
// lock's session axis so distinct env-injected instances get distinct locks
|
|
128
|
+
// (parallel same-folder agents no longer collide) — without reordering instance
|
|
129
|
+
// acquisition before the lock, which would defeat the #257 stability gate. An
|
|
130
|
+
// explicit VIBER_CHANNEL_SESSION_ID still wins (back-compat). The token is
|
|
131
|
+
// hashed (never raw) because LOCK_SESSION_ID is logged as a prefix below.
|
|
132
|
+
const LOCK_SESSION_ID =
|
|
133
|
+
process.env.VIBER_CHANNEL_SESSION_ID ??
|
|
134
|
+
(process.env.VIBER_INSTANCE_TOKEN
|
|
135
|
+
? `inst-${createHash("sha256").update(process.env.VIBER_INSTANCE_TOKEN).digest("hex").slice(0, 16)}`
|
|
136
|
+
: "");
|
|
137
|
+
const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
|
|
138
|
+
const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
|
|
139
|
+
// SESSION_FILE (the conversation handle path) is computed AFTER the instance is
|
|
140
|
+
// acquired (#269) — it is namespaced by the server-issued instance, not the
|
|
141
|
+
// local fingerprint/sessionId. Declared as a `let` near the mint below.
|
|
142
|
+
|
|
143
|
+
// VIBER_REATTACH_WINDOW_SECONDS overrides the default TTL-based window.
|
|
144
|
+
// A handle older than this cannot point at a live conversation — skip reattach.
|
|
145
|
+
// Parsed once, mirroring the VIBER_REFRESH_LEAD_SECONDS pattern below: validate
|
|
146
|
+
// finite && >= 0, log invalid→ignore, log override, fall back to the default.
|
|
147
|
+
const REATTACH_WINDOW_SECONDS: number = (() => {
|
|
148
|
+
const raw = process.env.VIBER_REATTACH_WINDOW_SECONDS;
|
|
149
|
+
if (raw === undefined) return DEFAULT_REATTACH_WINDOW_SECONDS;
|
|
150
|
+
const parsed = Number.parseInt(raw, 10);
|
|
151
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
152
|
+
process.stderr.write(
|
|
153
|
+
`[viber-channel] Invalid VIBER_REATTACH_WINDOW_SECONDS=${raw}, ignoring.\n`
|
|
154
|
+
);
|
|
155
|
+
return DEFAULT_REATTACH_WINDOW_SECONDS;
|
|
156
|
+
}
|
|
157
|
+
process.stderr.write(
|
|
158
|
+
`[viber-channel] reattach window overridden to ${parsed}s via VIBER_REATTACH_WINDOW_SECONDS\n`
|
|
159
|
+
);
|
|
160
|
+
return parsed;
|
|
161
|
+
})();
|
|
162
|
+
|
|
163
|
+
// VIBER_STABILITY_DELAY_MS: how long to wait after mcp.connect() before
|
|
164
|
+
// creating a conversation. A doomed process (broken-pipe respawn) dies during
|
|
165
|
+
// this window via the stdin "end" handler, setting shuttingDown=true — the gate
|
|
166
|
+
// then skips acquireConversation and the conversation is never created.
|
|
167
|
+
// Default: 500ms (observed broken-pipe deaths are ~0s; 500ms is imperceptible).
|
|
168
|
+
// Set to 0 to disable (useful for tests / custom launchers).
|
|
169
|
+
const DEFAULT_STABILITY_DELAY_MS = 500;
|
|
170
|
+
const STABILITY_DELAY_MS: number = (() => {
|
|
171
|
+
const raw = process.env.VIBER_STABILITY_DELAY_MS;
|
|
172
|
+
if (raw === undefined) return DEFAULT_STABILITY_DELAY_MS;
|
|
173
|
+
const parsed = Number.parseInt(raw, 10);
|
|
174
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
175
|
+
process.stderr.write(
|
|
176
|
+
`[viber-channel] Invalid VIBER_STABILITY_DELAY_MS=${raw}, ignoring.\n`
|
|
177
|
+
);
|
|
178
|
+
return DEFAULT_STABILITY_DELAY_MS;
|
|
179
|
+
}
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`[viber-channel] stability delay overridden to ${parsed}ms via VIBER_STABILITY_DELAY_MS\n`
|
|
182
|
+
);
|
|
183
|
+
return parsed;
|
|
184
|
+
})();
|
|
86
185
|
|
|
87
186
|
function isProcessAlive(pid: number): boolean {
|
|
88
187
|
try {
|
|
@@ -147,8 +246,17 @@ function releaseLock(): void {
|
|
|
147
246
|
// completes on slow boots) can cancel it idempotently via optional-chaining.
|
|
148
247
|
let scheduler: TokenRefreshScheduler | null = null;
|
|
149
248
|
|
|
249
|
+
// Stability gate flag (#257): set true as soon as any shutdown path begins so
|
|
250
|
+
// the gate can observe an in-flight shutdown and abort conversation creation.
|
|
251
|
+
let shuttingDown = false;
|
|
252
|
+
|
|
150
253
|
// Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
|
|
151
254
|
process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
|
|
255
|
+
// NOTE: shuttingDown is NOT set here. SIGINT/SIGTERM call process.exit(0) synchronously,
|
|
256
|
+
// so the event loop ends before any setTimeout (the stability gate) can observe the flag.
|
|
257
|
+
// Only the stdin `end` handler sets shuttingDown=true, because it does NOT call
|
|
258
|
+
// process.exit() synchronously — it fires before the MCP transport's read loop begins,
|
|
259
|
+
// giving the gate's setTimeout a chance to run and check the flag.
|
|
152
260
|
process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
153
261
|
process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
|
|
154
262
|
|
|
@@ -166,6 +274,7 @@ process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0
|
|
|
166
274
|
// `end` fires on real EOF (parent closes write end) and is sufficient for
|
|
167
275
|
// orphan detection. See plan #236 step-09 for the diagnosis.
|
|
168
276
|
process.stdin.on("end", () => {
|
|
277
|
+
shuttingDown = true;
|
|
169
278
|
process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
|
|
170
279
|
scheduler?.cancel();
|
|
171
280
|
releaseLock();
|
|
@@ -174,20 +283,19 @@ process.stdin.on("end", () => {
|
|
|
174
283
|
|
|
175
284
|
// ---- Auth from .viber/auth.json ----
|
|
176
285
|
|
|
177
|
-
import { loadAuth,
|
|
286
|
+
import { authFilePath, loadAuth, persistInstanceCredentials } from "./lib/auth.ts";
|
|
178
287
|
import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
|
|
179
288
|
import { buildSseUrl } from "./lib/urls.ts";
|
|
180
289
|
|
|
181
|
-
|
|
290
|
+
const auth = loadAuth();
|
|
182
291
|
|
|
183
292
|
// ---- Fingerprint verification ----
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (computedFp !== auth.client_fingerprint) {
|
|
293
|
+
//
|
|
294
|
+
// LOCK_FINGERPRINT (computed above from process.cwd()) is the same value; this
|
|
295
|
+
// re-checks it against the fingerprint stored in auth.json and exits on drift.
|
|
296
|
+
if (LOCK_FINGERPRINT !== auth.client_fingerprint) {
|
|
189
297
|
process.stderr.write(
|
|
190
|
-
`[viber-channel] Folder fingerprint mismatch —
|
|
298
|
+
`[viber-channel] Folder fingerprint mismatch — \`${authFilePath()}\` was issued for a different machine/folder.\n` +
|
|
191
299
|
`Re-run the connect flow at https://viber.dgypx.dev/projects.\n`
|
|
192
300
|
);
|
|
193
301
|
process.exit(1);
|
|
@@ -197,11 +305,9 @@ if (computedFp !== auth.client_fingerprint) {
|
|
|
197
305
|
|
|
198
306
|
import {
|
|
199
307
|
defaultLabel,
|
|
200
|
-
mintConversation,
|
|
201
308
|
refreshConversationToken,
|
|
202
309
|
RefreshHttpError,
|
|
203
310
|
RefreshNetworkError,
|
|
204
|
-
ReverifyRequiredError,
|
|
205
311
|
} from "./lib/conversation.ts";
|
|
206
312
|
import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
|
|
207
313
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
@@ -221,6 +327,7 @@ const mcp = new Server(
|
|
|
221
327
|
instructions: [
|
|
222
328
|
'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
|
|
223
329
|
"Reply with send_message. The `text` field is read aloud to the user, so keep it a short, natural spoken sentence — no markdown symbols, no bullet lists, no tables, no code blocks.",
|
|
330
|
+
"Exception for inline identifiers: when mentioning a file name, hostname, variable, or other short token inline (auth.json, viber-dev.dgypx.dev, CONVERSATION_TOKEN), write the literal form with its real dots and dashes — do NOT spell them out as 'point' or 'dash'. The 'no markdown' rule is about visual clutter (`**bold**`, `- bullets`, code fences, tables, long path lists), not the punctuation that's naturally part of identifiers.",
|
|
224
331
|
"Put technical content (code, lists, JSON, tables, command output, long file paths) in the optional `artifact` field with the matching `format`; it renders in a side viewer the user can read.",
|
|
225
332
|
"Available formats: `markdown` (default), `code`, `json`, `html`.",
|
|
226
333
|
"Use `html` only when the rendering needs structural HTML markdown can't express (styled cards, badges, mixed layouts). Scripts and event handlers are stripped server-side; don't try to send executable JS.",
|
|
@@ -337,8 +444,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
337
444
|
};
|
|
338
445
|
});
|
|
339
446
|
|
|
340
|
-
// Prevent duplicate instances (#166)
|
|
341
|
-
|
|
447
|
+
// Prevent duplicate instances (#166); fingerprint namespacing for different
|
|
448
|
+
// projects (#267); session-id namespacing for concurrent launches of the same
|
|
449
|
+
// project (#259). The fingerprint and session-id prefixes are logged so a user
|
|
450
|
+
// with multiple channels can tell their stderr logs apart.
|
|
451
|
+
const sessionIdLogged = LOCK_SESSION_ID === "" ? "<unset>" : LOCK_SESSION_ID.slice(0, 8);
|
|
452
|
+
process.stderr.write(
|
|
453
|
+
`[viber-channel] startup: pid=${process.pid}, fp=${LOCK_FINGERPRINT.slice(0, 8)}, sessionId=${sessionIdLogged}, acquiring lock\n`,
|
|
454
|
+
);
|
|
342
455
|
const blockingPid = acquireLock();
|
|
343
456
|
process.stderr.write(`[viber-channel] startup: lock acquired (blockingPid=${blockingPid})\n`);
|
|
344
457
|
|
|
@@ -362,38 +475,129 @@ if (blockingPid !== null) {
|
|
|
362
475
|
process.exit(1);
|
|
363
476
|
}
|
|
364
477
|
|
|
365
|
-
// ----
|
|
478
|
+
// ---- Stability gate (#257): defer conversation creation so a doomed process exits first ----
|
|
479
|
+
//
|
|
480
|
+
// Claude Code respawns the channel on transport reconnects; a process that dies
|
|
481
|
+
// within the first second (broken-pipe) had already created a conversation
|
|
482
|
+
// because acquireConversation() ran immediately. The gate delays that call so
|
|
483
|
+
// the stdin "end" handler can set shuttingDown=true and let process.exit(0)
|
|
484
|
+
// race the timer. If the gate sees shuttingDown, it exits without creating a
|
|
485
|
+
// conversation. A healthy process proceeds after STABILITY_DELAY_MS with no
|
|
486
|
+
// user-perceptible delay (the user has not spoken yet at that point).
|
|
487
|
+
|
|
488
|
+
process.stderr.write(`[viber-channel] startup: stability gate (${STABILITY_DELAY_MS}ms)...\n`);
|
|
489
|
+
const gateOpen = await awaitStableStartup(STABILITY_DELAY_MS, () => shuttingDown);
|
|
490
|
+
if (!gateOpen) {
|
|
491
|
+
process.stderr.write(`[viber-channel] startup: stability gate detected shutdown — skipping conversation creation\n`);
|
|
492
|
+
process.exit(0);
|
|
493
|
+
}
|
|
494
|
+
process.stderr.write(`[viber-channel] startup: stability gate passed (${STABILITY_DELAY_MS}ms), acquiring conversation\n`);
|
|
495
|
+
|
|
496
|
+
// ---- Acquire conversation token (reattach or mint, after mcp.connect so a token-rejected notification can be sent) ----
|
|
366
497
|
|
|
367
498
|
let CONVERSATION_TOKEN: string;
|
|
368
499
|
let VOICE_BASE_URL: string;
|
|
369
500
|
let CONVERSATION_ID: string;
|
|
501
|
+
let SESSION_FILE = "";
|
|
502
|
+
// This channel's own server-issued instance id (#280 step-16). Used by the
|
|
503
|
+
// self-echo guard: messages posted by this instance are NOT surfaced back to
|
|
504
|
+
// Claude when the conversation SSE replays them.
|
|
505
|
+
let OWN_INSTANCE_ID = "";
|
|
370
506
|
|
|
371
|
-
process.stderr.write(`[viber-channel] startup: calling mintConversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"})\n`);
|
|
372
507
|
try {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
508
|
+
// #269: acquire this channel's server-issued instance identity — env-injected
|
|
509
|
+
// VIBER_INSTANCE_TOKEN > persisted auth.json instance_token > register a new
|
|
510
|
+
// one with the stable project_token (self-healing auth.json). The conversation
|
|
511
|
+
// handle is namespaced by the instance, not the local fingerprint/sessionId.
|
|
512
|
+
const acquired = await acquireInstance(BASE_URL, auth, auth.client_fingerprint);
|
|
513
|
+
let instanceToken = acquired.instance_token;
|
|
514
|
+
// Self-echo guard (#280 step-16) needs the REAL server instance id — never the
|
|
515
|
+
// token that instance_key may fall back to (Codex review P1). Undefined → ""
|
|
516
|
+
// → the guard is a no-op (delivers everything), which is the safe default.
|
|
517
|
+
OWN_INSTANCE_ID = acquired.instance_id ?? "";
|
|
518
|
+
SESSION_FILE = sessionFilePath(LOCK_BASE_URL, acquired.instance_key, LOCK_DIR);
|
|
519
|
+
|
|
520
|
+
process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE})\n`);
|
|
521
|
+
|
|
522
|
+
// Join the conversation with the instance_token. A 401 means the instance was
|
|
523
|
+
// revoked (the token is durable — no expiry case): re-register a fresh instance
|
|
524
|
+
// ONCE (a NEW instance_id, the correct post-revocation outcome) and retry. A
|
|
525
|
+
// second failure falls through to the catch below.
|
|
526
|
+
let minted: ConversationMintResponse;
|
|
527
|
+
try {
|
|
528
|
+
minted = await acquireConversation(
|
|
529
|
+
SESSION_FILE,
|
|
530
|
+
BASE_URL,
|
|
531
|
+
auth.project_id,
|
|
532
|
+
instanceToken,
|
|
533
|
+
auth.client_fingerprint,
|
|
534
|
+
label,
|
|
535
|
+
auth.target_conversation_id ?? null,
|
|
536
|
+
REATTACH_WINDOW_SECONDS,
|
|
537
|
+
);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (err instanceof ConversationMintError && err.status === 401) {
|
|
540
|
+
process.stderr.write(
|
|
541
|
+
`[viber-channel] instance token rejected (401 revoked) — re-registering a fresh instance once\n`,
|
|
542
|
+
);
|
|
543
|
+
const reg = await registerInstance(
|
|
544
|
+
BASE_URL,
|
|
545
|
+
auth.project_id,
|
|
546
|
+
auth.project_token,
|
|
547
|
+
auth.client_fingerprint,
|
|
548
|
+
);
|
|
549
|
+
persistInstanceCredentials(authFilePath(), reg.instance_id, reg.instance_token);
|
|
550
|
+
instanceToken = reg.instance_token;
|
|
551
|
+
OWN_INSTANCE_ID = reg.instance_id;
|
|
552
|
+
SESSION_FILE = sessionFilePath(LOCK_BASE_URL, reg.instance_id, LOCK_DIR);
|
|
553
|
+
minted = await acquireConversation(
|
|
554
|
+
SESSION_FILE,
|
|
555
|
+
BASE_URL,
|
|
556
|
+
auth.project_id,
|
|
557
|
+
instanceToken,
|
|
558
|
+
auth.client_fingerprint,
|
|
559
|
+
label,
|
|
560
|
+
auth.target_conversation_id ?? null,
|
|
561
|
+
REATTACH_WINDOW_SECONDS,
|
|
562
|
+
);
|
|
563
|
+
} else {
|
|
564
|
+
throw err;
|
|
390
565
|
}
|
|
391
566
|
}
|
|
392
567
|
|
|
568
|
+
// project_token / instance_token are stable, long-lived credentials (#274/#269);
|
|
569
|
+
// neither is rotated by the mint response. The membership conversation_token
|
|
570
|
+
// (below) is the short-lived tier and self-refreshes.
|
|
571
|
+
|
|
393
572
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
394
573
|
VOICE_BASE_URL = minted.ws_url;
|
|
395
574
|
CONVERSATION_ID = minted.conversation_id;
|
|
396
575
|
|
|
576
|
+
// #280 step-27: hold the instance control stream open for the WHOLE session —
|
|
577
|
+
// it IS the "online" presence signal (step-02). Without it this agent never
|
|
578
|
+
// shows online in the control plane (sidebar Agents) and is not invitable.
|
|
579
|
+
// Pushed `join` events are logged but not acted on (an interactive session
|
|
580
|
+
// stays bound to its own conversation — option A); a `stop` (revoke) is
|
|
581
|
+
// logged only: the conversation-token path already fails closed.
|
|
582
|
+
if (OWN_INSTANCE_ID !== "") {
|
|
583
|
+
const controlStream = runPersistentControlStream(
|
|
584
|
+
{ baseUrl: BASE_URL, instanceId: OWN_INSTANCE_ID, instanceToken },
|
|
585
|
+
{
|
|
586
|
+
log: (m) => process.stderr.write(m),
|
|
587
|
+
onJoin: (pushed) =>
|
|
588
|
+
process.stderr.write(
|
|
589
|
+
`[viber-channel] control stream: join for ${pushed.conversation_id} ignored — interactive session keeps its own conversation\n`,
|
|
590
|
+
),
|
|
591
|
+
onStop: (reason) =>
|
|
592
|
+
process.stderr.write(`[viber-channel] control stream stopped (${reason})\n`),
|
|
593
|
+
},
|
|
594
|
+
);
|
|
595
|
+
// Presence-only consumer: firstJoin ALWAYS settles (rejected at loop end) —
|
|
596
|
+
// observe it so an ignored join/stop never becomes an unhandled rejection.
|
|
597
|
+
controlStream.firstJoin.catch(() => {});
|
|
598
|
+
controlStream.done.catch(() => {});
|
|
599
|
+
}
|
|
600
|
+
|
|
397
601
|
// Schedule silent token refresh ahead of expiry (#237 step-03). On success
|
|
398
602
|
// we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
|
|
399
603
|
// so subsequent SSE reconnects and send_message calls pick up the new token
|
|
@@ -481,20 +685,29 @@ try {
|
|
|
481
685
|
);
|
|
482
686
|
scheduler.start(minted.expires_at);
|
|
483
687
|
} catch (err) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
process.exit(2);
|
|
493
|
-
}
|
|
688
|
+
// The conversation mint failed. With the stable-token model (#274) a revoked
|
|
689
|
+
// or otherwise invalid project_token yields HTTP 401 — there is no reverify
|
|
690
|
+
// dance and no rotation, so the only recovery is to re-run the connect flow.
|
|
691
|
+
// Network errors and unexpected 5xx land here too; the message stays generic
|
|
692
|
+
// enough to cover them while pointing at the most common cause. The stderr
|
|
693
|
+
// line below is invisible in the Claude Code UI, which only sees the MCP
|
|
694
|
+
// server die ("tools fetch failed") — surface an actionable notification so
|
|
695
|
+
// the user knows what to do instead of failing opaquely. Clean exit, no retry.
|
|
494
696
|
process.stderr.write(`[viber-channel] ${String(err)}\n`);
|
|
495
697
|
process.stderr.write(
|
|
496
|
-
`[viber-channel] If your project_token is no longer valid, run the connect flow again at
|
|
698
|
+
`[viber-channel] If your project_token is no longer valid, run the connect flow again at ${BASE_URL}/projects.\n`
|
|
497
699
|
);
|
|
700
|
+
await mcp.notification({
|
|
701
|
+
method: "notifications/claude/channel",
|
|
702
|
+
params: {
|
|
703
|
+
content:
|
|
704
|
+
`⚠️ Could not start the voice channel: ${String(err)}. ` +
|
|
705
|
+
`If your project_token was revoked, reconnect at ${BASE_URL}/projects and run ` +
|
|
706
|
+
`\`bunx viber-channel connect <url>\` again, then restart this channel. ` +
|
|
707
|
+
`Otherwise this may be a temporary network issue — restart the channel to retry.`,
|
|
708
|
+
meta: { source: "system", type: "project_token_invalid" },
|
|
709
|
+
},
|
|
710
|
+
});
|
|
498
711
|
process.exit(1);
|
|
499
712
|
}
|
|
500
713
|
|
|
@@ -539,6 +752,11 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
|
|
|
539
752
|
});
|
|
540
753
|
}
|
|
541
754
|
|
|
755
|
+
// Drops the persisted echo of a user transcript already delivered ephemerally
|
|
756
|
+
// (the conversation event bus delivers it twice when a web UI is open — #221
|
|
757
|
+
// mechanism, surfaced by the #254 Active section that keeps the UI open).
|
|
758
|
+
const messageDedup = new MessageDedup();
|
|
759
|
+
|
|
542
760
|
/**
|
|
543
761
|
* Forward a saved conversation message from the event bus to Claude.
|
|
544
762
|
*
|
|
@@ -554,8 +772,21 @@ async function pushMessage(msg: {
|
|
|
554
772
|
id?: string | null;
|
|
555
773
|
source?: string;
|
|
556
774
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
775
|
+
sender_instance_id?: string | null;
|
|
557
776
|
}): Promise<void> {
|
|
558
777
|
process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
|
|
778
|
+
// Never echo this channel's OWN posted messages back to Claude (#280 step-16):
|
|
779
|
+
// send_message posts as this instance, and the conversation SSE replays it.
|
|
780
|
+
if (isOwnMessage(msg.sender_instance_id, OWN_INSTANCE_ID)) {
|
|
781
|
+
process.stderr.write(`[viber-channel] dropping own posted message (self-echo)\n`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (messageDedup.isDuplicate(msg)) {
|
|
785
|
+
process.stderr.write(
|
|
786
|
+
`[viber-channel] dropping duplicate persisted message (already delivered ephemerally)\n`,
|
|
787
|
+
);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
559
790
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
560
791
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
561
792
|
const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
|
|
@@ -632,7 +863,8 @@ async function sseLoop(): Promise<void> {
|
|
|
632
863
|
break;
|
|
633
864
|
|
|
634
865
|
case "stop":
|
|
635
|
-
process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
|
|
866
|
+
process.stderr.write(`[viber-channel] Received stop signal, clearing session handle and exiting.\n`);
|
|
867
|
+
clearHandle(SESSION_FILE);
|
|
636
868
|
scheduler?.cancel();
|
|
637
869
|
releaseLock();
|
|
638
870
|
process.exit(0);
|
|
@@ -665,6 +897,7 @@ async function sseLoop(): Promise<void> {
|
|
|
665
897
|
role?: string;
|
|
666
898
|
timestamp?: string;
|
|
667
899
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
900
|
+
sender_instance_id?: string | null;
|
|
668
901
|
};
|
|
669
902
|
if (msg.content) {
|
|
670
903
|
await pushMessage(msg);
|