viber-channel 0.7.1 → 0.8.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.
@@ -22,7 +22,7 @@ import {
22
22
  RefreshNetworkError,
23
23
  refreshConversationToken,
24
24
  } from "./conversation.js";
25
- import { ConversationTokenExpiredError, fetchMessages, postMessage } from "./messages.js";
25
+ import { ackMessage, ConversationTokenExpiredError, fetchMessages, postMessage } from "./messages.js";
26
26
  import { buildSseUrl } from "./urls.js";
27
27
  import { cfAccessHeaders } from "./cfAccess.js";
28
28
  import { sessionFilePath, writeHandle } from "./channel_session.js";
@@ -534,8 +534,49 @@ export async function sseLoop(opts: {
534
534
  const primeHistory = options.awaitInvite !== true;
535
535
  let primed = false;
536
536
 
537
+ // #346 read-ACK bookkeeping. Tracked SEPARATELY from handledIds so a transient
538
+ // ACK failure is RETRIED on the next reconnect/catch-up instead of being lost
539
+ // forever (codex step-02 review): handledIds gates the reply (at-most-once),
540
+ // ackedIds gates the ACK (at-least-once-until-confirmed). ackInFlightIds avoids
541
+ // spamming concurrent ACKs for the same message within a run.
542
+ const ackedIds = new Set<string>();
543
+ const ackInFlightIds = new Set<string>();
544
+
545
+ // Emit a read-acknowledgement for a web→agent message. Run by the bridge CODE
546
+ // (not the LLM), fire-and-forget, BEFORE any reply — so the web "reçu" checkmark
547
+ // has zero impact on agent reply latency. Only role=user|user_voice with no
548
+ // sender_instance_id qualify (mirrors the Worker; the Worker is the source of
549
+ // truth and 404s anything else). Idempotent server-side, so re-acking on
550
+ // reconnect is safe; we only mark ackedIds on a confirmed success so a failed
551
+ // ACK is retried. Never throws (ackMessage swallows).
552
+ // TODO(#346/#280 s19): when addressed_to is revived (per-instance targeting),
553
+ // gate this on "this instance is an intended recipient" — otherwise an agent Y
554
+ // receiving the per-conversation SSE fan-out would ACK a message addressed to
555
+ // agent X, lighting "reçu" while X never received it. Mirror the same guard in
556
+ // the Worker's insertMessageAck qualification. N/A today: addressed_to is
557
+ // dormant and fan-out is per-conversation, so every connected bridge truly
558
+ // receives the message (consistent with decision A = delivered-to-bridge).
559
+ function ackInboundReceipt(msg: ConversationMessage): void {
560
+ if (msg.role !== "user" && msg.role !== "user_voice") return;
561
+ if (msg.sender_instance_id) return;
562
+ const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
563
+ if (!idStr) return;
564
+ if (ackedIds.has(idStr) || ackInFlightIds.has(idStr)) return;
565
+ ackInFlightIds.add(idStr);
566
+ void ackMessage(baseUrl, runtime.id, runtime.token, idStr, signal)
567
+ .then((ok) => {
568
+ if (ok) ackedIds.add(idStr);
569
+ else process.stderr.write(`${logPrefix} ack failed for message ${idStr} (will retry on reconnect)\n`);
570
+ })
571
+ .finally(() => ackInFlightIds.delete(idStr));
572
+ }
573
+
537
574
  async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
538
575
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
576
+ // Confirm receipt to the web INDEPENDENTLY of handled/reply state, BEFORE the
577
+ // handledIds early-return — so a previously-failed ACK is retried when this
578
+ // message comes back through catch-up on reconnect (codex step-02 review).
579
+ ackInboundReceipt(msg);
539
580
  if (idStr && handledIds.has(idStr)) return false;
540
581
  if (!shouldHandleMessage(msg, ownInstanceKey, ownPostedIds, options)) {
541
582
  if (idStr) handledIds.add(idStr); // skip decision is final — role won't change
@@ -575,6 +616,10 @@ export async function sseLoop(opts: {
575
616
  const seed = (msg: ConversationMessage): void => {
576
617
  const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
577
618
  if (idStr) handledIds.add(idStr);
619
+ // #346: a bridge that was offline still RECEIVED these messages on
620
+ // reconnect — ACK seeded web→agent messages so offline→online shows
621
+ // "reçu" even though we won't reply to backlog.
622
+ ackInboundReceipt(msg);
578
623
  };
579
624
 
580
625
  // FIRST await-invite pass: bound the replay to the TRIGGER only — forward
@@ -796,14 +841,23 @@ export function buildAgentIdentityInstructions(opts: {
796
841
 
797
842
  /**
798
843
  * Read the agent identity from the spawn env and build the identity line.
799
- * `VIBER_AGENT_NAME_EXPLICIT === "1"` gates whether `VIBER_CODEX_BRIDGE_LABEL`
800
- * is treated as an explicit name (the label is always set — even for auto ids —
801
- * so the separate flag is the only safe signal). Shared by every bridge.
844
+ * `VIBER_AGENT_NAME_EXPLICIT === "1"` gates whether the spawn label is treated as
845
+ * an explicit name (the label is always set — even for auto ids — so the separate
846
+ * flag is the only safe signal). Shared by every runtime:
847
+ * - the codex/gemma bridges set `VIBER_CODEX_BRIDGE_LABEL`;
848
+ * - a Claude terminal session (#328) has no bridge — its label is
849
+ * `VIBER_CHANNEL_LABEL` (set by buildClaudeLaunchSpec) — so fall back to it.
802
850
  */
803
851
  export function agentIdentityFromEnv(): string {
804
852
  const explicit = process.env.VIBER_AGENT_NAME_EXPLICIT === "1";
853
+ // A BLANK bridge label counts as absent (defense in depth, #328): the terminal
854
+ // launcher wipes an inherited VIBER_CODEX_BRIDGE_LABEL to "", so a Claude
855
+ // session falls through to its own VIBER_CHANNEL_LABEL instead of a leaked
856
+ // parent bridge name.
857
+ const bridgeLabel = process.env.VIBER_CODEX_BRIDGE_LABEL?.trim();
858
+ const label = bridgeLabel || process.env.VIBER_CHANNEL_LABEL;
805
859
  return buildAgentIdentityInstructions({
806
- explicitName: explicit ? process.env.VIBER_CODEX_BRIDGE_LABEL : undefined,
860
+ explicitName: explicit ? label : undefined,
807
861
  role: process.env.VIBER_AGENT_ROLE,
808
862
  });
809
863
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Control-plane capabilities help (#332).
3
+ *
4
+ * An agent connected via the channel does not otherwise know that a `vibe-master`
5
+ * control plane exists on the machine, nor how to spawn other agents with it. Per
6
+ * the issue + JP: this must be DISCOVERABLE ON DEMAND, never injected into every
7
+ * conversation. So the channel exposes a `capabilities` MCP tool that returns
8
+ * this text only when the agent chooses to call it; the channel instructions
9
+ * carry just a one-line pointer to the tool, not its contents.
10
+ *
11
+ * Pure (no I/O) so it is trivially testable and identical across hosts.
12
+ */
13
+
14
+ export function capabilitiesText(): string {
15
+ return [
16
+ "Viber control plane (vibe-master) — orchestrate other agents from this machine.",
17
+ "",
18
+ "vibe-master is a CLI + TUI (installed on the operator machine) that spawns and",
19
+ "supervises agents which appear in the Viber web UI and answer in their own",
20
+ "conversations. Use it when you want to delegate to another agent (e.g. a Codex",
21
+ "reviewer, a second Claude, or a Gemma chat).",
22
+ "",
23
+ "Runtimes: codex (background, via vctl) · gemma (background) · claude (terminal).",
24
+ "(--runtime defaults to codex. Gemma is conversational/read-only only — it",
25
+ " rejects --permission read-write.)",
26
+ "",
27
+ "Spawn one agent (CLI, non-interactive):",
28
+ " vibe-master spawn --permission <read-only|read-write> \\",
29
+ " [--runtime codex|claude|gemma] [--name <id>]",
30
+ " → only --permission is required; --runtime and --name are optional",
31
+ " (--name auto-generated if omitted). Prints the agent id; it registers",
32
+ " in Viber and comes online.",
33
+ "",
34
+ "Interactive: `vibe-master tui` (menu: + Add agent → runtime → permission → name).",
35
+ "Inspect / stop: `vibe-master list` · `vibe-master kill <id>`.",
36
+ "",
37
+ "Talk to a spawned agent from here with list_agents + message_agent.",
38
+ "Requires vibe-master installed on the machine (see the Install page on the web).",
39
+ "Run `vibe-master --help` for the full command reference.",
40
+ ].join("\n");
41
+ }
package/lib/lockfile.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  * axis that isolates them for a normal bunx client, where sessionId is always
15
15
  * "" (the #259 sessionId axis below provides no isolation there).
16
16
  *
17
- * #259 adds the per-instance axis: when the launcher (e.g. viber-dev.ps1)
18
- * supplies a per-launch session id via VIBER_CHANNEL_SESSION_ID, the lock is
17
+ * #259 adds the per-instance axis: when a launcher supplies a per-launch session
18
+ * id via VIBER_CHANNEL_SESSION_ID, the lock is
19
19
  * additionally namespaced by it, so two concurrent launches of the *same*
20
20
  * project each get their own lock. Channel respawns within one launch inherit
21
21
  * the same env var and reuse the same lock — accidental duplicates are still
package/lib/messages.ts CHANGED
@@ -95,6 +95,37 @@ export async function fetchMessages(
95
95
  return Array.isArray(body.messages) ? body.messages : [];
96
96
  }
97
97
 
98
+ /**
99
+ * POST /api/conversations/:id/messages/:messageId/ack — read-acknowledgement (#346).
100
+ *
101
+ * The bridge CODE (not the LLM) confirms receipt of a web→agent message so the
102
+ * web UI can show a "reçu" checkmark, with zero impact on agent reply latency.
103
+ *
104
+ * Best-effort and NON-BLOCKING: never throws. Any failure (network, 401 expired
105
+ * token, 404 for a message the Worker deems non-ackable) resolves to `false` so
106
+ * the message-handling flow is never disrupted by the ACK. Idempotence is owned
107
+ * by the Worker (PK + INSERT OR IGNORE), so re-acking on reconnect is safe.
108
+ */
109
+ export async function ackMessage(
110
+ baseUrl: string,
111
+ conversationId: string,
112
+ conversationToken: string,
113
+ messageId: string | number,
114
+ signal?: AbortSignal,
115
+ ): Promise<boolean> {
116
+ const url = `${baseUrl}/api/conversations/${conversationId}/messages/${messageId}/ack`;
117
+ try {
118
+ const resp = await fetch(url, {
119
+ method: "POST",
120
+ headers: { Authorization: `Bearer ${conversationToken}`, ...cfAccessHeaders() },
121
+ signal,
122
+ });
123
+ return resp.ok;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
98
129
  export interface MessagePostSuccess {
99
130
  ok: true;
100
131
  message_id: string;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * viber-channel stale-version guard (#330).
3
+ *
4
+ * A globally-installed `viber-channel` that a user placed by hand (old docs) can
5
+ * fall far behind npm `latest`. When a later refactor removes/renames a lib file,
6
+ * that stale install crashes with a raw `Cannot find module './lib/…'` instead of
7
+ * anything actionable. We can't fix an ALREADY-broken old install from here (its
8
+ * code predates this guard), so the useful move is PREVENTION: on every startup
9
+ * that still runs, check whether the installed version is behind `latest` and, if
10
+ * so, print a clear one-line nudge to update — BEFORE the user ever hits a
11
+ * breaking refactor. Best-effort and non-fatal: any error (offline, timeout, odd
12
+ * registry response) is swallowed so the channel starts normally.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ const NPM_LATEST_URL = "https://registry.npmjs.org/viber-channel/latest";
19
+ const DEFAULT_TIMEOUT_MS = 1500;
20
+
21
+ /** Parse a `major.minor.patch` string to a numeric tuple; non-numeric → 0. */
22
+ function parseVersion(v: string): [number, number, number] {
23
+ const parts = v
24
+ .trim()
25
+ .replace(/^v/, "")
26
+ .split(".")
27
+ .map((p) => Number.parseInt(p, 10));
28
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
29
+ }
30
+
31
+ /** True iff `local` is strictly older than `latest` (major.minor.patch order). */
32
+ export function isOlder(local: string, latest: string): boolean {
33
+ const a = parseVersion(local);
34
+ const b = parseVersion(latest);
35
+ for (let i = 0; i < 3; i++) {
36
+ if (a[i] < b[i]) return true;
37
+ if (a[i] > b[i]) return false;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /** Read this package's own version from its package.json (best-effort → null). */
43
+ export function readLocalVersion(
44
+ readFile: (p: string) => string = (p) => readFileSync(p, "utf-8"),
45
+ dir: string = import.meta.dir,
46
+ ): string | null {
47
+ try {
48
+ const pkg = JSON.parse(readFile(join(dir, "..", "package.json")));
49
+ return typeof pkg.version === "string" ? pkg.version : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Fetch npm `latest` version with a short timeout; null on any failure. */
56
+ export async function fetchLatestVersion(
57
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
58
+ fetchImpl: typeof fetch = fetch,
59
+ ): Promise<string | null> {
60
+ const ctrl = new AbortController();
61
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
62
+ try {
63
+ const res = await fetchImpl(NPM_LATEST_URL, { signal: ctrl.signal });
64
+ if (!res.ok) return null;
65
+ const body = (await res.json()) as { version?: unknown };
66
+ return typeof body.version === "string" ? body.version : null;
67
+ } catch {
68
+ return null;
69
+ } finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+
74
+ export interface StaleCheck {
75
+ local: string;
76
+ latest: string;
77
+ stale: boolean;
78
+ }
79
+
80
+ /** Compare the installed version against npm latest. Null when either is unknown. */
81
+ export async function checkStaleVersion(opts?: {
82
+ localVersion?: string | null;
83
+ fetchLatest?: () => Promise<string | null>;
84
+ }): Promise<StaleCheck | null> {
85
+ // Honor an EXPLICIT localVersion (incl. null = "unknown"); only read the real
86
+ // package.json when the caller didn't pass the key at all.
87
+ const local =
88
+ opts && "localVersion" in opts ? opts.localVersion : readLocalVersion();
89
+ if (!local) return null;
90
+ const latest = opts?.fetchLatest
91
+ ? await opts.fetchLatest()
92
+ : await fetchLatestVersion();
93
+ if (!latest) return null;
94
+ return { local, latest, stale: isOlder(local, latest) };
95
+ }
96
+
97
+ /** The one-line nudge printed to stderr when the install is behind. */
98
+ export function staleWarning(local: string, latest: string): string {
99
+ return (
100
+ `[viber-channel] version ${local} is behind latest ${latest}. ` +
101
+ `Update to avoid a broken install: bun add -g viber-channel@latest`
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Best-effort: warn on stderr when this install is behind npm latest. Never
107
+ * throws, never blocks longer than the fetch timeout. Called at startup.
108
+ */
109
+ export async function warnIfStale(opts?: {
110
+ localVersion?: string | null;
111
+ fetchLatest?: () => Promise<string | null>;
112
+ write?: (msg: string) => void;
113
+ }): Promise<void> {
114
+ try {
115
+ const check = await checkStaleVersion(opts);
116
+ if (check?.stale) {
117
+ (opts?.write ?? ((m) => process.stderr.write(`${m}\n`)))(
118
+ staleWarning(check.local, check.latest),
119
+ );
120
+ }
121
+ } catch {
122
+ /* best-effort — never break startup on a version check */
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.7.1",
3
+ "version": "0.8.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": {
package/viber-channel.ts CHANGED
@@ -16,9 +16,9 @@
16
16
  *
17
17
  * claude --dangerously-load-development-channels server:<name>
18
18
  *
19
- * Two PowerShell launchers wrap the right flag:
20
- * - viber.ps1 → server:viber-channel (staging)
21
- * - viber-dev.ps1 → server:viber-dev-channel (this repo, dev)
19
+ * For dev in this repo, vibe-master-dev.ps1 (the multi-agent TUI launcher) wraps
20
+ * the flag: `server:viber-dev-channel` against the dev backend. Staging sessions
21
+ * use the plain user-scoped `viber-channel` registration with no launcher flag.
22
22
  *
23
23
  * CLI subcommand: `bunx viber-channel connect <claim_url>` runs the one-shot
24
24
  * claim flow that writes .viber/auth.json without requiring any Claude Code
@@ -54,6 +54,9 @@ import {
54
54
  type TokenRefreshScheduler,
55
55
  } from "./lib/token_refresh.ts";
56
56
  import { awaitStableStartup } from "./lib/startup_gate.ts";
57
+ import { agentIdentityFromEnv } from "./lib/bridge_core.ts";
58
+ import { warnIfStale } from "./lib/version_check.ts";
59
+ import { capabilitiesText } from "./lib/capabilities.ts";
57
60
 
58
61
  // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
59
62
  //
@@ -98,6 +101,11 @@ import { awaitStableStartup } from "./lib/startup_gate.ts";
98
101
  );
99
102
  process.exit(1);
100
103
  }
104
+ // #330: this is the exact path that crashed on a stale hand-placed install
105
+ // (`bunx viber-channel connect` → missing ./lib/peers.ts). Await the check
106
+ // here so the "you're behind, update" nudge shows BEFORE the connect work
107
+ // (best-effort — never throws, short timeout).
108
+ await warnIfStale();
101
109
  try {
102
110
  await runConnect(claimUrl);
103
111
  process.exit(0);
@@ -108,6 +116,11 @@ import { awaitStableStartup } from "./lib/startup_gate.ts";
108
116
  }
109
117
  }
110
118
 
119
+ // Server path (fell through the dispatch block): warn if this install is behind
120
+ // npm latest, but fire-and-forget so it never delays mcp.connect — the nudge
121
+ // lands on stderr shortly after startup (#330).
122
+ void warnIfStale();
123
+
111
124
  // Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
112
125
  // Namespaced by (VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID) so:
113
126
  // - channels at different backends (staging + dev) coexist (different base_url);
@@ -319,7 +332,7 @@ import {
319
332
  RefreshHttpError,
320
333
  RefreshNetworkError,
321
334
  } from "./lib/conversation.ts";
322
- import { ConversationTokenExpiredError } from "./lib/messages.ts";
335
+ import { ackMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
323
336
  import {
324
337
  listAgents as libListAgents,
325
338
  messageAgent as libMessageAgent,
@@ -340,18 +353,32 @@ const mcp = new Server(
340
353
  experimental: { "claude/channel": {} },
341
354
  tools: {},
342
355
  },
356
+ // #369 truncation fix: Claude Code truncates long MCP `instructions`
357
+ // ("…[truncated]") and this blob was ~3 KB → the TAIL was dropped before the
358
+ // agent saw it (that hid the agent's name #328, and threatened the #332
359
+ // pointer + exit-intent). So: CRITICAL lines are FRONT-LOADED (identity →
360
+ // core reply → actions/questions → anti-deadlock → capabilities → exit) and
361
+ // the nice-to-have style notes live at the tail where a cut is harmless.
362
+ // Condensed vs the old prose (same substance — reviewed). Identity is "" for
363
+ // JP's own session (no explicit-name flag) → the identity LINE is filtered
364
+ // out for him; the rest of the (now shorter, reordered) blob applies to every
365
+ // session including his.
343
366
  instructions: [
344
- 'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
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.",
367
+ agentIdentityFromEnv(),
368
+ // --- critical: front-loaded so truncation can never drop them ---
369
+ 'Voice transcripts arrive as <channel source="viber-channel"> events (the user\'s microphone speech). Reply with send_message. The `text` is BOTH read aloud (TTS) AND shown as Markdown — make it natural aloud AND easy to read; lead with the answer, no preamble.',
370
+ "Put ACTIONS and QUESTIONS in `text`, visibly — never bury them in the artifact. Use light Markdown (short bullet/numbered lists, **bold**) to stay readable; a short spoken list is fine.",
371
+ // #343 anti-deadlock (hands-free: the user is NOT watching the terminal).
372
+ "CRITICAL — while this channel is active, NEVER block on a terminal prompt or AskUserQuestion: the user is hands-free and cannot see the terminal, so it deadlocks. Put EVERY question or choice in send_message `text` and take the answer from the next voice transcript.",
373
+ // #332 discoverability pointer (the how-to lives in the capabilities tool).
374
+ "To orchestrate or spawn OTHER agents (Codex, Claude, Gemma) on this machine, call the `capabilities` tool for how — only when relevant.",
353
375
  "On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
354
- ].join(" "),
376
+ // --- nice-to-have style notes (safe near the tail) ---
377
+ "The `artifact` (format: markdown default, or code/json/html) is for HEAVY/LONG content (big code, large tables, long analyses, JSON, file lists); keep `text` a brief spoken summary that points to it ('details on the side'). Scripts/event handlers are stripped server-side.",
378
+ "NO EMOJI in `text` (read aloud — an emoji becomes spoken noise). Write identifiers LITERALLY (288, auth.json, viber-dev.dgypx.dev) — never spell out dots/dashes; a lone long token or path is better placed in the artifact.",
379
+ ]
380
+ .filter((line) => line.length > 0)
381
+ .join(" "),
355
382
  }
356
383
  );
357
384
 
@@ -438,6 +465,20 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
438
465
  additionalProperties: false,
439
466
  },
440
467
  },
468
+ {
469
+ name: "capabilities",
470
+ description:
471
+ "On-demand: learn what the Viber control plane (vibe-master) can do on this " +
472
+ "machine — how to spawn and supervise OTHER agents (Codex, Claude, Gemma). " +
473
+ "Call this only when orchestrating other agents is relevant; it is not part " +
474
+ "of the default context. Returns a short usage reference.",
475
+ inputSchema: {
476
+ type: "object" as const,
477
+ properties: {},
478
+ required: [],
479
+ additionalProperties: false,
480
+ },
481
+ },
441
482
  ],
442
483
  }));
443
484
 
@@ -478,6 +519,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
478
519
  return libMessageAgent(channelToolsCtx, args);
479
520
  }
480
521
 
522
+ // #332: on-demand control-plane discovery — static help, no runtime state.
523
+ if (request.params.name === "capabilities") {
524
+ return { content: [{ type: "text" as const, text: capabilitiesText() }] };
525
+ }
526
+
481
527
  if (request.params.name !== "send_message") {
482
528
  return {
483
529
  isError: true,
@@ -829,7 +875,7 @@ try {
829
875
  // ---- Channel ready banner ----
830
876
 
831
877
  process.stderr.write(
832
- `[viber-channel] Channel ready: 3 tools (send_message, list_agents, message_agent), SSE on /api/conversations/${CONVERSATION_ID}/events\n`
878
+ `[viber-channel] Channel ready: 4 tools (send_message, list_agents, message_agent, capabilities), SSE on /api/conversations/${CONVERSATION_ID}/events\n`
833
879
  );
834
880
 
835
881
  // Channel liveness is proven by the INSTANCE heartbeat (#311, started above), not
@@ -876,6 +922,43 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
876
922
  // mechanism, surfaced by the #254 Active section that keeps the UI open).
877
923
  const messageDedup = new MessageDedup();
878
924
 
925
+ // #346 read-ACK bookkeeping for the Claude MCP channel (mirrors bridge_core's
926
+ // ackInboundReceipt, which only covers the Codex/Gemma bridges). Tracks confirmed
927
+ // ACKs so we don't re-POST on SSE replay; in-flight set avoids a double-POST race.
928
+ const ackedMessageIds = new Set<string>();
929
+ const ackInFlightMessageIds = new Set<string>();
930
+
931
+ /**
932
+ * Emit a read-acknowledgement for a web→agent message received on the main
933
+ * conversation (#346). Run by the channel CODE (not the LLM), fire-and-forget,
934
+ * so the web "reçu" checkmark has zero impact on reply latency. Only
935
+ * role=user|user_voice with no sender_instance_id qualify (mirrors the Worker,
936
+ * which is the source of truth and 404s anything else). Idempotent server-side,
937
+ * so re-acking on reconnect is safe; ackedMessageIds is only set on a confirmed
938
+ * success so a failed ACK is retried on the next delivery. Never throws.
939
+ */
940
+ function ackInboundReceipt(msg: {
941
+ id?: string | number | null;
942
+ role?: string;
943
+ sender_instance_id?: string | null;
944
+ }): void {
945
+ if (msg.role !== "user" && msg.role !== "user_voice") return;
946
+ if (msg.sender_instance_id) return;
947
+ // The SSE "message" payload carries the D1 id as a NUMBER (MessageResponse.id).
948
+ // Coerce like bridge_core's ackInboundReceipt — a strict string check would
949
+ // bail on every real message and never ack.
950
+ const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
951
+ if (!idStr) return;
952
+ if (ackedMessageIds.has(idStr) || ackInFlightMessageIds.has(idStr)) return;
953
+ ackInFlightMessageIds.add(idStr);
954
+ void ackMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, idStr)
955
+ .then((ok) => {
956
+ if (ok) ackedMessageIds.add(idStr);
957
+ else process.stderr.write(`[viber-channel] ack failed for message ${idStr} (will retry on next delivery)\n`);
958
+ })
959
+ .finally(() => ackInFlightMessageIds.delete(idStr));
960
+ }
961
+
879
962
  /**
880
963
  * Forward a saved conversation message from the event bus to Claude.
881
964
  *
@@ -1084,6 +1167,9 @@ async function sseLoop(): Promise<void> {
1084
1167
  sender_instance_id?: string | null;
1085
1168
  };
1086
1169
  if (msg.content) {
1170
+ // #346: confirm receipt to the web BEFORE forwarding to the LLM
1171
+ // and independently of any reply — decoupled from reply latency.
1172
+ ackInboundReceipt(msg);
1087
1173
  await pushMessage(msg);
1088
1174
  } else {
1089
1175
  process.stderr.write(`[viber-channel] 'message' event has empty content\n`);