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/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
- const LOCK_SESSION_ID = process.env.VIBER_CHANNEL_SESSION_ID ?? "";
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
- const SESSION_FILE = sessionFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
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 is running
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 instance already running (PID ${existingPid}), exiting.\n`
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 { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
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. 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.",
295
- "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.",
296
- "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.",
297
- "Available formats: `markdown` (default), `code`, `json`, `html`.",
298
- "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.",
299
- "Rule of thumb: if it would sound bad read aloud, it belongs in the artifact, not the text.",
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 spoken/conversational reply (read aloud — keep it short and natural, no markdown). " +
314
- "`artifact` is optional and carries technical content (markdown, code, JSON, or sanitized HTML) rendered in a side viewer. " +
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: "Spoken/conversational reply. Plain sentences, no markdown symbols or lists.",
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
- if (request.params.name !== "send_message") {
356
- return {
357
- isError: true,
358
- content: [{ type: "text" as const, text: `Unknown tool: ${request.params.name}` }],
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
- const args = (request.params.arguments ?? {}) as Record<string, unknown>;
363
- const text = args.text;
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
- const parsed = parseArtifact(args.artifact);
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: `Invalid input: ${parsed.error}` }],
484
+ content: [{ type: "text" as const, text: `Unknown tool: ${request.params.name}` }],
376
485
  };
377
486
  }
378
- const artifact = parsed.artifact;
379
487
 
380
- let result;
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
- result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text, artifact);
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
- let CONVERSATION_TOKEN: string;
464
- let VOICE_BASE_URL: string;
465
- let CONVERSATION_ID: string;
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
- 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
- );
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 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.
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: 1 tool (send_message), SSE on /api/conversations/${CONVERSATION_ID}/events\n`
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(msg: {
657
- content: string;
658
- id?: string | null;
659
- source?: string;
660
- artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
661
- }): Promise<void> {
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
- if (messageDedup.isDuplicate(msg)) {
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> = { source: msg.source ?? "conversation" };
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);