viber-channel 0.5.3 → 0.7.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/agent_tools.ts +201 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +122 -0
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +351 -0
- package/lib/conversation.ts +23 -9
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +164 -0
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +32 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +303 -0
- package/lib/supervisor_config.ts +140 -0
- package/package.json +9 -2
- package/viber-channel.ts +376 -71
- package/viber-codex-bridge.ts +1261 -0
- package/viber-codex-supervisor.ts +96 -0
- package/viber-gemma-bridge.ts +169 -0
package/viber-channel.ts
CHANGED
|
@@ -30,14 +30,25 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot
|
|
|
30
30
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { runConnect } from "./lib/connect.ts";
|
|
33
|
-
import { lockFilePath } from "./lib/lockfile.ts";
|
|
33
|
+
import { lockFilePath, lockSessionId } from "./lib/lockfile.ts";
|
|
34
34
|
import { sessionFilePath, clearHandle } from "./lib/channel_session.ts";
|
|
35
35
|
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
36
36
|
import { MessageDedup } from "./lib/message_dedup.ts";
|
|
37
37
|
import {
|
|
38
38
|
acquireConversation,
|
|
39
|
+
ConversationMintError,
|
|
39
40
|
DEFAULT_REATTACH_WINDOW_SECONDS,
|
|
41
|
+
type ConversationMintResponse,
|
|
40
42
|
} from "./lib/conversation.ts";
|
|
43
|
+
import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
|
|
44
|
+
import { startParentWatchdog } from "./lib/parent_watchdog.ts";
|
|
45
|
+
import { acquireInstance, registerInstance } from "./lib/instance.ts";
|
|
46
|
+
import {
|
|
47
|
+
runPersistentControlStream,
|
|
48
|
+
sendInstanceHeartbeat,
|
|
49
|
+
} from "./lib/control_stream.ts";
|
|
50
|
+
import { runDmStream } from "./lib/dm_stream.ts";
|
|
51
|
+
import { isOwnMessage } from "./lib/self_echo.ts";
|
|
41
52
|
import {
|
|
42
53
|
createTokenRefreshScheduler,
|
|
43
54
|
type TokenRefreshScheduler,
|
|
@@ -50,6 +61,22 @@ import { awaitStableStartup } from "./lib/startup_gate.ts";
|
|
|
50
61
|
// exits — it does not start the MCP server. Any other invocation (no args, or
|
|
51
62
|
// the unknown subcommand) falls through to the MCP server below.
|
|
52
63
|
{
|
|
64
|
+
// #280 step-29: in an AGENT session (vibe-master terminal), the channel must
|
|
65
|
+
// be the PLUGIN copy (`--channels plugin:viber-channel@…` — only it can
|
|
66
|
+
// inject inbound messages). The manually-registered viber-dev-channel MCP
|
|
67
|
+
// loads in the same session with the same env; instead of racing it for the
|
|
68
|
+
// channel lock, it bows out deterministically here. Normal sessions
|
|
69
|
+
// (no VIBER_CHANNEL_AGENT_SESSION) are unaffected.
|
|
70
|
+
if (
|
|
71
|
+
process.env.VIBER_CHANNEL_AGENT_SESSION === "1" &&
|
|
72
|
+
process.env.VIBER_CHANNEL_PLUGIN !== "1"
|
|
73
|
+
) {
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
"[viber-channel] agent session: yielding to the plugin channel copy (manual MCP registration exits)\n",
|
|
76
|
+
);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
const sub = process.argv[2];
|
|
54
81
|
if (sub === "--help" || sub === "-h") {
|
|
55
82
|
process.stdout.write(
|
|
@@ -100,10 +127,23 @@ const LOCK_DIR =
|
|
|
100
127
|
? join(process.env.APPDATA, "viber")
|
|
101
128
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
102
129
|
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
103
|
-
|
|
130
|
+
// #269: a fleet's parallel agents each carry their own VIBER_INSTANCE_TOKEN but
|
|
131
|
+
// often no VIBER_CHANNEL_SESSION_ID. Fold a HASH of the instance token into the
|
|
132
|
+
// lock's session axis so distinct env-injected instances get distinct locks
|
|
133
|
+
// (parallel same-folder agents no longer collide) — without reordering instance
|
|
134
|
+
// acquisition before the lock, which would defeat the #257 stability gate. An
|
|
135
|
+
// explicit VIBER_CHANNEL_SESSION_ID still wins (back-compat). The token is
|
|
136
|
+
// hashed (never raw) because LOCK_SESSION_ID is logged as a prefix below.
|
|
137
|
+
// Lock session-id axis (#259/#269/#293): explicit session id > env instance
|
|
138
|
+
// token > VIBER_CHANNEL_LABEL > "". A labelled 2nd agent in the same folder gets
|
|
139
|
+
// its own lock (and its own fresh identity via acquireInstance); a plain launch
|
|
140
|
+
// keeps sessionId="" so accidental duplicates stay blocked. See lockSessionId.
|
|
141
|
+
const LOCK_SESSION_ID = lockSessionId(process.env);
|
|
104
142
|
const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
|
|
105
143
|
const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
|
|
106
|
-
|
|
144
|
+
// SESSION_FILE (the conversation handle path) is computed AFTER the instance is
|
|
145
|
+
// acquired (#269) — it is namespaced by the server-issued instance, not the
|
|
146
|
+
// local fingerprint/sessionId. Declared as a `let` near the mint below.
|
|
107
147
|
|
|
108
148
|
// VIBER_REATTACH_WINDOW_SECONDS overrides the default TTL-based window.
|
|
109
149
|
// A handle older than this cannot point at a live conversation — skip reattach.
|
|
@@ -186,9 +226,14 @@ function acquireLock(): number | null {
|
|
|
186
226
|
continue;
|
|
187
227
|
}
|
|
188
228
|
|
|
189
|
-
// Another live channel
|
|
229
|
+
// Another live channel holds this folder's lock. This blocks accidental
|
|
230
|
+
// duplicate spawns (#166/#259) — but it also blocks a deliberate 2nd agent
|
|
231
|
+
// in the same folder (#293). Make the message actionable: a labelled launch
|
|
232
|
+
// gets its own identity AND lock (see LOCK_SESSION_ID above).
|
|
190
233
|
process.stderr.write(
|
|
191
|
-
`[viber-channel] Another
|
|
234
|
+
`[viber-channel] Another Viber agent holds this folder (PID ${existingPid}), exiting. ` +
|
|
235
|
+
`To run a SECOND agent here, set VIBER_CHANNEL_LABEL=<name> in this MCP server's env ` +
|
|
236
|
+
`(it gets its own identity + lock) and relaunch.\n`
|
|
192
237
|
);
|
|
193
238
|
return existingPid;
|
|
194
239
|
}
|
|
@@ -248,7 +293,7 @@ process.stdin.on("end", () => {
|
|
|
248
293
|
|
|
249
294
|
// ---- Auth from .viber/auth.json ----
|
|
250
295
|
|
|
251
|
-
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
296
|
+
import { authFilePath, loadAuth, persistInstanceCredentials } from "./lib/auth.ts";
|
|
252
297
|
import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
|
|
253
298
|
import { buildSseUrl } from "./lib/urls.ts";
|
|
254
299
|
|
|
@@ -274,7 +319,13 @@ import {
|
|
|
274
319
|
RefreshHttpError,
|
|
275
320
|
RefreshNetworkError,
|
|
276
321
|
} from "./lib/conversation.ts";
|
|
277
|
-
import {
|
|
322
|
+
import { ConversationTokenExpiredError } from "./lib/messages.ts";
|
|
323
|
+
import {
|
|
324
|
+
listAgents as libListAgents,
|
|
325
|
+
messageAgent as libMessageAgent,
|
|
326
|
+
sendMessage as libSendMessage,
|
|
327
|
+
type AgentToolsContext,
|
|
328
|
+
} from "./lib/agent_tools.ts";
|
|
278
329
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
279
330
|
|
|
280
331
|
const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
@@ -291,12 +342,14 @@ const mcp = new Server(
|
|
|
291
342
|
},
|
|
292
343
|
instructions: [
|
|
293
344
|
'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
|
|
294
|
-
"Reply with send_message.
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
"
|
|
298
|
-
"
|
|
299
|
-
"
|
|
345
|
+
"Reply with send_message. You are talking to a human who both LISTENS (the `text` is read aloud by TTS) AND READS it on screen (the `text` is ALSO rendered as Markdown in the UI). So aim for both: it must sound natural read aloud AND be easy to read. Lead with the answer, no preamble or recap.",
|
|
346
|
+
"USE light Markdown in `text` when it makes the message easier to read: short bullet lists (`- `), numbered steps (`1.`), checklists, **bold** for key terms. A short list reads aloud just fine — a spoken list is natural. Don't cram several points into one dense paragraph; break them out. Avoid heavy/awkward-to-speak markup (big tables, code blocks, long fenced snippets) in `text` — that belongs in the artifact.",
|
|
347
|
+
"ACTIONS and QUESTIONS belong in `text`, made visible — never bury them in the artifact. If you need a decision, ask it clearly in `text` (a short bullet list of the options is good); the artifact may hold supporting detail but the question itself must be in `text` where it jumps out.",
|
|
348
|
+
"The `artifact` is for HEAVY or LONG content only: big code, large tables, long multi-point analyses, review dumps, JSON, file lists. When you use an artifact, keep `text` a brief spoken summary that points to it ('details on the side') — don't read the heavy content aloud.",
|
|
349
|
+
"Write identifiers LITERALLY with their real characters (288, auth.json, viber-dev.dgypx.dev, CONVERSATION_TOKEN) — never spell them out as 'point'/'dash' and never as awkward digit-by-digit text. A lone long token or path is better placed in the artifact than spoken.",
|
|
350
|
+
"Artifact formats: `markdown` (default), `code`, `json`, `html`. 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 send executable JS.",
|
|
351
|
+
"NO EMOJI in `text` — it is read aloud, so an emoji becomes spoken noise (a check mark is read as 'check mark'). Use plain Markdown (bullets, bold) for structure instead. Emoji are tolerable only inside an artifact (not spoken).",
|
|
352
|
+
"Rule of thumb: short and conversational, but readable — break points into a list rather than a wall of text; reserve the artifact for what is genuinely long or heavy.",
|
|
300
353
|
"On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
|
|
301
354
|
].join(" "),
|
|
302
355
|
}
|
|
@@ -310,8 +363,8 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
310
363
|
name: "send_message",
|
|
311
364
|
description:
|
|
312
365
|
"Send a message into the Viber conversation this channel is bound to. " +
|
|
313
|
-
"`text` is the
|
|
314
|
-
"`artifact` is optional and carries
|
|
366
|
+
"`text` is the conversational reply — it is BOTH read aloud (TTS) AND rendered as Markdown on screen, so keep it short and natural but use light Markdown (short bullet/numbered lists, **bold**) when it aids readability. Put any ACTIONS or QUESTIONS for the user here, visibly. " +
|
|
367
|
+
"`artifact` is optional and carries HEAVY/LONG content (big code, large tables, long analyses, JSON, sanitized HTML) rendered in a side viewer; when used, keep `text` a brief summary that points to it. " +
|
|
315
368
|
"Use `html` only when structural HTML markdown can't express is required; scripts and event handlers are stripped before display.",
|
|
316
369
|
inputSchema: {
|
|
317
370
|
type: "object" as const,
|
|
@@ -319,7 +372,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
319
372
|
text: {
|
|
320
373
|
type: "string",
|
|
321
374
|
minLength: 1,
|
|
322
|
-
description: "
|
|
375
|
+
description: "Conversational reply, read aloud AND shown as Markdown. Short and natural, but use light Markdown (short lists, numbered steps, **bold**) for readability. Actions/questions go here, visibly. NO emoji (it gets read aloud).",
|
|
323
376
|
},
|
|
324
377
|
artifact: {
|
|
325
378
|
type: "object",
|
|
@@ -345,44 +398,101 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
345
398
|
required: ["text"],
|
|
346
399
|
},
|
|
347
400
|
},
|
|
401
|
+
{
|
|
402
|
+
name: "list_agents",
|
|
403
|
+
description:
|
|
404
|
+
"List the OTHER agents (instances) in this project you can talk to directly (#288). " +
|
|
405
|
+
"Returns each agent's id, label, runtime kind, and whether it is currently online. " +
|
|
406
|
+
"Use this to find the id of an agent (e.g. 'Codex Review') before calling message_agent. " +
|
|
407
|
+
"Only agents in the same project are returned; you are never in the list.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object" as const,
|
|
410
|
+
properties: {},
|
|
411
|
+
required: [],
|
|
412
|
+
additionalProperties: false,
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: "message_agent",
|
|
417
|
+
description:
|
|
418
|
+
"Send a direct message to another agent in this project (#288). " +
|
|
419
|
+
"Pass the target agent's instance id (from list_agents, or the from_instance_id of a DM you received) " +
|
|
420
|
+
"and the message text. Opens or reuses a private 1:1 DM with that agent and posts your message; " +
|
|
421
|
+
"the agent's reply arrives back on this channel tagged source=agent-dm. " +
|
|
422
|
+
"Fails if the target agent is offline.",
|
|
423
|
+
inputSchema: {
|
|
424
|
+
type: "object" as const,
|
|
425
|
+
properties: {
|
|
426
|
+
instance_id: {
|
|
427
|
+
type: "string",
|
|
428
|
+
minLength: 1,
|
|
429
|
+
description: "The target agent's instance id (from list_agents or a received DM's from_instance_id).",
|
|
430
|
+
},
|
|
431
|
+
text: {
|
|
432
|
+
type: "string",
|
|
433
|
+
minLength: 1,
|
|
434
|
+
description: "The message to send to the target agent.",
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
required: ["instance_id", "text"],
|
|
438
|
+
additionalProperties: false,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
348
441
|
],
|
|
349
442
|
}));
|
|
350
443
|
|
|
444
|
+
// Live, by-reference view of this channel's runtime state for the shared tool
|
|
445
|
+
// logic (#317, lib/agent_tools.ts). Every getter reads the current module var at
|
|
446
|
+
// call time — never a snapshot — so token refresh / re-mint are picked up. The
|
|
447
|
+
// Codex bridge implements the same AgentToolsContext against its own runtime,
|
|
448
|
+
// so both hosts run ONE tool implementation (no double maintenance).
|
|
449
|
+
const channelToolsCtx: AgentToolsContext = {
|
|
450
|
+
baseUrl: () => BASE_URL,
|
|
451
|
+
instanceToken: () => INSTANCE_TOKEN,
|
|
452
|
+
voiceBaseUrl: () => VOICE_BASE_URL,
|
|
453
|
+
// send_message posts into the main conversation for the Claude MCP.
|
|
454
|
+
sendConversationId: () => CONVERSATION_ID,
|
|
455
|
+
sendConversationToken: () => CONVERSATION_TOKEN,
|
|
456
|
+
// Stream the opened DM so the peer's reply surfaces here (deduped by the Map;
|
|
457
|
+
// a re-join replaces the stream). ws_url is the main conversation's voice host.
|
|
458
|
+
startDmStream: (dm) =>
|
|
459
|
+
startDmStream({
|
|
460
|
+
conversation_id: dm.conversation_id,
|
|
461
|
+
conversation_token: dm.conversation_token,
|
|
462
|
+
ws_url: VOICE_BASE_URL,
|
|
463
|
+
expires_at: dm.expires_at,
|
|
464
|
+
}),
|
|
465
|
+
};
|
|
466
|
+
|
|
351
467
|
// NOTE: CONVERSATION_TOKEN, CONVERSATION_ID, BASE_URL are populated after
|
|
352
468
|
// mintConversation() runs (below). The handler closure captures the variables
|
|
353
469
|
// by reference — by the time Claude Code calls the tool, they are populated.
|
|
354
470
|
mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
};
|
|
471
|
+
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
472
|
+
|
|
473
|
+
if (request.params.name === "list_agents") {
|
|
474
|
+
return libListAgents(channelToolsCtx);
|
|
360
475
|
}
|
|
361
476
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (typeof text !== "string" || text.trim().length === 0) {
|
|
365
|
-
return {
|
|
366
|
-
isError: true,
|
|
367
|
-
content: [{ type: "text" as const, text: "Invalid input: text must be a non-empty string" }],
|
|
368
|
-
};
|
|
477
|
+
if (request.params.name === "message_agent") {
|
|
478
|
+
return libMessageAgent(channelToolsCtx, args);
|
|
369
479
|
}
|
|
370
480
|
|
|
371
|
-
|
|
372
|
-
if (!parsed.ok) {
|
|
481
|
+
if (request.params.name !== "send_message") {
|
|
373
482
|
return {
|
|
374
483
|
isError: true,
|
|
375
|
-
content: [{ type: "text" as const, text: `
|
|
484
|
+
content: [{ type: "text" as const, text: `Unknown tool: ${request.params.name}` }],
|
|
376
485
|
};
|
|
377
486
|
}
|
|
378
|
-
const artifact = parsed.artifact;
|
|
379
487
|
|
|
380
|
-
|
|
488
|
+
// send_message: the shared lib validates input, guards the startup window, and
|
|
489
|
+
// posts (with artifact). It rethrows ConversationTokenExpiredError because the
|
|
490
|
+
// teardown policy is host-specific — here, Claude is notified and the process
|
|
491
|
+
// exits 3 (the bridge handles the same error as a controlled shutdown).
|
|
381
492
|
try {
|
|
382
|
-
|
|
493
|
+
return await libSendMessage(channelToolsCtx, args);
|
|
383
494
|
} catch (err) {
|
|
384
495
|
if (err instanceof ConversationTokenExpiredError) {
|
|
385
|
-
// Notify Claude (and the user) that the channel token has expired
|
|
386
496
|
await handleConversationTokenExpired(mcp);
|
|
387
497
|
// handleConversationTokenExpired calls process.exit(3) — unreachable, but
|
|
388
498
|
// TypeScript needs a return to satisfy the type checker.
|
|
@@ -396,17 +506,6 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
396
506
|
content: [{ type: "text" as const, text: `Unexpected error: ${String(err)}` }],
|
|
397
507
|
};
|
|
398
508
|
}
|
|
399
|
-
|
|
400
|
-
if (!result.ok) {
|
|
401
|
-
return {
|
|
402
|
-
isError: true,
|
|
403
|
-
content: [{ type: "text" as const, text: result.detail }],
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return {
|
|
408
|
-
content: [{ type: "text" as const, text: `Message sent: ${result.message_id}` }],
|
|
409
|
-
};
|
|
410
509
|
});
|
|
411
510
|
|
|
412
511
|
// Prevent duplicate instances (#166); fingerprint namespacing for different
|
|
@@ -440,6 +539,27 @@ if (blockingPid !== null) {
|
|
|
440
539
|
process.exit(1);
|
|
441
540
|
}
|
|
442
541
|
|
|
542
|
+
// ---- Parent-liveness watchdog (#298) ----
|
|
543
|
+
//
|
|
544
|
+
// Start it HERE — right after mcp.connect(), BEFORE the stability gate and the
|
|
545
|
+
// (slow) conversation mint — so the agent (claude.exe) is still alive when we
|
|
546
|
+
// read the process table to resolve it. Starting it late (after the mint) opened
|
|
547
|
+
// a window where, if Claude died first, resolveAgentPid couldn't find claude.exe
|
|
548
|
+
// and (pre-fix) fell back to a surviving shim → permanent zombie (#298 P2a).
|
|
549
|
+
//
|
|
550
|
+
// The heartbeat proves the PROCESS is alive — not that the AGENT is. If Claude
|
|
551
|
+
// dies but this process lingers (Windows stdin-EOF can fail to propagate through
|
|
552
|
+
// the cmd.exe/bunx spawn chain), it becomes a zombie that keeps beating and shows
|
|
553
|
+
// "online" with no one answering. The watchdog watches the agent PID+identity and
|
|
554
|
+
// exits the instant it's gone — the reliable backstop to the stdin "end" handler.
|
|
555
|
+
startParentWatchdog({
|
|
556
|
+
onParentGone: () => {
|
|
557
|
+
scheduler?.cancel();
|
|
558
|
+
releaseLock();
|
|
559
|
+
process.exit(0);
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
|
|
443
563
|
// ---- Stability gate (#257): defer conversation creation so a doomed process exits first ----
|
|
444
564
|
//
|
|
445
565
|
// Claude Code respawns the channel on transport reconnects; a process that dies
|
|
@@ -460,29 +580,138 @@ process.stderr.write(`[viber-channel] startup: stability gate passed (${STABILIT
|
|
|
460
580
|
|
|
461
581
|
// ---- Acquire conversation token (reattach or mint, after mcp.connect so a token-rejected notification can be sent) ----
|
|
462
582
|
|
|
463
|
-
|
|
464
|
-
let
|
|
465
|
-
|
|
583
|
+
// Empty until the conversation is acquired below (after mcp.connect + the
|
|
584
|
+
// stability gate). Initialised (not bare `let x: string`) so the send_message
|
|
585
|
+
// handler can safely guard on them during the startup window without hitting a
|
|
586
|
+
// temporal-dead-zone ReferenceError if a tool call lands before this point.
|
|
587
|
+
let CONVERSATION_TOKEN = "";
|
|
588
|
+
let VOICE_BASE_URL = "";
|
|
589
|
+
let CONVERSATION_ID = "";
|
|
590
|
+
let SESSION_FILE = "";
|
|
591
|
+
// This channel's own server-issued instance id (#280 step-16). Used by the
|
|
592
|
+
// self-echo guard: messages posted by this instance are NOT surfaced back to
|
|
593
|
+
// Claude when the conversation SSE replays them.
|
|
594
|
+
let OWN_INSTANCE_ID = "";
|
|
595
|
+
// This channel's durable instance_token (#288). Captured at startup so the
|
|
596
|
+
// agent-facing MCP tools (list_agents, message_agent) can authenticate to the
|
|
597
|
+
// peers / DM endpoints. Empty until acquireInstance runs below.
|
|
598
|
+
let INSTANCE_TOKEN = "";
|
|
599
|
+
|
|
600
|
+
// Agent↔agent DM conversations we are actively streaming (#288 step-04), keyed
|
|
601
|
+
// by conversation_id → the AbortController for its SSE loop. A re-pushed `join`
|
|
602
|
+
// (peer re-opened the DM, e.g. with a fresh token) ABORTS the old stream and
|
|
603
|
+
// starts a new one, so a stuck/stale stream can never block a fresh join.
|
|
604
|
+
const activeDmStreams = new Map<string, AbortController>();
|
|
466
605
|
|
|
467
|
-
process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE})\n`);
|
|
468
606
|
try {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
607
|
+
// #269: acquire this channel's server-issued instance identity — env-injected
|
|
608
|
+
// VIBER_INSTANCE_TOKEN > persisted auth.json instance_token > register a new
|
|
609
|
+
// one with the stable project_token (self-healing auth.json). The conversation
|
|
610
|
+
// handle is namespaced by the instance, not the local fingerprint/sessionId.
|
|
611
|
+
const acquired = await acquireInstance(BASE_URL, auth, auth.client_fingerprint);
|
|
612
|
+
let instanceToken = acquired.instance_token;
|
|
613
|
+
// Self-echo guard (#280 step-16) needs the REAL server instance id — never the
|
|
614
|
+
// token that instance_key may fall back to (Codex review P1). Undefined → ""
|
|
615
|
+
// → the guard is a no-op (delivers everything), which is the safe default.
|
|
616
|
+
OWN_INSTANCE_ID = acquired.instance_id ?? "";
|
|
617
|
+
SESSION_FILE = sessionFilePath(LOCK_BASE_URL, acquired.instance_key, LOCK_DIR);
|
|
618
|
+
|
|
619
|
+
process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE})\n`);
|
|
620
|
+
|
|
621
|
+
// Join the conversation with the instance_token. A 401 means the instance was
|
|
622
|
+
// revoked (the token is durable — no expiry case): re-register a fresh instance
|
|
623
|
+
// ONCE (a NEW instance_id, the correct post-revocation outcome) and retry. A
|
|
624
|
+
// second failure falls through to the catch below.
|
|
625
|
+
let minted: ConversationMintResponse;
|
|
626
|
+
try {
|
|
627
|
+
minted = await acquireConversation(
|
|
628
|
+
SESSION_FILE,
|
|
629
|
+
BASE_URL,
|
|
630
|
+
auth.project_id,
|
|
631
|
+
instanceToken,
|
|
632
|
+
auth.client_fingerprint,
|
|
633
|
+
label,
|
|
634
|
+
auth.target_conversation_id ?? null,
|
|
635
|
+
REATTACH_WINDOW_SECONDS,
|
|
636
|
+
);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
if (err instanceof ConversationMintError && err.status === 401) {
|
|
639
|
+
process.stderr.write(
|
|
640
|
+
`[viber-channel] instance token rejected (401 revoked) — re-registering a fresh instance once\n`,
|
|
641
|
+
);
|
|
642
|
+
const reg = await registerInstance(
|
|
643
|
+
BASE_URL,
|
|
644
|
+
auth.project_id,
|
|
645
|
+
auth.project_token,
|
|
646
|
+
auth.client_fingerprint,
|
|
647
|
+
);
|
|
648
|
+
persistInstanceCredentials(authFilePath(), reg.instance_id, reg.instance_token);
|
|
649
|
+
instanceToken = reg.instance_token;
|
|
650
|
+
OWN_INSTANCE_ID = reg.instance_id;
|
|
651
|
+
SESSION_FILE = sessionFilePath(LOCK_BASE_URL, reg.instance_id, LOCK_DIR);
|
|
652
|
+
minted = await acquireConversation(
|
|
653
|
+
SESSION_FILE,
|
|
654
|
+
BASE_URL,
|
|
655
|
+
auth.project_id,
|
|
656
|
+
instanceToken,
|
|
657
|
+
auth.client_fingerprint,
|
|
658
|
+
label,
|
|
659
|
+
auth.target_conversation_id ?? null,
|
|
660
|
+
REATTACH_WINDOW_SECONDS,
|
|
661
|
+
);
|
|
662
|
+
} else {
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
479
666
|
|
|
480
|
-
// project_token
|
|
481
|
-
//
|
|
667
|
+
// project_token / instance_token are stable, long-lived credentials (#274/#269);
|
|
668
|
+
// neither is rotated by the mint response. The membership conversation_token
|
|
669
|
+
// (below) is the short-lived tier and self-refreshes.
|
|
482
670
|
|
|
483
671
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
484
672
|
VOICE_BASE_URL = minted.ws_url;
|
|
485
673
|
CONVERSATION_ID = minted.conversation_id;
|
|
674
|
+
// Capture the durable instance_token for the agent-facing MCP tools (#288).
|
|
675
|
+
INSTANCE_TOKEN = instanceToken;
|
|
676
|
+
|
|
677
|
+
// #280 step-27: hold the instance control stream open for the WHOLE session —
|
|
678
|
+
// it IS the "online" presence signal (step-02). Without it this agent never
|
|
679
|
+
// shows online in the control plane (sidebar Agents) and is not invitable.
|
|
680
|
+
// A pushed `join` is an agent↔agent DM invite (#288 step-04): we run a DM SSE
|
|
681
|
+
// for it IN PARALLEL with the main conversation (coexistence — the main
|
|
682
|
+
// conversation stays bound to its own SSE, option A). A `stop` (revoke) is
|
|
683
|
+
// logged only: the conversation-token path already fails closed.
|
|
684
|
+
if (OWN_INSTANCE_ID !== "") {
|
|
685
|
+
const controlStream = runPersistentControlStream(
|
|
686
|
+
{ baseUrl: BASE_URL, instanceId: OWN_INSTANCE_ID, instanceToken },
|
|
687
|
+
{
|
|
688
|
+
log: (m) => process.stderr.write(m),
|
|
689
|
+
onJoin: (pushed) => startDmStream(pushed),
|
|
690
|
+
onStop: (reason) =>
|
|
691
|
+
process.stderr.write(`[viber-channel] control stream stopped (${reason})\n`),
|
|
692
|
+
// #311: the server is probing — answer with an immediate instance heartbeat.
|
|
693
|
+
onBeatNow: () => {
|
|
694
|
+
void sendInstanceHeartbeat(BASE_URL, OWN_INSTANCE_ID, INSTANCE_TOKEN);
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
);
|
|
698
|
+
// Presence-only consumer: firstJoin ALWAYS settles (rejected at loop end) —
|
|
699
|
+
// observe it so an ignored join/stop never becomes an unhandled rejection.
|
|
700
|
+
controlStream.firstJoin.catch(() => {});
|
|
701
|
+
controlStream.done.catch(() => {});
|
|
702
|
+
// #311: beat the INSTANCE control-stream liveness so the server's sweeper
|
|
703
|
+
// never keeps this agent "online" after it dies (the control SSE can stay
|
|
704
|
+
// half-open behind the tunnel). The instance_token is durable (#269/#274),
|
|
705
|
+
// read via closure so a re-register is picked up. unref'd internally.
|
|
706
|
+
const instanceHeartbeat = startInstanceHeartbeat({
|
|
707
|
+
baseUrl: BASE_URL,
|
|
708
|
+
instanceId: OWN_INSTANCE_ID,
|
|
709
|
+
getInstanceToken: () => INSTANCE_TOKEN,
|
|
710
|
+
});
|
|
711
|
+
// Stop beating once the control stream ends (revoke / 401 / abort) so a
|
|
712
|
+
// doomed instance doesn't keep beating (Opus review).
|
|
713
|
+
controlStream.done.finally(() => instanceHeartbeat.stop());
|
|
714
|
+
}
|
|
486
715
|
|
|
487
716
|
// Schedule silent token refresh ahead of expiry (#237 step-03). On success
|
|
488
717
|
// we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
|
|
@@ -600,9 +829,13 @@ try {
|
|
|
600
829
|
// ---- Channel ready banner ----
|
|
601
830
|
|
|
602
831
|
process.stderr.write(
|
|
603
|
-
`[viber-channel] Channel ready:
|
|
832
|
+
`[viber-channel] Channel ready: 3 tools (send_message, list_agents, message_agent), SSE on /api/conversations/${CONVERSATION_ID}/events\n`
|
|
604
833
|
);
|
|
605
834
|
|
|
835
|
+
// Channel liveness is proven by the INSTANCE heartbeat (#311, started above), not
|
|
836
|
+
// a per-conversation beat — the legacy conversation heartbeat (#298) was removed
|
|
837
|
+
// in #315 once "Active" derived from instance liveness.
|
|
838
|
+
|
|
606
839
|
// ---- URLs and auth headers ----
|
|
607
840
|
|
|
608
841
|
/** Primary SSE endpoint: conversation-scoped event bus (step-03 / #221). */
|
|
@@ -653,14 +886,33 @@ const messageDedup = new MessageDedup();
|
|
|
653
886
|
* so downstream consumers (step-03 React viewer) can opt-in without a second
|
|
654
887
|
* round-trip; the channel itself does no special rendering of it yet.
|
|
655
888
|
*/
|
|
656
|
-
async function pushMessage(
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
889
|
+
async function pushMessage(
|
|
890
|
+
msg: {
|
|
891
|
+
content: string;
|
|
892
|
+
id?: string | null;
|
|
893
|
+
source?: string;
|
|
894
|
+
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
895
|
+
sender_instance_id?: string | null;
|
|
896
|
+
},
|
|
897
|
+
// Provenance for an agent↔agent DM (#288 step-04): when set, the message is
|
|
898
|
+
// tagged as coming from a peer DM so the model knows which conversation it is
|
|
899
|
+
// and which instance to reply to (via message_agent), distinct from the main
|
|
900
|
+
// conversation's voice/text flow.
|
|
901
|
+
origin?: { conversationId: string },
|
|
902
|
+
): Promise<void> {
|
|
662
903
|
process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
|
|
663
|
-
|
|
904
|
+
// Never echo this channel's OWN posted messages back to Claude (#280 step-16):
|
|
905
|
+
// send_message posts as this instance, and the conversation SSE replays it.
|
|
906
|
+
if (isOwnMessage(msg.sender_instance_id, OWN_INSTANCE_ID)) {
|
|
907
|
+
process.stderr.write(`[viber-channel] dropping own posted message (self-echo)\n`);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// The dedup is for the main conversation's ephemeral+persisted DOUBLE delivery
|
|
911
|
+
// of user voice transcripts. DM messages (origin set) don't have that problem,
|
|
912
|
+
// and the dedup keys on content alone — running DMs through it would let a DM
|
|
913
|
+
// and a main-conversation message with identical short text (e.g. "ok") drop
|
|
914
|
+
// each other within the TTL (Codex + reviewer P1). So skip dedup for DMs.
|
|
915
|
+
if (!origin && messageDedup.isDuplicate(msg)) {
|
|
664
916
|
process.stderr.write(
|
|
665
917
|
`[viber-channel] dropping duplicate persisted message (already delivered ephemerally)\n`,
|
|
666
918
|
);
|
|
@@ -668,7 +920,20 @@ async function pushMessage(msg: {
|
|
|
668
920
|
}
|
|
669
921
|
// MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
|
|
670
922
|
// null fields are rejected as invalid type. Omit message_id when missing.
|
|
671
|
-
const meta: Record<string, unknown> =
|
|
923
|
+
const meta: Record<string, unknown> = origin
|
|
924
|
+
? { source: "agent-dm", conversation_id: origin.conversationId }
|
|
925
|
+
: { source: msg.source ?? "conversation" };
|
|
926
|
+
if (origin && typeof msg.sender_instance_id === "string" && msg.sender_instance_id.length > 0) {
|
|
927
|
+
// The id the model passes to message_agent to reply to this peer.
|
|
928
|
+
meta.from_instance_id = msg.sender_instance_id;
|
|
929
|
+
} else if (origin) {
|
|
930
|
+
// No sender id on a DM message → the model can still see conversation_id but
|
|
931
|
+
// has no instance to reply to. Server-posted DM messages always carry
|
|
932
|
+
// sender_instance_id today; this branch is a guard for malformed payloads.
|
|
933
|
+
process.stderr.write(
|
|
934
|
+
`[viber-channel] DM message on ${origin.conversationId} has no sender_instance_id — reply not routable\n`,
|
|
935
|
+
);
|
|
936
|
+
}
|
|
672
937
|
if (typeof msg.id === "string" && msg.id.length > 0) {
|
|
673
938
|
meta.message_id = msg.id;
|
|
674
939
|
}
|
|
@@ -687,6 +952,45 @@ async function pushMessage(msg: {
|
|
|
687
952
|
}
|
|
688
953
|
}
|
|
689
954
|
|
|
955
|
+
/**
|
|
956
|
+
* Start streaming an agent↔agent DM (#288 step-04) pushed via the control
|
|
957
|
+
* stream. Dedups by conversation_id; surfaces each incoming message to the
|
|
958
|
+
* model with DM provenance (origin). Runs for the DM's lifetime in parallel
|
|
959
|
+
* with the main conversation; clears its dedup slot when the stream ends.
|
|
960
|
+
*/
|
|
961
|
+
function startDmStream(minted: ConversationMintResponse): void {
|
|
962
|
+
// A re-pushed join for a live DM means the peer re-opened it (likely a fresh
|
|
963
|
+
// token). Abort the old stream and start a new one with the new credentials,
|
|
964
|
+
// rather than ignoring the join and leaving a possibly-stale stream running.
|
|
965
|
+
const prior = activeDmStreams.get(minted.conversation_id);
|
|
966
|
+
if (prior) {
|
|
967
|
+
process.stderr.write(
|
|
968
|
+
`[viber-channel] DM ${minted.conversation_id} re-joined — replacing existing stream\n`,
|
|
969
|
+
);
|
|
970
|
+
prior.abort();
|
|
971
|
+
}
|
|
972
|
+
const controller = new AbortController();
|
|
973
|
+
activeDmStreams.set(minted.conversation_id, controller);
|
|
974
|
+
process.stderr.write(`[viber-channel] joining agent DM ${minted.conversation_id}\n`);
|
|
975
|
+
// The agent's liveness (and thus the DM's presence in the "Active" sidebar) is
|
|
976
|
+
// proven by the INSTANCE heartbeat, not a per-conversation beat — the legacy
|
|
977
|
+
// per-DM conversation heartbeat (#298) was removed in #315.
|
|
978
|
+
void runDmStream({
|
|
979
|
+
wsUrl: minted.ws_url,
|
|
980
|
+
conversationId: minted.conversation_id,
|
|
981
|
+
conversationToken: minted.conversation_token,
|
|
982
|
+
log: (m) => process.stderr.write(m),
|
|
983
|
+
signal: controller.signal,
|
|
984
|
+
onMessage: (msg) => pushMessage(msg, { conversationId: minted.conversation_id }),
|
|
985
|
+
}).finally(() => {
|
|
986
|
+
// Only evict if WE are still the registered controller — a newer join may
|
|
987
|
+
// have replaced us, and it must keep its slot.
|
|
988
|
+
if (activeDmStreams.get(minted.conversation_id) === controller) {
|
|
989
|
+
activeDmStreams.delete(minted.conversation_id);
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
690
994
|
/** SSE stream — preferred method (#170) */
|
|
691
995
|
async function sseLoop(): Promise<void> {
|
|
692
996
|
const headers = getHeaders();
|
|
@@ -776,6 +1080,7 @@ async function sseLoop(): Promise<void> {
|
|
|
776
1080
|
role?: string;
|
|
777
1081
|
timestamp?: string;
|
|
778
1082
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
1083
|
+
sender_instance_id?: string | null;
|
|
779
1084
|
};
|
|
780
1085
|
if (msg.content) {
|
|
781
1086
|
await pushMessage(msg);
|