viber-channel 0.5.0 → 0.5.2

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 CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { clientFingerprint } from "./fingerprint.js";
22
+ import { cfAccessHeaders } from "./cfAccess.js";
22
23
 
23
24
  interface InitResponse {
24
25
  confirm_url: string;
@@ -141,7 +142,7 @@ export async function runConnect(
141
142
 
142
143
  const initResp = await fetch(`${baseUrl}/api/connect/${claimId}`, {
143
144
  method: "POST",
144
- headers: { "Content-Type": "application/json" },
145
+ headers: { "Content-Type": "application/json", ...cfAccessHeaders() },
145
146
  body: JSON.stringify({ client_fingerprint: fingerprint }),
146
147
  });
147
148
  if (!initResp.ok) {
@@ -158,7 +159,7 @@ export async function runConnect(
158
159
  const deadline = Date.now() + MAX_POLL_DURATION_MS;
159
160
  while (Date.now() < deadline) {
160
161
  await sleep(POLL_INTERVAL_MS);
161
- const pollResp = await fetch(pollUrl);
162
+ const pollResp = await fetch(pollUrl, { headers: cfAccessHeaders() });
162
163
  if (!pollResp.ok) {
163
164
  const body = await pollResp.text().catch(() => "");
164
165
  throw new Error(`Poll failed (HTTP ${pollResp.status}): ${body}`);
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { cfAccessHeaders } from "./cfAccess.js";
3
+ import { ConversationTokenExpiredError } from "./messages.js";
3
4
 
4
5
  export interface ConversationMintResponse {
5
6
  conversation_id: string;
@@ -76,6 +77,100 @@ export async function mintConversation(
76
77
  return (await resp.json()) as ConversationMintResponse;
77
78
  }
78
79
 
80
+ export interface RefreshTokenResponse {
81
+ conversation_token: string;
82
+ expires_at: number;
83
+ }
84
+
85
+ /**
86
+ * HTTP failure from /refresh-token (any non-401 non-2xx). `.retryable` lets
87
+ * the scheduler distinguish a transient server hiccup (5xx, 408, 409) from a
88
+ * permanent state mismatch (4xx other than 401), so it can back off and try
89
+ * again instead of immediately ending the channel.
90
+ */
91
+ export class RefreshHttpError extends Error {
92
+ status: number;
93
+ detail: string;
94
+ retryable: boolean;
95
+ constructor(status: number, detail: string) {
96
+ super(`Refresh failed (HTTP ${status}): ${detail}`);
97
+ this.name = "RefreshHttpError";
98
+ this.status = status;
99
+ this.detail = detail;
100
+ // 5xx, 408 (timeout), 409 (concurrent-refresh race) — server/transient.
101
+ // 4xx others (403 fingerprint mismatch, etc.) are permanent.
102
+ this.retryable = status >= 500 || status === 408 || status === 409;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Wrapper for `fetch` failures (DNS, connection reset, TLS, etc.). Always
108
+ * retryable — by definition we never got a server response to classify.
109
+ */
110
+ export class RefreshNetworkError extends Error {
111
+ cause: unknown;
112
+ retryable: true = true as const;
113
+ constructor(cause: unknown) {
114
+ super(`Refresh failed: network error: ${String(cause)}`);
115
+ this.name = "RefreshNetworkError";
116
+ this.cause = cause;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * POST /api/conversations/:conversationId/refresh-token
122
+ *
123
+ * Rotates the per-conversation token in place. Worker updates the row's
124
+ * token+expiry and returns the new pair. The caller (scheduler) propagates
125
+ * the new token to module state so subsequent requests use it.
126
+ *
127
+ * @throws ConversationTokenExpiredError on HTTP 401 (token is unknown or has
128
+ * already been rotated away from the value sent in the Authorization header)
129
+ * @throws RefreshHttpError on any other non-2xx status — inspect `.retryable`
130
+ * @throws RefreshNetworkError on fetch-level failure (always retryable)
131
+ */
132
+ export async function refreshConversationToken(
133
+ baseUrl: string,
134
+ conversationId: string,
135
+ currentToken: string,
136
+ fingerprint: string,
137
+ ): Promise<RefreshTokenResponse> {
138
+ let resp: Response;
139
+ try {
140
+ resp = await fetch(
141
+ `${baseUrl}/api/conversations/${conversationId}/refresh-token`,
142
+ {
143
+ method: "POST",
144
+ headers: {
145
+ Authorization: `Bearer ${currentToken}`,
146
+ "X-Client-Fingerprint": fingerprint,
147
+ "Content-Type": "application/json",
148
+ ...cfAccessHeaders(),
149
+ },
150
+ body: "{}",
151
+ },
152
+ );
153
+ } catch (err) {
154
+ throw new RefreshNetworkError(err);
155
+ }
156
+
157
+ if (resp.status === 401) {
158
+ throw new ConversationTokenExpiredError();
159
+ }
160
+
161
+ if (!resp.ok) {
162
+ let detail = "";
163
+ try {
164
+ detail = await resp.text();
165
+ } catch {
166
+ // ignore
167
+ }
168
+ throw new RefreshHttpError(resp.status, detail);
169
+ }
170
+
171
+ return (await resp.json()) as RefreshTokenResponse;
172
+ }
173
+
79
174
  export function defaultLabel(folderPath: string): string {
80
175
  const folder = basename(folderPath);
81
176
  // Local time, not UTC — the user sees this label in the UI; UTC was confusing.
@@ -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 {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Token refresh scheduler — single-responsibility module.
3
+ *
4
+ * Fires a `refresh()` callback shortly before the conversation token expires,
5
+ * re-schedules itself on success, and routes failures to `onFailure()`. No
6
+ * knowledge of the worker, MCP server, or any channel module state.
7
+ */
8
+
9
+ export interface TokenRefreshState {
10
+ /** Unix seconds — the new expiry returned by the refresh callback. */
11
+ expiresAt: number;
12
+ }
13
+
14
+ export interface TokenRefreshDeps {
15
+ /** Returns current time in unix seconds. Defaults to Date.now()/1000. */
16
+ now?: () => number;
17
+ /** Schedules a callback after `ms` milliseconds. Defaults to setTimeout. */
18
+ setTimer?: (fn: () => void, ms: number) => unknown;
19
+ /** Cancels a previously-scheduled timer. Defaults to clearTimeout. */
20
+ clearTimer?: (h: unknown) => void;
21
+ /** Seconds before expiry to trigger the refresh. Default 300 (5 minutes). */
22
+ leadSeconds?: number;
23
+ /** Log sink. Defaults to writing to process.stderr with a newline. */
24
+ log?: (line: string) => void;
25
+ }
26
+
27
+ export interface TokenRefreshScheduler {
28
+ /** Start the scheduler with the initial token expiry (unix seconds). */
29
+ start: (initialExpiresAt: number) => void;
30
+ /** Cancel any pending refresh. Idempotent. Stops re-scheduling. */
31
+ cancel: () => void;
32
+ }
33
+
34
+ const DEFAULT_LEAD_SECONDS = 300;
35
+
36
+ type SchedulerState = "active" | "cancelled";
37
+
38
+ export function createTokenRefreshScheduler(
39
+ refresh: () => Promise<TokenRefreshState>,
40
+ onFailure: (err: unknown) => void | Promise<void>,
41
+ deps: TokenRefreshDeps = {},
42
+ ): TokenRefreshScheduler {
43
+ const now = deps.now ?? (() => Date.now() / 1000);
44
+ const setTimer =
45
+ deps.setTimer ??
46
+ ((fn: () => void, ms: number) => setTimeout(fn, ms) as unknown);
47
+ const clearTimer = deps.clearTimer ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
48
+ const leadSeconds = deps.leadSeconds ?? DEFAULT_LEAD_SECONDS;
49
+ const log = deps.log ?? ((line: string) => process.stderr.write(`${line}\n`));
50
+
51
+ let handle: unknown = null;
52
+ let state: SchedulerState = "active";
53
+
54
+ const scheduleAt = (expiresAt: number): void => {
55
+ if (state === "cancelled") return;
56
+ const delayMs = Math.max(0, (expiresAt - leadSeconds - now()) * 1000);
57
+ handle = setTimer(fire, delayMs);
58
+ };
59
+
60
+ const fire = (): void => {
61
+ handle = null;
62
+ // void intentional — timer callback cannot be async.
63
+ void runRefresh();
64
+ };
65
+
66
+ const runRefresh = async (): Promise<void> => {
67
+ try {
68
+ const result = await refresh();
69
+ if (state === "cancelled") return;
70
+ const ttlSeconds = Math.max(0, result.expiresAt - now());
71
+ const nextSeconds = Math.max(0, ttlSeconds - leadSeconds);
72
+ const ttlMin = Math.round(ttlSeconds / 60);
73
+ const nextMin = Math.round(nextSeconds / 60);
74
+ log(
75
+ `[viber-channel] token refreshed, next refresh in ${nextMin}m (ttl ${ttlMin}m)`,
76
+ );
77
+ scheduleAt(result.expiresAt);
78
+ } catch (err) {
79
+ try {
80
+ await onFailure(err);
81
+ } catch {
82
+ // Failure callback owns its own error path; swallow.
83
+ }
84
+ }
85
+ };
86
+
87
+ return {
88
+ start(initialExpiresAt: number): void {
89
+ if (state === "cancelled") return;
90
+ if (handle !== null) {
91
+ clearTimer(handle);
92
+ handle = null;
93
+ }
94
+ scheduleAt(initialExpiresAt);
95
+ },
96
+ cancel(): void {
97
+ state = "cancelled";
98
+ if (handle !== null) {
99
+ clearTimer(handle);
100
+ handle = null;
101
+ }
102
+ },
103
+ };
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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,13 +1,28 @@
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";
@@ -15,6 +30,11 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot
15
30
  import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
16
31
  import { join } from "node:path";
17
32
  import { runConnect } from "./lib/connect.ts";
33
+ import { lockFilePath } from "./lib/lockfile.ts";
34
+ import {
35
+ createTokenRefreshScheduler,
36
+ type TokenRefreshScheduler,
37
+ } from "./lib/token_refresh.ts";
18
38
 
19
39
  // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
20
40
  //
@@ -53,13 +73,16 @@ import { runConnect } from "./lib/connect.ts";
53
73
  }
54
74
  }
55
75
 
56
- // Lock file path: %APPDATA%/viber/ (Windows) or ~/.config/viber/ (Linux/Mac)
76
+ // 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.
57
80
  const LOCK_DIR =
58
81
  process.env.APPDATA
59
82
  ? join(process.env.APPDATA, "viber")
60
83
  : join(process.env.HOME ?? "/tmp", ".config", "viber");
61
- // TODO: rename to channel.lock once all installations have transitioned off the old name
62
- const LOCK_FILE = join(LOCK_DIR, "watchdog.lock");
84
+ const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
85
+ const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_DIR);
63
86
 
64
87
  function isProcessAlive(pid: number): boolean {
65
88
  try {
@@ -119,10 +142,15 @@ function releaseLock(): void {
119
142
  }
120
143
  }
121
144
 
145
+ // Token-refresh scheduler — null until the initial mint succeeds. Declared at
146
+ // module scope so the early exit handlers below (which fire before the mint
147
+ // completes on slow boots) can cancel it idempotently via optional-chaining.
148
+ let scheduler: TokenRefreshScheduler | null = null;
149
+
122
150
  // Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
123
- process.on("exit", releaseLock);
124
- process.on("SIGINT", () => { releaseLock(); process.exit(0); });
125
- process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
151
+ process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
152
+ process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
153
+ process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
126
154
 
127
155
  // stdin EOF — Claude Code closes its end of the pipe when the session
128
156
  // terminates (or the parent process is killed via TerminateProcess on
@@ -130,13 +158,16 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
130
158
  // the bun subprocess outlives Claude Code and leaks (the orphan that
131
159
  // taunts us on every restart). The MCP transport uses stdin too, but
132
160
  // it doesn't propagate close as a process exit, so we wire it ourselves.
161
+ //
162
+ // NOTE: we deliberately listen to `end` only, not `close`. On Windows/Bun,
163
+ // registering an MCP `StdioServerTransport` reader appears to trigger a
164
+ // spurious `close` event ~2 ms after the transport's read loop starts,
165
+ // which would kill the channel mid-mint before MCP traffic even begins.
166
+ // `end` fires on real EOF (parent closes write end) and is sufficient for
167
+ // orphan detection. See plan #236 step-09 for the diagnosis.
133
168
  process.stdin.on("end", () => {
134
169
  process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
135
- releaseLock();
136
- process.exit(0);
137
- });
138
- process.stdin.on("close", () => {
139
- process.stderr.write("[viber-channel] stdin close event, shutting down\n");
170
+ scheduler?.cancel();
140
171
  releaseLock();
141
172
  process.exit(0);
142
173
  });
@@ -164,8 +195,15 @@ if (computedFp !== auth.client_fingerprint) {
164
195
 
165
196
  // ---- Imports for mint ----
166
197
 
167
- import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
168
- import { postMessage, ConversationTokenExpiredError } from "./lib/messages.ts";
198
+ import {
199
+ defaultLabel,
200
+ mintConversation,
201
+ refreshConversationToken,
202
+ RefreshHttpError,
203
+ RefreshNetworkError,
204
+ ReverifyRequiredError,
205
+ } from "./lib/conversation.ts";
206
+ import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
169
207
  import { cfAccessHeaders } from "./lib/cfAccess.ts";
170
208
 
171
209
  const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
@@ -181,10 +219,13 @@ const mcp = new Server(
181
219
  tools: {},
182
220
  },
183
221
  instructions: [
184
- 'Voice transcripts arrive as <channel source="viber-channel">.',
185
- "Each event contains transcriptions from the user's microphone.",
186
- "Process the transcript and respond with speak(). Do NOT launch any watchdog or call check_voice().",
187
- "On exit intent (bye, au revoir, stop) → call stop_conversation(), speak farewell, stop.",
222
+ 'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
223
+ "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.",
224
+ "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
+ "Available formats: `markdown` (default), `code`, `json`, `html`.",
226
+ "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.",
227
+ "Rule of thumb: if it would sound bad read aloud, it belongs in the artifact, not the text.",
228
+ "On exit intent (bye, au revoir, stop) call stop_conversation(), speak a brief farewell, and stop.",
188
229
  ].join(" "),
189
230
  }
190
231
  );
@@ -196,14 +237,37 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
196
237
  {
197
238
  name: "send_message",
198
239
  description:
199
- "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.",
240
+ "Send a message into the Viber conversation this channel is bound to. " +
241
+ "`text` is the spoken/conversational reply (read aloud — keep it short and natural, no markdown). " +
242
+ "`artifact` is optional and carries technical content (markdown, code, JSON, or sanitized HTML) rendered in a side viewer. " +
243
+ "Use `html` only when structural HTML markdown can't express is required; scripts and event handlers are stripped before display.",
200
244
  inputSchema: {
201
245
  type: "object" as const,
202
246
  properties: {
203
247
  text: {
204
248
  type: "string",
205
249
  minLength: 1,
206
- description: "Message text to send to the user's conversation",
250
+ description: "Spoken/conversational reply. Plain sentences, no markdown symbols or lists.",
251
+ },
252
+ artifact: {
253
+ type: "object",
254
+ description: "Optional technical content rendered in the artifact viewer alongside the spoken text.",
255
+ properties: {
256
+ content: {
257
+ type: "string",
258
+ minLength: 1,
259
+ description: "Artifact body — markdown, code, JSON, or HTML.",
260
+ },
261
+ format: {
262
+ type: "string",
263
+ enum: ["markdown", "code", "json", "html"],
264
+ description:
265
+ "Render format for the artifact body. Defaults to markdown if omitted. " +
266
+ "Use `html` only when structural HTML is required (styled cards, badges, mixed layouts); " +
267
+ "scripts, event handlers, iframes, and inline styles are stripped server-side before rendering.",
268
+ },
269
+ },
270
+ required: ["content"],
207
271
  },
208
272
  },
209
273
  required: ["text"],
@@ -232,9 +296,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
232
296
  };
233
297
  }
234
298
 
299
+ const parsed = parseArtifact(args.artifact);
300
+ if (!parsed.ok) {
301
+ return {
302
+ isError: true,
303
+ content: [{ type: "text" as const, text: `Invalid input: ${parsed.error}` }],
304
+ };
305
+ }
306
+ const artifact = parsed.artifact;
307
+
235
308
  let result;
236
309
  try {
237
- result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text);
310
+ result = await postMessage(BASE_URL, CONVERSATION_ID, CONVERSATION_TOKEN, text, artifact);
238
311
  } catch (err) {
239
312
  if (err instanceof ConversationTokenExpiredError) {
240
313
  // Notify Claude (and the user) that the channel token has expired
@@ -320,6 +393,93 @@ try {
320
393
  CONVERSATION_TOKEN = minted.conversation_token;
321
394
  VOICE_BASE_URL = minted.ws_url;
322
395
  CONVERSATION_ID = minted.conversation_id;
396
+
397
+ // Schedule silent token refresh ahead of expiry (#237 step-03). On success
398
+ // we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
399
+ // so subsequent SSE reconnects and send_message calls pick up the new token
400
+ // automatically. On failure we fall through to the existing
401
+ // handleConversationTokenExpired path (notification + exit code 3).
402
+ //
403
+ // VIBER_REFRESH_LEAD_SECONDS overrides the default 300s lead window — used
404
+ // for shortening the refresh-before-expiry gap during E2E manual testing
405
+ // (set close to TTL to force a refresh seconds after startup). Out of band
406
+ // for normal operation; the scheduler module's default applies when unset.
407
+ const leadOverride = process.env.VIBER_REFRESH_LEAD_SECONDS;
408
+ const leadSeconds = leadOverride ? Number.parseInt(leadOverride, 10) : undefined;
409
+ if (leadOverride !== undefined) {
410
+ if (!Number.isFinite(leadSeconds) || (leadSeconds as number) < 0) {
411
+ process.stderr.write(
412
+ `[viber-channel] Invalid VIBER_REFRESH_LEAD_SECONDS=${leadOverride}, ignoring.\n`
413
+ );
414
+ } else {
415
+ process.stderr.write(
416
+ `[viber-channel] token refresh lead overridden to ${leadSeconds}s via VIBER_REFRESH_LEAD_SECONDS\n`
417
+ );
418
+ }
419
+ }
420
+ const validLead =
421
+ leadSeconds !== undefined && Number.isFinite(leadSeconds) && leadSeconds >= 0
422
+ ? leadSeconds
423
+ : undefined;
424
+
425
+ // Track the current token's expiry so the retry path can refuse to retry
426
+ // past it. Updated only after a successful refresh — failed attempts leave
427
+ // it pointing at the still-valid current token.
428
+ let currentExpiresAt = minted.expires_at;
429
+
430
+ scheduler = createTokenRefreshScheduler(
431
+ async () => {
432
+ // Retry transient failures (5xx, 408, 409, network) with exponential
433
+ // backoff so a momentary blip during the 5-min lead window doesn't kill
434
+ // a channel whose token is still valid. Permanent failures (401, 403)
435
+ // surface immediately.
436
+ const MAX_ATTEMPTS = 5;
437
+ const HEADROOM_SECONDS = 10;
438
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
439
+ try {
440
+ const refreshed = await refreshConversationToken(
441
+ BASE_URL,
442
+ CONVERSATION_ID,
443
+ CONVERSATION_TOKEN,
444
+ auth.client_fingerprint,
445
+ );
446
+ // SSE connection: the server accepts the new token immediately on
447
+ // subsequent requests, and any in-flight fetch still has the old
448
+ // token attached at the header level — the server keeps the open
449
+ // stream alive on the old token until its natural close. Safe to
450
+ // overwrite the module variable here.
451
+ CONVERSATION_TOKEN = refreshed.conversation_token;
452
+ currentExpiresAt = refreshed.expires_at;
453
+ return { expiresAt: refreshed.expires_at };
454
+ } catch (err) {
455
+ const retryable =
456
+ (err instanceof RefreshHttpError && err.retryable) ||
457
+ err instanceof RefreshNetworkError;
458
+ if (!retryable || attempt === MAX_ATTEMPTS) throw err;
459
+ // Bound retries by token validity: never sleep past expiry, leave
460
+ // a small headroom so the post-sleep request has time to land.
461
+ const proposedDelay = Math.min(30 * 2 ** (attempt - 1), 120);
462
+ const remaining = currentExpiresAt - Date.now() / 1000;
463
+ if (remaining - proposedDelay < HEADROOM_SECONDS) throw err;
464
+ process.stderr.write(
465
+ `[viber-channel] token refresh attempt ${attempt} failed (${String(err)}); retry in ${proposedDelay}s (${Math.round(remaining)}s until expiry)\n`,
466
+ );
467
+ await new Promise((r) => setTimeout(r, proposedDelay * 1000));
468
+ }
469
+ }
470
+ // Unreachable — the loop above either returns the refreshed state or
471
+ // throws on attempt === MAX_ATTEMPTS.
472
+ throw new Error("token refresh: retry loop exhausted without resolution");
473
+ },
474
+ async (err) => {
475
+ process.stderr.write(`[viber-channel] token refresh failed: ${String(err)}\n`);
476
+ // We don't call scheduler.cancel() here — we're inside the scheduler's
477
+ // failure path, and handleConversationTokenExpired ends the process anyway.
478
+ await handleConversationTokenExpired(mcp);
479
+ },
480
+ validLead !== undefined ? { leadSeconds: validLead } : undefined,
481
+ );
482
+ scheduler.start(minted.expires_at);
323
483
  } catch (err) {
324
484
  if (err instanceof ReverifyRequiredError) {
325
485
  await mcp.notification({
@@ -379,19 +539,36 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
379
539
  });
380
540
  }
381
541
 
382
- /** Forward a saved conversation message from the event bus to Claude. */
383
- async function pushMessage(msg: { text: string; id?: string | null; source?: string }): Promise<void> {
384
- process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.text.slice(0, 60)}"\n`);
542
+ /**
543
+ * Forward a saved conversation message from the event bus to Claude.
544
+ *
545
+ * Wire field name is `content` — the SSE payload mirrors the Worker's
546
+ * `MessageResponse` shape (viber-api/src/db/types.ts), republished verbatim by
547
+ * Python (`ws_server.internal_notify_message` → `publish_to_conversation`).
548
+ * The optional `artifact` field is accepted here and threaded through the meta
549
+ * so downstream consumers (step-03 React viewer) can opt-in without a second
550
+ * round-trip; the channel itself does no special rendering of it yet.
551
+ */
552
+ async function pushMessage(msg: {
553
+ content: string;
554
+ id?: string | null;
555
+ source?: string;
556
+ artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
557
+ }): Promise<void> {
558
+ process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
385
559
  // MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
386
560
  // null fields are rejected as invalid type. Omit message_id when missing.
387
- const meta: Record<string, string> = { source: msg.source ?? "conversation" };
561
+ const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
388
562
  if (typeof msg.id === "string" && msg.id.length > 0) {
389
563
  meta.message_id = msg.id;
390
564
  }
565
+ if (msg.artifact && typeof msg.artifact === "object" && typeof msg.artifact.content === "string") {
566
+ meta.artifact = msg.artifact;
567
+ }
391
568
  try {
392
569
  await mcp.notification({
393
570
  method: "notifications/claude/channel",
394
- params: { content: msg.text, meta },
571
+ params: { content: msg.content, meta },
395
572
  });
396
573
  process.stderr.write(`[viber-channel] pushMessage OK\n`);
397
574
  } catch (err) {
@@ -456,6 +633,7 @@ async function sseLoop(): Promise<void> {
456
633
 
457
634
  case "stop":
458
635
  process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
636
+ scheduler?.cancel();
459
637
  releaseLock();
460
638
  process.exit(0);
461
639
  break;
@@ -477,11 +655,21 @@ async function sseLoop(): Promise<void> {
477
655
  process.stderr.write(`[viber-channel] SSE event 'message' received, data_len=${data.length}\n`);
478
656
  if (data) {
479
657
  try {
480
- const msg = JSON.parse(data) as { text: string; id?: string; source?: string; role?: string; timestamp?: string };
481
- if (msg.text) {
658
+ // Wire shape mirrors viber-api `MessageResponse` (db/types.ts):
659
+ // primary text is `content`, not `text`. `artifact` is threaded
660
+ // through opaquely so step-03 (React viewer) can consume it.
661
+ const msg = JSON.parse(data) as {
662
+ content: string;
663
+ id?: string;
664
+ source?: string;
665
+ role?: string;
666
+ timestamp?: string;
667
+ artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
668
+ };
669
+ if (msg.content) {
482
670
  await pushMessage(msg);
483
671
  } else {
484
- process.stderr.write(`[viber-channel] 'message' event has empty text\n`);
672
+ process.stderr.write(`[viber-channel] 'message' event has empty content\n`);
485
673
  }
486
674
  } catch (err) {
487
675
  process.stderr.write(`[viber-channel] Failed to parse message data: ${String(err)}\n`);