viber-channel 0.5.3 → 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 +50 -1
- package/lib/bridge_spawn.ts +105 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +307 -0
- package/lib/conversation.ts +23 -9
- package/lib/instance.ts +137 -0
- package/lib/messages.ts +24 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +292 -0
- package/lib/supervisor_config.ts +121 -0
- package/package.json +7 -2
- package/viber-channel.ts +138 -16
- package/viber-codex-bridge.ts +1247 -0
- package/viber-codex-supervisor.ts +96 -0
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viber-channel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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": {
|
|
7
|
-
"viber-channel": "./viber-channel.ts"
|
|
7
|
+
"viber-channel": "./viber-channel.ts",
|
|
8
|
+
"viber-codex-bridge": "./viber-codex-bridge.ts",
|
|
9
|
+
"viber-codex-supervisor": "./viber-codex-supervisor.ts"
|
|
8
10
|
},
|
|
9
11
|
"files": [
|
|
10
12
|
"viber-channel.ts",
|
|
13
|
+
"viber-codex-bridge.ts",
|
|
14
|
+
"viber-codex-supervisor.ts",
|
|
11
15
|
"lib/",
|
|
12
16
|
"README.md"
|
|
13
17
|
],
|
|
@@ -31,6 +35,7 @@
|
|
|
31
35
|
"license": "MIT",
|
|
32
36
|
"scripts": {
|
|
33
37
|
"start": "bun run viber-channel.ts",
|
|
38
|
+
"start:codex-bridge": "bun run viber-codex-bridge.ts",
|
|
34
39
|
"test": "bun test"
|
|
35
40
|
},
|
|
36
41
|
"dependencies": {
|
package/viber-channel.ts
CHANGED
|
@@ -28,6 +28,7 @@ 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";
|
|
@@ -36,8 +37,13 @@ import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
|
36
37
|
import { MessageDedup } from "./lib/message_dedup.ts";
|
|
37
38
|
import {
|
|
38
39
|
acquireConversation,
|
|
40
|
+
ConversationMintError,
|
|
39
41
|
DEFAULT_REATTACH_WINDOW_SECONDS,
|
|
42
|
+
type ConversationMintResponse,
|
|
40
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";
|
|
41
47
|
import {
|
|
42
48
|
createTokenRefreshScheduler,
|
|
43
49
|
type TokenRefreshScheduler,
|
|
@@ -50,6 +56,22 @@ import { awaitStableStartup } from "./lib/startup_gate.ts";
|
|
|
50
56
|
// exits — it does not start the MCP server. Any other invocation (no args, or
|
|
51
57
|
// the unknown subcommand) falls through to the MCP server below.
|
|
52
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
|
+
|
|
53
75
|
const sub = process.argv[2];
|
|
54
76
|
if (sub === "--help" || sub === "-h") {
|
|
55
77
|
process.stdout.write(
|
|
@@ -100,10 +122,23 @@ const LOCK_DIR =
|
|
|
100
122
|
? join(process.env.APPDATA, "viber")
|
|
101
123
|
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
102
124
|
const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
103
|
-
|
|
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
|
+
: "");
|
|
104
137
|
const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
|
|
105
138
|
const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
|
|
106
|
-
|
|
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.
|
|
107
142
|
|
|
108
143
|
// VIBER_REATTACH_WINDOW_SECONDS overrides the default TTL-based window.
|
|
109
144
|
// A handle older than this cannot point at a live conversation — skip reattach.
|
|
@@ -248,7 +283,7 @@ process.stdin.on("end", () => {
|
|
|
248
283
|
|
|
249
284
|
// ---- Auth from .viber/auth.json ----
|
|
250
285
|
|
|
251
|
-
import { authFilePath, loadAuth } from "./lib/auth.ts";
|
|
286
|
+
import { authFilePath, loadAuth, persistInstanceCredentials } from "./lib/auth.ts";
|
|
252
287
|
import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
|
|
253
288
|
import { buildSseUrl } from "./lib/urls.ts";
|
|
254
289
|
|
|
@@ -463,27 +498,106 @@ process.stderr.write(`[viber-channel] startup: stability gate passed (${STABILIT
|
|
|
463
498
|
let CONVERSATION_TOKEN: string;
|
|
464
499
|
let VOICE_BASE_URL: string;
|
|
465
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 = "";
|
|
466
506
|
|
|
467
|
-
process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE})\n`);
|
|
468
507
|
try {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
479
567
|
|
|
480
|
-
// project_token
|
|
481
|
-
//
|
|
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.
|
|
482
571
|
|
|
483
572
|
CONVERSATION_TOKEN = minted.conversation_token;
|
|
484
573
|
VOICE_BASE_URL = minted.ws_url;
|
|
485
574
|
CONVERSATION_ID = minted.conversation_id;
|
|
486
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
|
+
|
|
487
601
|
// Schedule silent token refresh ahead of expiry (#237 step-03). On success
|
|
488
602
|
// we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
|
|
489
603
|
// so subsequent SSE reconnects and send_message calls pick up the new token
|
|
@@ -658,8 +772,15 @@ async function pushMessage(msg: {
|
|
|
658
772
|
id?: string | null;
|
|
659
773
|
source?: string;
|
|
660
774
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
775
|
+
sender_instance_id?: string | null;
|
|
661
776
|
}): Promise<void> {
|
|
662
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
|
+
}
|
|
663
784
|
if (messageDedup.isDuplicate(msg)) {
|
|
664
785
|
process.stderr.write(
|
|
665
786
|
`[viber-channel] dropping duplicate persisted message (already delivered ephemerally)\n`,
|
|
@@ -776,6 +897,7 @@ async function sseLoop(): Promise<void> {
|
|
|
776
897
|
role?: string;
|
|
777
898
|
timestamp?: string;
|
|
778
899
|
artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
|
|
900
|
+
sender_instance_id?: string | null;
|
|
779
901
|
};
|
|
780
902
|
if (msg.content) {
|
|
781
903
|
await pushMessage(msg);
|