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/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.5.3",
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
- const LOCK_SESSION_ID = process.env.VIBER_CHANNEL_SESSION_ID ?? "";
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
- const SESSION_FILE = sessionFilePath(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.
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
- const minted = await acquireConversation(
470
- SESSION_FILE,
471
- BASE_URL,
472
- auth.project_id,
473
- auth.project_token,
474
- auth.client_fingerprint,
475
- label,
476
- auth.target_conversation_id ?? null,
477
- REATTACH_WINDOW_SECONDS,
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 is now a stable, long-lived handshake credential (#274). It is
481
- // never rotated by the mint response, so auth.json is never rewritten here.
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);