viber-channel 0.4.0 → 0.5.1

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/lib/connect.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * connect.ts — one-shot claim flow that turns a Viber connection URL into
3
+ * `.viber/auth.json` so the channel can be started.
4
+ *
5
+ * Invoked from the CLI as:
6
+ * bunx viber-channel connect https://viber.dgypx.dev/connect/<claim_id>
7
+ *
8
+ * Pure logic: takes a URL + a target folder, talks to the Worker, writes the
9
+ * three files (`.viber/auth.json`, `.viber/readme.md`, `.gitignore` entry).
10
+ * No MCP, no SSE.
11
+ */
12
+
13
+ import {
14
+ appendFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { clientFingerprint } from "./fingerprint.js";
22
+ import { cfAccessHeaders } from "./cfAccess.js";
23
+
24
+ interface InitResponse {
25
+ confirm_url: string;
26
+ wait_token: string;
27
+ }
28
+
29
+ interface ConsumedResponse {
30
+ status: "consumed";
31
+ project_id: number;
32
+ project_name: string;
33
+ user_email: string;
34
+ project_token: string;
35
+ issued_at: string;
36
+ rotates_after?: string;
37
+ target_conversation_id?: string | null;
38
+ readme_md: string;
39
+ }
40
+
41
+ interface PendingResponse {
42
+ status: "pending";
43
+ }
44
+
45
+ interface TerminalResponse {
46
+ status: "rejected" | "expired";
47
+ reason?: string;
48
+ }
49
+
50
+ type PollResponse = ConsumedResponse | PendingResponse | TerminalResponse;
51
+
52
+ const POLL_INTERVAL_MS = 2000;
53
+ const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
54
+
55
+ function parseClaimUrl(input: string): { baseUrl: string; claimId: string } {
56
+ let url: URL;
57
+ try {
58
+ url = new URL(input);
59
+ } catch {
60
+ throw new Error(`Invalid URL: ${input}`);
61
+ }
62
+ const match = url.pathname.match(/^\/connect\/([a-f0-9-]+)\/?$/i);
63
+ if (!match) {
64
+ throw new Error(
65
+ `Not a Viber claim URL. Expected format: https://<host>/connect/<claim_id>`,
66
+ );
67
+ }
68
+ return { baseUrl: `${url.protocol}//${url.host}`, claimId: match[1] };
69
+ }
70
+
71
+ function sleep(ms: number): Promise<void> {
72
+ return new Promise((resolve) => setTimeout(resolve, ms));
73
+ }
74
+
75
+ function writeAuthJson(
76
+ cwd: string,
77
+ data: ConsumedResponse,
78
+ fingerprint: string,
79
+ ): void {
80
+ const viberDir = join(cwd, ".viber");
81
+ mkdirSync(viberDir, { recursive: true });
82
+
83
+ const auth: Record<string, unknown> = {
84
+ schema_version: 1,
85
+ project_id: data.project_id,
86
+ project_name: data.project_name,
87
+ user_email: data.user_email,
88
+ project_token: data.project_token,
89
+ client_fingerprint: fingerprint,
90
+ issued_at: data.issued_at,
91
+ rotates_after: data.rotates_after,
92
+ };
93
+ // Only emit target_conversation_id when the server explicitly returned a
94
+ // non-null value (attach-to-project flow). Absent / null → omit to keep
95
+ // the file minimal for the common "fresh standalone conversation" case.
96
+ if (
97
+ data.target_conversation_id !== undefined &&
98
+ data.target_conversation_id !== null
99
+ ) {
100
+ auth.target_conversation_id = data.target_conversation_id;
101
+ }
102
+
103
+ writeFileSync(
104
+ join(viberDir, "auth.json"),
105
+ JSON.stringify(auth, null, 2),
106
+ "utf-8",
107
+ );
108
+ writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
109
+
110
+ // Append `.viber/` to .gitignore if not already present.
111
+ const gitignorePath = join(cwd, ".gitignore");
112
+ let current = "";
113
+ if (existsSync(gitignorePath)) {
114
+ current = readFileSync(gitignorePath, "utf-8");
115
+ }
116
+ const alreadyIgnored = current
117
+ .split(/\r?\n/)
118
+ .some((line) => {
119
+ const trimmed = line.trim();
120
+ return trimmed === ".viber/" || trimmed === ".viber";
121
+ });
122
+ if (!alreadyIgnored) {
123
+ const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
124
+ appendFileSync(gitignorePath, `${prefix}.viber/\n`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Run the claim flow. Writes .viber/auth.json + .viber/readme.md + .gitignore
130
+ * entry on success. Exits the process on any terminal outcome.
131
+ */
132
+ export async function runConnect(
133
+ claimUrl: string,
134
+ cwd: string = process.cwd(),
135
+ ): Promise<void> {
136
+ const { baseUrl, claimId } = parseClaimUrl(claimUrl);
137
+
138
+ const fingerprint = clientFingerprint(cwd);
139
+ process.stderr.write(
140
+ `[viber-channel] fingerprint=${fingerprint.slice(0, 8)}…\n`,
141
+ );
142
+
143
+ const initResp = await fetch(`${baseUrl}/api/connect/${claimId}`, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
146
+ body: JSON.stringify({ client_fingerprint: fingerprint }),
147
+ });
148
+ if (!initResp.ok) {
149
+ const body = await initResp.text().catch(() => "");
150
+ throw new Error(`Claim init failed (HTTP ${initResp.status}): ${body}`);
151
+ }
152
+ const init = (await initResp.json()) as InitResponse;
153
+ process.stdout.write(
154
+ `\nConfirm this connection in your browser:\n ${init.confirm_url}\n\n`,
155
+ );
156
+ process.stdout.write(`Polling for confirmation (up to 10 minutes)…\n`);
157
+
158
+ const pollUrl = `${baseUrl}/api/connect/${claimId}?wait_token=${encodeURIComponent(init.wait_token)}`;
159
+ const deadline = Date.now() + MAX_POLL_DURATION_MS;
160
+ while (Date.now() < deadline) {
161
+ await sleep(POLL_INTERVAL_MS);
162
+ const pollResp = await fetch(pollUrl, { headers: cfAccessHeaders() });
163
+ if (!pollResp.ok) {
164
+ const body = await pollResp.text().catch(() => "");
165
+ throw new Error(`Poll failed (HTTP ${pollResp.status}): ${body}`);
166
+ }
167
+ const data = (await pollResp.json()) as PollResponse;
168
+ if (data.status === "consumed") {
169
+ writeAuthJson(cwd, data, fingerprint);
170
+ process.stdout.write(
171
+ `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
172
+ ` Wrote ${join(cwd, ".viber", "auth.json")}\n\n` +
173
+ `Next steps:\n` +
174
+ ` 1. Register the channel MCP server (one-time per machine):\n` +
175
+ ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
176
+ ` 2. Restart Claude Code in this directory.\n\n`,
177
+ );
178
+ return;
179
+ }
180
+ if (data.status === "rejected" || data.status === "expired") {
181
+ throw new Error(
182
+ `Connection ${data.status}${data.reason ? `: ${data.reason}` : ""}. Get a fresh URL from ${baseUrl}/projects.`,
183
+ );
184
+ }
185
+ // "pending" — keep polling
186
+ }
187
+ throw new Error(
188
+ `Confirmation timed out after ${MAX_POLL_DURATION_MS / 60_000} minutes. Get a fresh URL from ${baseUrl}/projects.`,
189
+ );
190
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL.
3
+ *
4
+ * The lock prevents two instances of the channel from racing on stdin when
5
+ * Claude Code accidentally spawns duplicates. But two channels targeting
6
+ * *different* backends (e.g. staging + dev) are legitimately different
7
+ * processes — they shouldn't share a lock.
8
+ *
9
+ * Namespacing the lock by base URL lets them coexist.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { join } from "node:path";
13
+
14
+ /**
15
+ * Return the lock file path for a given base URL, under `lockDir`.
16
+ *
17
+ * The filename is `channel-{8-hex}.lock` where the hex is a stable SHA-256
18
+ * prefix of the base URL. Two callers with the same base URL get the same
19
+ * path (same process collision still detected); different base URLs get
20
+ * different paths (no false collision).
21
+ */
22
+ export function lockFilePath(baseUrl: string, lockDir: string): string {
23
+ const suffix = createHash("sha256").update(baseUrl).digest("hex").slice(0, 8);
24
+ return join(lockDir, `channel-${suffix}.lock`);
25
+ }
package/lib/messages.ts CHANGED
@@ -15,6 +15,62 @@ export class ConversationTokenExpiredError extends Error {
15
15
  }
16
16
  }
17
17
 
18
+ export type ArtifactFormat = "markdown" | "code" | "json" | "html";
19
+
20
+ export interface Artifact {
21
+ content: string;
22
+ format?: ArtifactFormat;
23
+ }
24
+
25
+ const ALLOWED_ARTIFACT_FORMATS: readonly ArtifactFormat[] = [
26
+ "markdown",
27
+ "code",
28
+ "json",
29
+ "html",
30
+ ];
31
+
32
+ export type ParseArtifactResult =
33
+ | { ok: true; artifact: Artifact | undefined }
34
+ | { ok: false; error: string };
35
+
36
+ /**
37
+ * Validate and normalize a raw `artifact` value coming from MCP tool input.
38
+ *
39
+ * - `undefined` / `null` → `{ ok: true, artifact: undefined }` (no artifact attached).
40
+ * - Anything else must be a plain object with a non-empty trimmed `content` string and,
41
+ * optionally, a `format` from the whitelist (`markdown` | `code` | `json` | `html`).
42
+ * An omitted `format` is normalized to `"markdown"`.
43
+ *
44
+ * Kept as a pure function so the CallTool handler in viber-channel.ts stays small
45
+ * and the validation rules are unit-testable on their own.
46
+ */
47
+ export function parseArtifact(raw: unknown): ParseArtifactResult {
48
+ if (raw === undefined || raw === null) {
49
+ return { ok: true, artifact: undefined };
50
+ }
51
+ if (typeof raw !== "object" || Array.isArray(raw)) {
52
+ return { ok: false, error: "artifact must be an object" };
53
+ }
54
+ const rawArtifact = raw as Record<string, unknown>;
55
+ const content = rawArtifact.content;
56
+ if (typeof content !== "string" || content.trim().length === 0) {
57
+ return { ok: false, error: "artifact.content must be a non-empty string" };
58
+ }
59
+ if (rawArtifact.format === undefined) {
60
+ return { ok: true, artifact: { content, format: "markdown" } };
61
+ }
62
+ if (
63
+ typeof rawArtifact.format !== "string" ||
64
+ !ALLOWED_ARTIFACT_FORMATS.includes(rawArtifact.format as ArtifactFormat)
65
+ ) {
66
+ return {
67
+ ok: false,
68
+ error: `artifact.format must be one of ${ALLOWED_ARTIFACT_FORMATS.join(", ")}`,
69
+ };
70
+ }
71
+ return { ok: true, artifact: { content, format: rawArtifact.format as ArtifactFormat } };
72
+ }
73
+
18
74
  export interface MessagePostSuccess {
19
75
  ok: true;
20
76
  message_id: string;
@@ -40,6 +96,7 @@ export async function postMessage(
40
96
  conversationId: string,
41
97
  conversationToken: string,
42
98
  text: string,
99
+ artifact?: Artifact,
43
100
  ): Promise<MessagePostResult> {
44
101
  if (!text || text.trim().length === 0) {
45
102
  return {
@@ -60,7 +117,7 @@ export async function postMessage(
60
117
  "Content-Type": "application/json",
61
118
  ...cfAccessHeaders(),
62
119
  },
63
- body: JSON.stringify({ content: text }),
120
+ body: JSON.stringify(artifact ? { content: text, artifact } : { content: text }),
64
121
  });
65
122
  } catch (err) {
66
123
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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
@@ -1,27 +1,84 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * viber-channel MCP channel server (#217)
3
+ * viber-channel MCP channel server (#217, #232)
4
4
  *
5
5
  * Reads .viber/auth.json for authentication, then connects to
6
6
  * /api/conversations/<id>/events SSE stream and pushes transcripts and
7
7
  * messages as channel events.
8
8
  *
9
- * Start Claude with:
10
- * claude --dangerously-load-development-channels server:viber-channel
9
+ * Launch via Claude Code — two MCP server names are in use:
10
+ * - server:viber-channel — npm-published build (bunx viber-channel@latest),
11
+ * staging backend (viber.dgypx.dev), wired by
12
+ * external consumers via `claude mcp add`.
13
+ * - server:viber-dev-channel — local-source build, dev backend
14
+ * (viber-dev.dgypx.dev). Defined in this repo's
15
+ * committed .mcp.json (or override locally).
16
+ *
17
+ * claude --dangerously-load-development-channels server:<name>
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)
22
+ *
23
+ * CLI subcommand: `bunx viber-channel connect <claim_url>` runs the one-shot
24
+ * claim flow that writes .viber/auth.json without requiring any Claude Code
25
+ * recipe in CLAUDE.md.
11
26
  */
12
27
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
13
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
29
  import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
30
  import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
16
31
  import { join } from "node:path";
32
+ import { runConnect } from "./lib/connect.ts";
33
+ import { lockFilePath } from "./lib/lockfile.ts";
34
+
35
+ // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
36
+ //
37
+ // `bunx viber-channel connect <claim_url>` runs the one-shot claim flow and
38
+ // exits — it does not start the MCP server. Any other invocation (no args, or
39
+ // the unknown subcommand) falls through to the MCP server below.
40
+ {
41
+ const sub = process.argv[2];
42
+ if (sub === "--help" || sub === "-h") {
43
+ process.stdout.write(
44
+ `viber-channel — voice + text MCP channel for Claude Code\n\n` +
45
+ `USAGE\n` +
46
+ ` bunx viber-channel connect <claim_url> one-shot auth flow; writes .viber/auth.json\n` +
47
+ ` bunx viber-channel start the MCP server (used by Claude Code)\n\n` +
48
+ `Get <claim_url> from https://viber.dgypx.dev/projects → Create connection.\n`,
49
+ );
50
+ process.exit(0);
51
+ }
52
+ if (sub === "connect") {
53
+ const claimUrl = process.argv[3];
54
+ if (!claimUrl) {
55
+ process.stderr.write(
56
+ `[viber-channel] Missing claim URL.\n` +
57
+ `Usage: bunx viber-channel connect <claim_url>\n` +
58
+ `Get the URL from https://viber.dgypx.dev/projects → Create connection.\n`,
59
+ );
60
+ process.exit(1);
61
+ }
62
+ try {
63
+ await runConnect(claimUrl);
64
+ process.exit(0);
65
+ } catch (err) {
66
+ process.stderr.write(`[viber-channel] ${err instanceof Error ? err.message : String(err)}\n`);
67
+ process.exit(1);
68
+ }
69
+ }
70
+ }
17
71
 
18
- // Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac)
72
+ // Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac).
73
+ // Namespaced by VIBER_BASE_URL so channels targeting different backends
74
+ // (staging + dev) coexist on the same machine — only same-backend duplicates
75
+ // still collide, which is the intended guard.
19
76
  const LOCK_DIR =
20
77
  process.env.APPDATA
21
78
  ? join(process.env.APPDATA, "viber")
22
79
  : join(process.env.HOME ?? "/tmp", ".config", "viber");
23
- // TODO: rename to channel.lock once all installations have transitioned off the old name
24
- const LOCK_FILE = join(LOCK_DIR, "watchdog.lock");
80
+ const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
81
+ const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_DIR);
25
82
 
26
83
  function isProcessAlive(pid: number): boolean {
27
84
  try {
@@ -92,16 +149,18 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
92
149
  // the bun subprocess outlives Claude Code and leaks (the orphan that
93
150
  // taunts us on every restart). The MCP transport uses stdin too, but
94
151
  // it doesn't propagate close as a process exit, so we wire it ourselves.
152
+ //
153
+ // NOTE: we deliberately listen to `end` only, not `close`. On Windows/Bun,
154
+ // registering an MCP `StdioServerTransport` reader appears to trigger a
155
+ // spurious `close` event ~2 ms after the transport's read loop starts,
156
+ // which would kill the channel mid-mint before MCP traffic even begins.
157
+ // `end` fires on real EOF (parent closes write end) and is sufficient for
158
+ // orphan detection. See plan #236 step-09 for the diagnosis.
95
159
  process.stdin.on("end", () => {
96
160
  process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
97
161
  releaseLock();
98
162
  process.exit(0);
99
163
  });
100
- process.stdin.on("close", () => {
101
- process.stderr.write("[viber-channel] stdin close event, shutting down\n");
102
- releaseLock();
103
- process.exit(0);
104
- });
105
164
 
106
165
  // ---- Auth from .viber/auth.json ----
107
166
 
@@ -127,7 +186,7 @@ if (computedFp !== auth.client_fingerprint) {
127
186
  // ---- Imports for mint ----
128
187
 
129
188
  import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
130
- import { postMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
189
+ import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
131
190
  import { cfAccessHeaders } from "./lib/cfAccess.ts";
132
191
 
133
192
  const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
@@ -136,17 +195,20 @@ const label = process.env.VIBER_CONVERSATION_LABEL ?? defaultLabel(process.cwd()
136
195
  // ---- MCP server ----
137
196
 
138
197
  const mcp = new Server(
139
- { name: "viber-channel", version: "0.3.0" },
198
+ { name: "viber-channel", version: "0.5.0" },
140
199
  {
141
200
  capabilities: {
142
201
  experimental: { "claude/channel": {} },
143
202
  tools: {},
144
203
  },
145
204
  instructions: [
146
- 'Voice transcripts arrive as <channel source="viber-channel">.',
147
- "Each event contains transcriptions from the user's microphone.",
148
- "Process the transcript and respond with speak(). Do NOT launch any watchdog or call check_voice().",
149
- "On exit intent (bye, au revoir, stop) → call stop_conversation(), speak farewell, stop.",
205
+ 'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
206
+ "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.",
207
+ "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.",
208
+ "Available formats: `markdown` (default), `code`, `json`, `html`.",
209
+ "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.",
210
+ "Rule of thumb: if it would sound bad read aloud, it belongs in the artifact, not the text.",
211
+ "On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
150
212
  ].join(" "),
151
213
  }
152
214
  );
@@ -158,14 +220,37 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
158
220
  {
159
221
  name: "send_message",
160
222
  description:
161
- "Send a message into the Viber conversation this channel is bound to. The user sees it in the web UI in real-time. Use this to deliver Claude's responses to the user without typing.",
223
+ "Send a message into the Viber conversation this channel is bound to. " +
224
+ "`text` is the spoken/conversational reply (read aloud — keep it short and natural, no markdown). " +
225
+ "`artifact` is optional and carries technical content (markdown, code, JSON, or sanitized HTML) rendered in a side viewer. " +
226
+ "Use `html` only when structural HTML markdown can't express is required; scripts and event handlers are stripped before display.",
162
227
  inputSchema: {
163
228
  type: "object" as const,
164
229
  properties: {
165
230
  text: {
166
231
  type: "string",
167
232
  minLength: 1,
168
- description: "Message text to send to the user's conversation",
233
+ description: "Spoken/conversational reply. Plain sentences, no markdown symbols or lists.",
234
+ },
235
+ artifact: {
236
+ type: "object",
237
+ description: "Optional technical content rendered in the artifact viewer alongside the spoken text.",
238
+ properties: {
239
+ content: {
240
+ type: "string",
241
+ minLength: 1,
242
+ description: "Artifact body — markdown, code, JSON, or HTML.",
243
+ },
244
+ format: {
245
+ type: "string",
246
+ enum: ["markdown", "code", "json", "html"],
247
+ description:
248
+ "Render format for the artifact body. Defaults to markdown if omitted. " +
249
+ "Use `html` only when structural HTML is required (styled cards, badges, mixed layouts); " +
250
+ "scripts, event handlers, iframes, and inline styles are stripped server-side before rendering.",
251
+ },
252
+ },
253
+ required: ["content"],
169
254
  },
170
255
  },
171
256
  required: ["text"],
@@ -194,9 +279,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
194
279
  };
195
280
  }
196
281
 
282
+ const parsed = parseArtifact(args.artifact);
283
+ if (!parsed.ok) {
284
+ return {
285
+ isError: true,
286
+ content: [{ type: "text" as const, text: `Invalid input: ${parsed.error}` }],
287
+ };
288
+ }
289
+ const artifact = parsed.artifact;
290
+
197
291
  let result;
198
292
  try {
199
- result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text);
293
+ result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text, artifact);
200
294
  } catch (err) {
201
295
  if (err instanceof ConversationTokenExpiredError) {
202
296
  // Notify Claude (and the user) that the channel token has expired
@@ -341,19 +435,36 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
341
435
  });
342
436
  }
343
437
 
344
- /** Forward a saved conversation message from the event bus to Claude. */
345
- async function pushMessage(msg: { text: string; id?: string | null; source?: string }): Promise<void> {
346
- process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.text.slice(0, 60)}"\n`);
438
+ /**
439
+ * Forward a saved conversation message from the event bus to Claude.
440
+ *
441
+ * Wire field name is `content` — the SSE payload mirrors the Worker's
442
+ * `MessageResponse` shape (viber-api/src/db/types.ts), republished verbatim by
443
+ * Python (`ws_server.internal_notify_message` → `publish_to_conversation`).
444
+ * The optional `artifact` field is accepted here and threaded through the meta
445
+ * so downstream consumers (step-03 React viewer) can opt-in without a second
446
+ * round-trip; the channel itself does no special rendering of it yet.
447
+ */
448
+ async function pushMessage(msg: {
449
+ content: string;
450
+ id?: string | null;
451
+ source?: string;
452
+ artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
453
+ }): Promise<void> {
454
+ process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
347
455
  // MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
348
456
  // null fields are rejected as invalid type. Omit message_id when missing.
349
- const meta: Record<string, string> = { source: msg.source ?? "conversation" };
457
+ const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
350
458
  if (typeof msg.id === "string" && msg.id.length > 0) {
351
459
  meta.message_id = msg.id;
352
460
  }
461
+ if (msg.artifact && typeof msg.artifact === "object" && typeof msg.artifact.content === "string") {
462
+ meta.artifact = msg.artifact;
463
+ }
353
464
  try {
354
465
  await mcp.notification({
355
466
  method: "notifications/claude/channel",
356
- params: { content: msg.text, meta },
467
+ params: { content: msg.content, meta },
357
468
  });
358
469
  process.stderr.write(`[viber-channel] pushMessage OK\n`);
359
470
  } catch (err) {
@@ -439,11 +550,21 @@ async function sseLoop(): Promise<void> {
439
550
  process.stderr.write(`[viber-channel] SSE event 'message' received, data_len=${data.length}\n`);
440
551
  if (data) {
441
552
  try {
442
- const msg = JSON.parse(data) as { text: string; id?: string; source?: string; role?: string; timestamp?: string };
443
- if (msg.text) {
553
+ // Wire shape mirrors viber-api `MessageResponse` (db/types.ts):
554
+ // primary text is `content`, not `text`. `artifact` is threaded
555
+ // through opaquely so step-03 (React viewer) can consume it.
556
+ const msg = JSON.parse(data) as {
557
+ content: string;
558
+ id?: string;
559
+ source?: string;
560
+ role?: string;
561
+ timestamp?: string;
562
+ artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
563
+ };
564
+ if (msg.content) {
444
565
  await pushMessage(msg);
445
566
  } else {
446
- process.stderr.write(`[viber-channel] 'message' event has empty text\n`);
567
+ process.stderr.write(`[viber-channel] 'message' event has empty content\n`);
447
568
  }
448
569
  } catch (err) {
449
570
  process.stderr.write(`[viber-channel] Failed to parse message data: ${String(err)}\n`);