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/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 so channels targeting different backends
78
- // (staging + dev) coexist on the same machine — only same-backend duplicates
79
- // still collide, which is the intended guard.
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
- const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_DIR);
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, updateAuthToken } from "./lib/auth.ts";
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
- let auth = loadAuth();
290
+ const auth = loadAuth();
182
291
 
183
292
  // ---- Fingerprint verification ----
184
-
185
- import { clientFingerprint } from "./lib/fingerprint.ts";
186
-
187
- const computedFp = clientFingerprint(process.cwd());
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 — \`.viber/auth.json\` was issued for a different machine/folder.\n` +
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
- process.stderr.write(`[viber-channel] startup: pid=${process.pid}, acquiring lock\n`);
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
- // ---- Mint conversation token (after mcp.connect so reverify notifications can be sent) ----
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
- const minted = await mintConversation(
374
- BASE_URL,
375
- auth.project_id,
376
- auth.project_token,
377
- auth.client_fingerprint,
378
- label,
379
- auth.target_conversation_id ?? null,
380
- );
381
- process.stderr.write(`[viber-channel] startup: mint OK, conv_id=${minted.conversation_id}\n`);
382
-
383
- // Apply token rotation if the server issued a new project_token
384
- if (minted.new_project_token) {
385
- try {
386
- auth = updateAuthToken(process.cwd(), auth, minted.new_project_token);
387
- } catch (err) {
388
- process.stderr.write(`[viber-channel] Warning: failed to update .viber/auth.json: ${String(err)}\n`);
389
- // Don't exit — token is valid in memory; auth.json will be stale but channel keeps running
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
- if (err instanceof ReverifyRequiredError) {
485
- await mcp.notification({
486
- method: "notifications/claude/channel",
487
- params: {
488
- content: `⚠️ Re-verification required (reason: ${err.reason}). Visit ${err.url} to re-authenticate, then restart this channel.`,
489
- meta: { source: "system", type: "reverify_required", url: err.url },
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 https://viber.dgypx.dev/projects.\n`
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);