viber-channel 0.5.1 → 0.5.3

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/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
3
 
4
4
  export interface AuthJson {
5
5
  schema_version: number;
@@ -9,7 +9,6 @@ export interface AuthJson {
9
9
  project_token: string;
10
10
  client_fingerprint: string;
11
11
  issued_at: string;
12
- rotates_after?: string;
13
12
  /**
14
13
  * Optional — when present, the CLI must pass this id to the conversation
15
14
  * mint endpoint so the worker UPDATES the existing standalone conversation
@@ -24,30 +23,30 @@ export interface AuthJson {
24
23
  }
25
24
 
26
25
  /**
27
- * Atomically update the project_token in .viber/auth.json.
28
- * Writes to a tmp file first, then renames (atomic on POSIX; also atomic on Windows for same-volume).
26
+ * Resolve the auth file path.
27
+ *
28
+ * Defaults to `<cwd>/.viber/auth.json`. The optional `VIBER_AUTH_FILE` env var
29
+ * overrides it — absolute paths are used verbatim, relative paths are resolved
30
+ * against `cwd`. This lets a local launcher point the channel at an alternate
31
+ * credential file (e.g. `.viber/dev.auth.json`) WITHOUT swapping `auth.json`,
32
+ * so the default consumer flow (env var unset) is completely unaffected.
29
33
  */
30
- export function updateAuthToken(folderPath: string, currentAuth: AuthJson, newToken: string): AuthJson {
31
- const path = join(folderPath, ".viber", "auth.json");
32
- const tmp = `${path}.tmp`;
33
- const updated: AuthJson = {
34
- ...currentAuth,
35
- project_token: newToken,
36
- issued_at: new Date().toISOString(),
37
- };
38
- writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
39
- renameSync(tmp, path);
40
- return updated;
34
+ export function authFilePath(cwd: string = process.cwd()): string {
35
+ const override = process.env.VIBER_AUTH_FILE;
36
+ if (override !== undefined && override.trim() !== "") {
37
+ return isAbsolute(override) ? override : join(cwd, override);
38
+ }
39
+ return join(cwd, ".viber", "auth.json");
41
40
  }
42
41
 
43
42
  export function loadAuth(cwd: string = process.cwd()): AuthJson {
44
- const path = join(cwd, ".viber", "auth.json");
43
+ const path = authFilePath(cwd);
45
44
  let raw: string;
46
45
  try {
47
46
  raw = readFileSync(path, "utf-8");
48
47
  } catch {
49
48
  process.stderr.write(
50
- `[viber-channel] No \`.viber/auth.json\` found in this folder.\n` +
49
+ `[viber-channel] No auth file found at \`${path}\`.\n` +
51
50
  `Run the connect flow first: visit https://viber.dgypx.dev/projects → Create connection.\n`
52
51
  );
53
52
  process.exit(1);
@@ -56,15 +55,15 @@ export function loadAuth(cwd: string = process.cwd()): AuthJson {
56
55
  try {
57
56
  auth = JSON.parse(raw) as AuthJson;
58
57
  } catch (err) {
59
- process.stderr.write(`[viber-channel] Malformed .viber/auth.json: ${String(err)}\n`);
58
+ process.stderr.write(`[viber-channel] Malformed auth file \`${path}\`: ${String(err)}\n`);
60
59
  process.exit(1);
61
60
  }
62
61
  if (auth.schema_version !== 1) {
63
- process.stderr.write(`[viber-channel] Unsupported auth schema version: ${auth.schema_version}\n`);
62
+ process.stderr.write(`[viber-channel] Unsupported auth schema version ${auth.schema_version} in \`${path}\`\n`);
64
63
  process.exit(1);
65
64
  }
66
65
  if (!auth.project_token) {
67
- process.stderr.write(`[viber-channel] Empty project_token in .viber/auth.json\n`);
66
+ process.stderr.write(`[viber-channel] Empty project_token in \`${path}\`\n`);
68
67
  process.exit(1);
69
68
  }
70
69
  return auth;
@@ -5,8 +5,8 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  *
6
6
  * The CONVERSATION_TOKEN minted at startup has a 1h TTL. After expiry, the
7
7
  * server starts responding with 401. We notify the user and exit with code 3
8
- * (distinct from reverify exit code 2) so the process manager / user can
9
- * distinguish the cause.
8
+ * (distinct from the startup/mint failure exit code 1) so the process manager /
9
+ * user can distinguish the cause.
10
10
  *
11
11
  * We do NOT attempt to re-mint inline — the project_token is tied to the
12
12
  * original auth flow. The user must restart the channel to get a fresh token.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * channel_session.ts — persist the channel's active conversation handle to disk.
3
+ *
4
+ * When a channel process mints or reattaches to a conversation, it writes the
5
+ * conversation_id here. A respawned channel process reads this file to find the
6
+ * existing conversation instead of minting a new one — avoiding duplicates (#257).
7
+ *
8
+ * Namespaced by `(VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID)` so:
9
+ * - dev and staging channels coexist on the same machine without colliding;
10
+ * - two *different* projects on the same backend each have their own handle and
11
+ * never reattach to each other's conversation — the fingerprint axis is the
12
+ * only thing that isolates them for a normal bunx client (#267), where the
13
+ * sessionId is always "";
14
+ * - two concurrent launches of the *same* project (each with its own session id
15
+ * set by viber-dev.ps1 / viber.ps1) each have their own handle (#259);
16
+ * - channel respawns within one launch inherit the same session id and find
17
+ * the same handle — reattach still works (#257).
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { join } from "node:path";
21
+ import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
22
+
23
+ export interface ChannelHandle {
24
+ conversation_id: string;
25
+ saved_at: number; // unix seconds
26
+ }
27
+
28
+ /**
29
+ * Return the session file path for a given base URL, fingerprint, and session
30
+ * id, under `dir`.
31
+ *
32
+ * The filename is `channel-session-{8-hex}.json` where the hex is a stable
33
+ * SHA-256 prefix of `${baseUrl}\n${fingerprint}\n${sessionId}` — the same input
34
+ * shape as `lockFilePath`, so the lock and session files sit side by side for
35
+ * the same `(baseUrl, fingerprint, sessionId)` triple.
36
+ */
37
+ export function sessionFilePath(
38
+ baseUrl: string,
39
+ fingerprint: string,
40
+ sessionId: string,
41
+ dir: string,
42
+ ): string {
43
+ const suffix = createHash("sha256")
44
+ .update(`${baseUrl}\n${fingerprint}\n${sessionId}`)
45
+ .digest("hex")
46
+ .slice(0, 8);
47
+ return join(dir, `channel-session-${suffix}.json`);
48
+ }
49
+
50
+ /**
51
+ * Read a persisted handle from `path`.
52
+ *
53
+ * Returns `null` — NEVER throws — when the file is absent, unreadable,
54
+ * contains invalid JSON, or the parsed object does not match the expected
55
+ * shape.
56
+ */
57
+ export function readHandle(path: string): ChannelHandle | null {
58
+ try {
59
+ const raw = readFileSync(path, "utf-8");
60
+ const parsed: unknown = JSON.parse(raw);
61
+ if (
62
+ parsed === null ||
63
+ typeof parsed !== "object" ||
64
+ Array.isArray(parsed)
65
+ ) {
66
+ return null;
67
+ }
68
+ const obj = parsed as Record<string, unknown>;
69
+ if (typeof obj.conversation_id !== "string" || obj.conversation_id.length === 0) return null;
70
+ if (!Number.isFinite(obj.saved_at)) return null;
71
+ return { conversation_id: obj.conversation_id, saved_at: obj.saved_at as number };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Write the conversation handle to `path`.
79
+ *
80
+ * Overwrites any existing file. Propagates write errors (matches how
81
+ * `acquireLock` in viber-channel.ts propagates `writeFileSync` failures —
82
+ * the caller decides whether to handle them).
83
+ */
84
+ export function writeHandle(path: string, conversationId: string): void {
85
+ const handle: ChannelHandle = {
86
+ conversation_id: conversationId,
87
+ saved_at: Math.floor(Date.now() / 1000),
88
+ };
89
+ writeFileSync(path, JSON.stringify(handle), "utf-8");
90
+ }
91
+
92
+ /**
93
+ * Remove the handle file at `path`.
94
+ *
95
+ * Best-effort — swallows errors if the file is absent, mirroring
96
+ * `releaseLock()` in viber-channel.ts.
97
+ */
98
+ export function clearHandle(path: string): void {
99
+ try {
100
+ unlinkSync(path);
101
+ } catch {
102
+ // Best-effort — file may already be gone
103
+ }
104
+ }
package/lib/connect.ts CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  readFileSync,
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
- import { join } from "node:path";
20
+ import { dirname, isAbsolute, join, relative } from "node:path";
21
+ import { authFilePath } from "./auth.js";
21
22
  import { clientFingerprint } from "./fingerprint.js";
22
23
  import { cfAccessHeaders } from "./cfAccess.js";
23
24
 
@@ -33,7 +34,6 @@ interface ConsumedResponse {
33
34
  user_email: string;
34
35
  project_token: string;
35
36
  issued_at: string;
36
- rotates_after?: string;
37
37
  target_conversation_id?: string | null;
38
38
  readme_md: string;
39
39
  }
@@ -80,6 +80,13 @@ function writeAuthJson(
80
80
  const viberDir = join(cwd, ".viber");
81
81
  mkdirSync(viberDir, { recursive: true });
82
82
 
83
+ // Honor VIBER_AUTH_FILE so connecting with the override set writes the
84
+ // credentials to the same file the channel will read (e.g. dev.auth.json),
85
+ // instead of silently writing auth.json while the channel reads elsewhere.
86
+ // readme.md + .gitignore stay in <cwd>/.viber regardless.
87
+ const authPath = authFilePath(cwd);
88
+ mkdirSync(dirname(authPath), { recursive: true });
89
+
83
90
  const auth: Record<string, unknown> = {
84
91
  schema_version: 1,
85
92
  project_id: data.project_id,
@@ -88,7 +95,6 @@ function writeAuthJson(
88
95
  project_token: data.project_token,
89
96
  client_fingerprint: fingerprint,
90
97
  issued_at: data.issued_at,
91
- rotates_after: data.rotates_after,
92
98
  };
93
99
  // Only emit target_conversation_id when the server explicitly returned a
94
100
  // non-null value (attach-to-project flow). Absent / null → omit to keep
@@ -100,11 +106,7 @@ function writeAuthJson(
100
106
  auth.target_conversation_id = data.target_conversation_id;
101
107
  }
102
108
 
103
- writeFileSync(
104
- join(viberDir, "auth.json"),
105
- JSON.stringify(auth, null, 2),
106
- "utf-8",
107
- );
109
+ writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf-8");
108
110
  writeFileSync(join(viberDir, "readme.md"), data.readme_md, "utf-8");
109
111
 
110
112
  // Append `.viber/` to .gitignore if not already present.
@@ -123,6 +125,20 @@ function writeAuthJson(
123
125
  const prefix = current === "" || current.endsWith("\n") ? "" : "\n";
124
126
  appendFileSync(gitignorePath, `${prefix}.viber/\n`);
125
127
  }
128
+
129
+ // The `.viber/` entry only covers credentials inside `.viber/`. If
130
+ // VIBER_AUTH_FILE points the auth file elsewhere, it is NOT auto-ignored —
131
+ // warn loudly so a project_token doesn't get committed by a later `git add`.
132
+ const relFromViber = relative(viberDir, authPath);
133
+ const insideViber =
134
+ relFromViber !== "" && !relFromViber.startsWith("..") && !isAbsolute(relFromViber);
135
+ if (!insideViber) {
136
+ process.stderr.write(
137
+ `[viber-channel] Warning: VIBER_AUTH_FILE points outside .viber/ (${authPath}).\n` +
138
+ ` This file holds your project token and is NOT covered by the .viber/ .gitignore entry.\n` +
139
+ ` Make sure it is git-ignored so the token is not committed.\n`,
140
+ );
141
+ }
126
142
  }
127
143
 
128
144
  /**
@@ -169,7 +185,7 @@ export async function runConnect(
169
185
  writeAuthJson(cwd, data, fingerprint);
170
186
  process.stdout.write(
171
187
  `\n✓ Connected as ${data.user_email} to project '${data.project_name}'.\n` +
172
- ` Wrote ${join(cwd, ".viber", "auth.json")}\n\n` +
188
+ ` Wrote ${authFilePath(cwd)}\n\n` +
173
189
  `Next steps:\n` +
174
190
  ` 1. Register the channel MCP server (one-time per machine):\n` +
175
191
  ` claude mcp add viber-channel --scope user -- bunx viber-channel@latest\n` +
@@ -1,23 +1,28 @@
1
1
  import { basename } from "node:path";
2
2
  import { cfAccessHeaders } from "./cfAccess.js";
3
+ import { ConversationTokenExpiredError } from "./messages.js";
4
+ import { readHandle, writeHandle } from "./channel_session.js";
3
5
 
4
6
  export interface ConversationMintResponse {
5
7
  conversation_id: string;
6
8
  conversation_token: string;
7
9
  ws_url: string;
8
10
  expires_at: number;
9
- new_project_token?: string;
10
- new_project_token_expires_at?: number | null;
11
11
  }
12
12
 
13
- export class ReverifyRequiredError extends Error {
14
- url: string;
15
- reason: string;
16
- constructor(url: string, reason: string) {
17
- super(`Re-verification required: ${reason}`);
18
- this.name = "ReverifyRequiredError";
19
- this.url = url;
20
- this.reason = reason;
13
+ /**
14
+ * Thrown by `reattachConversation` when the server returns a 409 indicating
15
+ * the requested conversation cannot be reattached (not found, wrong owner,
16
+ * wrong project, fingerprint mismatch, revoked, or race condition).
17
+ * The caller should fall back to `mintConversation`.
18
+ */
19
+ export class ReattachFailedError extends Error {
20
+ /** The `detail` code from the 409 body, e.g. `reattach_not_found` */
21
+ code: string;
22
+ constructor(code: string) {
23
+ super(`Reattach failed: ${code}`);
24
+ this.name = "ReattachFailedError";
25
+ this.code = code;
21
26
  }
22
27
  }
23
28
 
@@ -58,24 +63,151 @@ export async function mintConversation(
58
63
  body: JSON.stringify(requestBody),
59
64
  });
60
65
  if (!resp.ok) {
61
- if (resp.status === 403) {
62
- let body: { type?: string; reverify_url?: string; reason?: string };
66
+ const detail = await resp.text();
67
+ throw new Error(`Failed to mint conversation token (HTTP ${resp.status}): ${detail}`);
68
+ }
69
+ return (await resp.json()) as ConversationMintResponse;
70
+ }
71
+
72
+ /**
73
+ * POST /api/projects/:id/conversations with `reattach_conversation_id` body field.
74
+ *
75
+ * On 201 returns the same `ConversationMintResponse` as `mintConversation`.
76
+ * On 409 throws `ReattachFailedError` with the `detail` code from the body.
77
+ * On any other non-2xx throws a generic Error.
78
+ *
79
+ * Reattach failure (409) is NEVER fatal — the caller should fall back to mint.
80
+ */
81
+ export async function reattachConversation(
82
+ baseUrl: string,
83
+ projectId: number,
84
+ projectToken: string,
85
+ fingerprint: string,
86
+ conversationId: string,
87
+ ): Promise<ConversationMintResponse> {
88
+ const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
89
+ method: "POST",
90
+ headers: {
91
+ Authorization: `Bearer ${projectToken}`,
92
+ "X-Client-Fingerprint": fingerprint,
93
+ "Content-Type": "application/json",
94
+ ...cfAccessHeaders(),
95
+ },
96
+ body: JSON.stringify({ reattach_conversation_id: conversationId }),
97
+ });
98
+
99
+ if (!resp.ok) {
100
+ if (resp.status === 409) {
101
+ let code = "unknown";
63
102
  try {
64
- body = (await resp.json()) as typeof body;
103
+ const body = (await resp.json()) as { detail?: string };
104
+ if (typeof body.detail === "string") code = body.detail;
65
105
  } catch {
66
- body = {};
106
+ // ignore — code stays "unknown"
67
107
  }
68
- if (body.type === "reverify_required" && typeof body.reverify_url === "string") {
69
- throw new ReverifyRequiredError(body.reverify_url, body.reason ?? "unknown");
70
- }
71
- throw new Error(`Failed to mint conversation token (HTTP 403): ${JSON.stringify(body)}`);
108
+ throw new ReattachFailedError(code);
72
109
  }
73
110
  const detail = await resp.text();
74
- throw new Error(`Failed to mint conversation token (HTTP ${resp.status}): ${detail}`);
111
+ throw new Error(`Failed to reattach conversation (HTTP ${resp.status}): ${detail}`);
75
112
  }
113
+
76
114
  return (await resp.json()) as ConversationMintResponse;
77
115
  }
78
116
 
117
+ export interface RefreshTokenResponse {
118
+ conversation_token: string;
119
+ expires_at: number;
120
+ }
121
+
122
+ /**
123
+ * HTTP failure from /refresh-token (any non-401 non-2xx). `.retryable` lets
124
+ * the scheduler distinguish a transient server hiccup (5xx, 408, 409) from a
125
+ * permanent state mismatch (4xx other than 401), so it can back off and try
126
+ * again instead of immediately ending the channel.
127
+ */
128
+ export class RefreshHttpError extends Error {
129
+ status: number;
130
+ detail: string;
131
+ retryable: boolean;
132
+ constructor(status: number, detail: string) {
133
+ super(`Refresh failed (HTTP ${status}): ${detail}`);
134
+ this.name = "RefreshHttpError";
135
+ this.status = status;
136
+ this.detail = detail;
137
+ // 5xx, 408 (timeout), 409 (concurrent-refresh race) — server/transient.
138
+ // 4xx others (403 fingerprint mismatch, etc.) are permanent.
139
+ this.retryable = status >= 500 || status === 408 || status === 409;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Wrapper for `fetch` failures (DNS, connection reset, TLS, etc.). Always
145
+ * retryable — by definition we never got a server response to classify.
146
+ */
147
+ export class RefreshNetworkError extends Error {
148
+ cause: unknown;
149
+ retryable: true = true as const;
150
+ constructor(cause: unknown) {
151
+ super(`Refresh failed: network error: ${String(cause)}`);
152
+ this.name = "RefreshNetworkError";
153
+ this.cause = cause;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * POST /api/conversations/:conversationId/refresh-token
159
+ *
160
+ * Rotates the per-conversation token in place. Worker updates the row's
161
+ * token+expiry and returns the new pair. The caller (scheduler) propagates
162
+ * the new token to module state so subsequent requests use it.
163
+ *
164
+ * @throws ConversationTokenExpiredError on HTTP 401 (token is unknown or has
165
+ * already been rotated away from the value sent in the Authorization header)
166
+ * @throws RefreshHttpError on any other non-2xx status — inspect `.retryable`
167
+ * @throws RefreshNetworkError on fetch-level failure (always retryable)
168
+ */
169
+ export async function refreshConversationToken(
170
+ baseUrl: string,
171
+ conversationId: string,
172
+ currentToken: string,
173
+ fingerprint: string,
174
+ ): Promise<RefreshTokenResponse> {
175
+ let resp: Response;
176
+ try {
177
+ resp = await fetch(
178
+ `${baseUrl}/api/conversations/${conversationId}/refresh-token`,
179
+ {
180
+ method: "POST",
181
+ headers: {
182
+ Authorization: `Bearer ${currentToken}`,
183
+ "X-Client-Fingerprint": fingerprint,
184
+ "Content-Type": "application/json",
185
+ ...cfAccessHeaders(),
186
+ },
187
+ body: "{}",
188
+ },
189
+ );
190
+ } catch (err) {
191
+ throw new RefreshNetworkError(err);
192
+ }
193
+
194
+ if (resp.status === 401) {
195
+ throw new ConversationTokenExpiredError();
196
+ }
197
+
198
+ if (!resp.ok) {
199
+ let detail = "";
200
+ try {
201
+ detail = await resp.text();
202
+ } catch {
203
+ // ignore
204
+ }
205
+ throw new RefreshHttpError(resp.status, detail);
206
+ }
207
+
208
+ return (await resp.json()) as RefreshTokenResponse;
209
+ }
210
+
79
211
  export function defaultLabel(folderPath: string): string {
80
212
  const folder = basename(folderPath);
81
213
  // Local time, not UTC — the user sees this label in the UI; UTC was confusing.
@@ -84,3 +216,89 @@ export function defaultLabel(folderPath: string): string {
84
216
  const ts = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
85
217
  return `${folder} • ${ts}`;
86
218
  }
219
+
220
+ /**
221
+ * Default reattach window in seconds — matches the server's CONVERSATION_TTL_SECONDS.
222
+ * A handle older than this points at an expired conversation that cannot be reattached.
223
+ */
224
+ export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
225
+
226
+ /**
227
+ * Decide whether to reattach to the persisted conversation or mint a fresh one.
228
+ *
229
+ * Decision logic:
230
+ * 1. Read the handle from `sessionPath`. If present AND `now - saved_at < reattachWindowSeconds`,
231
+ * attempt `reattachConversation`.
232
+ * 2. On reattach success: use it.
233
+ * 3. On `ReattachFailedError` (409): log and fall back to `mintConversation`.
234
+ * 4. On no/stale handle: `mintConversation` directly.
235
+ * 5. After either success: write the (possibly new) conversation_id back to `sessionPath`.
236
+ *
237
+ * @param sessionPath Path returned by `sessionFilePath(baseUrl, fingerprint, sessionId, dir)`
238
+ * @param baseUrl Backend base URL
239
+ * @param projectId Project ID from auth.json
240
+ * @param projectToken Bearer token from auth.json
241
+ * @param fingerprint Client fingerprint from auth.json
242
+ * @param label Conversation label (used only when minting)
243
+ * @param targetConversationId Optional target for the attach-to-project flow (passed to mint)
244
+ * @param reattachWindowSeconds Override the default TTL-based window (for testing / env override)
245
+ * @param log Optional stderr writer, defaults to process.stderr.write
246
+ */
247
+ export async function acquireConversation(
248
+ sessionPath: string,
249
+ baseUrl: string,
250
+ projectId: number,
251
+ projectToken: string,
252
+ fingerprint: string,
253
+ label: string,
254
+ targetConversationId?: string | null,
255
+ reattachWindowSeconds: number = DEFAULT_REATTACH_WINDOW_SECONDS,
256
+ log: (msg: string) => void = (msg) => process.stderr.write(msg),
257
+ ): Promise<ConversationMintResponse> {
258
+ const nowSeconds = Math.floor(Date.now() / 1000);
259
+ const handle = readHandle(sessionPath);
260
+
261
+ // Branch on `handle` directly so TS narrows it to non-null inside — readHandle
262
+ // guarantees a finite saved_at, so handleAge is always a number here.
263
+ if (handle !== null) {
264
+ const handleAge = nowSeconds - handle.saved_at;
265
+ if (handleAge < reattachWindowSeconds) {
266
+ log(`[viber-channel] startup: session handle found (conv_id=${handle.conversation_id}, age=${handleAge}s < ${reattachWindowSeconds}s window), attempting reattach\n`);
267
+ try {
268
+ const result = await reattachConversation(baseUrl, projectId, projectToken, fingerprint, handle.conversation_id);
269
+ log(`[viber-channel] startup: reattach OK, conv_id=${result.conversation_id}\n`);
270
+ try {
271
+ writeHandle(sessionPath, result.conversation_id);
272
+ } catch (writeErr) {
273
+ // Best-effort — a failed write degrades to "next startup mints fresh"
274
+ // rather than crashing the channel. Disk full, AV lock, read-only mount.
275
+ log(`[viber-channel] Warning: failed to write session handle after reattach: ${String(writeErr)}\n`);
276
+ }
277
+ return result;
278
+ } catch (err) {
279
+ // Reattach failure is NEVER fatal. Everything (ReattachFailedError 409,
280
+ // unexpected 5xx, network errors from a thrown fetch) falls through to
281
+ // mint below, which surfaces a 401 to the caller if the token is dead.
282
+ const reason = err instanceof ReattachFailedError ? err.code : String(err);
283
+ log(`[viber-channel] startup: reattach failed (${reason}), falling back to mint\n`);
284
+ // Fall through to mint below
285
+ }
286
+ } else {
287
+ log(`[viber-channel] startup: session handle stale (conv_id=${handle.conversation_id}, age=${handleAge}s >= ${reattachWindowSeconds}s window), minting fresh\n`);
288
+ }
289
+ } else {
290
+ log(`[viber-channel] startup: no session handle found, minting fresh\n`);
291
+ }
292
+
293
+ // Mint a new conversation
294
+ const result = await mintConversation(baseUrl, projectId, projectToken, fingerprint, label, targetConversationId ?? null);
295
+ log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
296
+ try {
297
+ writeHandle(sessionPath, result.conversation_id);
298
+ } catch (writeErr) {
299
+ // Best-effort — a failed write degrades to "next startup mints fresh"
300
+ // rather than crashing the channel. Disk full, AV lock, read-only mount.
301
+ log(`[viber-channel] Warning: failed to write session handle after mint: ${String(writeErr)}\n`);
302
+ }
303
+ return result;
304
+ }
package/lib/lockfile.ts CHANGED
@@ -1,25 +1,50 @@
1
1
  /**
2
- * lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL.
2
+ * lockfile.ts — derive the channel's lock file path from VIBER_BASE_URL, the
3
+ * client fingerprint, and an optional per-launch session id.
3
4
  *
4
5
  * The lock prevents two instances of the channel from racing on stdin when
5
- * Claude Code accidentally spawns duplicates. But two channels targeting
6
+ * Claude Code accidentally spawns duplicates. Two channels targeting
6
7
  * *different* backends (e.g. staging + dev) are legitimately different
7
- * processes — they shouldn't share a lock.
8
+ * processes — they shouldn't share a lock; namespacing by base URL lets
9
+ * them coexist.
8
10
  *
9
- * Namespacing the lock by base URL lets them coexist.
11
+ * #267 adds the project axis: the lock is namespaced by `client_fingerprint`
12
+ * (deterministic per machine + folder, from .viber/auth.json). Two *different*
13
+ * projects on the same backend therefore never share a lock — this is the only
14
+ * axis that isolates them for a normal bunx client, where sessionId is always
15
+ * "" (the #259 sessionId axis below provides no isolation there).
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
19
+ * additionally namespaced by it, so two concurrent launches of the *same*
20
+ * project each get their own lock. Channel respawns within one launch inherit
21
+ * the same env var and reuse the same lock — accidental duplicates are still
22
+ * blocked.
10
23
  */
11
24
  import { createHash } from "node:crypto";
12
25
  import { join } from "node:path";
13
26
 
14
27
  /**
15
- * Return the lock file path for a given base URL, under `lockDir`.
28
+ * Return the lock file path for a given base URL, fingerprint, and session id,
29
+ * under `lockDir`.
16
30
  *
17
31
  * 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).
32
+ * prefix of `${baseUrl}\n${fingerprint}\n${sessionId}` (newline-separated so
33
+ * distinct concatenations cannot collide).
34
+ *
35
+ * When `sessionId === ""` (external bunx clients), the hash is stable on
36
+ * `${baseUrl}\n${fingerprint}\n` — so each project (distinct fingerprint)
37
+ * still converges on its own consistent path.
21
38
  */
22
- export function lockFilePath(baseUrl: string, lockDir: string): string {
23
- const suffix = createHash("sha256").update(baseUrl).digest("hex").slice(0, 8);
39
+ export function lockFilePath(
40
+ baseUrl: string,
41
+ fingerprint: string,
42
+ sessionId: string,
43
+ lockDir: string,
44
+ ): string {
45
+ const suffix = createHash("sha256")
46
+ .update(`${baseUrl}\n${fingerprint}\n${sessionId}`)
47
+ .digest("hex")
48
+ .slice(0, 8);
24
49
  return join(lockDir, `channel-${suffix}.lock`);
25
50
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MessageDedup (#254 follow-up) — drops the persisted echo of a user transcript.
3
+ *
4
+ * When a web UI is open, Python delivers the user's voice transcript to the
5
+ * channel TWICE: an ephemeral copy (`id: null`) the instant it is transcribed
6
+ * (the conversation event-bus path from #221), then the persisted copy (real
7
+ * `id`) once the browser saves it to the DB and the Worker republishes it.
8
+ * Both carry identical content, so Claude sees the utterance twice.
9
+ *
10
+ * This tracks recently-seen ephemeral (id-less) messages and reports a later
11
+ * id-bearing message with the same content as a duplicate. The channel forwards
12
+ * each utterance exactly once:
13
+ * - ephemeral message → recorded, forwarded
14
+ * - persisted echo of it → dropped
15
+ * - persisted message with no prior ephemeral (assistant/ai/channel) → forwarded
16
+ * - a genuine repeat (new ephemeral of the same text) → forwarded
17
+ *
18
+ * Content-only, time-bounded matching: a persisted message is only dropped if an
19
+ * ephemeral with the same trimmed content was seen within `ttlMs`.
20
+ */
21
+ export class MessageDedup {
22
+ private readonly recent = new Map<string, number>();
23
+
24
+ constructor(private readonly ttlMs: number = 15_000) {}
25
+
26
+ /**
27
+ * Records ephemeral (id-less) messages; returns `true` for a persisted
28
+ * (id-bearing) message that echoes a recent ephemeral one — i.e. drop it.
29
+ */
30
+ isDuplicate(msg: { id?: string | null; content: string }, now: number = Date.now()): boolean {
31
+ this.prune(now);
32
+ const key = msg.content.trim();
33
+ if (!key) return false;
34
+ const hasId = typeof msg.id === "string" && msg.id.length > 0;
35
+ if (!hasId) {
36
+ this.recent.set(key, now);
37
+ return false;
38
+ }
39
+ return this.recent.has(key);
40
+ }
41
+
42
+ private prune(now: number): void {
43
+ for (const [key, ts] of this.recent) {
44
+ if (now - ts > this.ttlMs) this.recent.delete(key);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Startup stability gate (#257).
3
+ *
4
+ * Defers work by `delayMs` milliseconds, then reports whether the process
5
+ * is still alive (true = proceed, false = shutting down detected).
6
+ *
7
+ * A process whose stdin pipe closes during the window exits via the existing
8
+ * `end` handler BEFORE the timer resolves in practice — but the flag check
9
+ * is a defensive guard in case the timer wins the race.
10
+ */
11
+ export async function awaitStableStartup(
12
+ delayMs: number,
13
+ isShuttingDown: () => boolean,
14
+ ): Promise<boolean> {
15
+ if (delayMs > 0) {
16
+ await new Promise<void>((r) => setTimeout(r, delayMs));
17
+ }
18
+ return !isShuttingDown();
19
+ }
@@ -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.1",
3
+ "version": "0.5.3",
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
@@ -31,6 +31,18 @@ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { runConnect } from "./lib/connect.ts";
33
33
  import { lockFilePath } from "./lib/lockfile.ts";
34
+ import { sessionFilePath, clearHandle } from "./lib/channel_session.ts";
35
+ import { clientFingerprint } from "./lib/fingerprint.ts";
36
+ import { MessageDedup } from "./lib/message_dedup.ts";
37
+ import {
38
+ acquireConversation,
39
+ DEFAULT_REATTACH_WINDOW_SECONDS,
40
+ } from "./lib/conversation.ts";
41
+ import {
42
+ createTokenRefreshScheduler,
43
+ type TokenRefreshScheduler,
44
+ } from "./lib/token_refresh.ts";
45
+ import { awaitStableStartup } from "./lib/startup_gate.ts";
34
46
 
35
47
  // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
36
48
  //
@@ -70,15 +82,71 @@ import { lockFilePath } from "./lib/lockfile.ts";
70
82
  }
71
83
 
72
84
  // 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.
85
+ // Namespaced by (VIBER_BASE_URL, client_fingerprint, VIBER_CHANNEL_SESSION_ID) so:
86
+ // - channels at different backends (staging + dev) coexist (different base_url);
87
+ // - two DIFFERENT projects on the same backend never collide — the fingerprint
88
+ // (deterministic per machine + folder) is the only axis that isolates them
89
+ // for a normal bunx client, where the session id is always "" (#267);
90
+ // - two concurrent launches of the SAME project coexist when each launcher
91
+ // supplies a per-launch session id (#259);
92
+ // - channel respawns within one launch inherit the same session id and
93
+ // re-acquire the same lock — accidental duplicates still blocked (#166).
94
+ //
95
+ // LOCK_FINGERPRINT is computed from the current folder (process.cwd()) rather
96
+ // than read from auth.json so the lock paths are available before loadAuth();
97
+ // it is verified equal to auth.client_fingerprint below — a mismatch exits.
76
98
  const LOCK_DIR =
77
99
  process.env.APPDATA
78
100
  ? join(process.env.APPDATA, "viber")
79
101
  : join(process.env.HOME ?? "/tmp", ".config", "viber");
80
102
  const LOCK_BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
81
- const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_DIR);
103
+ const LOCK_SESSION_ID = process.env.VIBER_CHANNEL_SESSION_ID ?? "";
104
+ const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
105
+ 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);
107
+
108
+ // VIBER_REATTACH_WINDOW_SECONDS overrides the default TTL-based window.
109
+ // A handle older than this cannot point at a live conversation — skip reattach.
110
+ // Parsed once, mirroring the VIBER_REFRESH_LEAD_SECONDS pattern below: validate
111
+ // finite && >= 0, log invalid→ignore, log override, fall back to the default.
112
+ const REATTACH_WINDOW_SECONDS: number = (() => {
113
+ const raw = process.env.VIBER_REATTACH_WINDOW_SECONDS;
114
+ if (raw === undefined) return DEFAULT_REATTACH_WINDOW_SECONDS;
115
+ const parsed = Number.parseInt(raw, 10);
116
+ if (!Number.isFinite(parsed) || parsed < 0) {
117
+ process.stderr.write(
118
+ `[viber-channel] Invalid VIBER_REATTACH_WINDOW_SECONDS=${raw}, ignoring.\n`
119
+ );
120
+ return DEFAULT_REATTACH_WINDOW_SECONDS;
121
+ }
122
+ process.stderr.write(
123
+ `[viber-channel] reattach window overridden to ${parsed}s via VIBER_REATTACH_WINDOW_SECONDS\n`
124
+ );
125
+ return parsed;
126
+ })();
127
+
128
+ // VIBER_STABILITY_DELAY_MS: how long to wait after mcp.connect() before
129
+ // creating a conversation. A doomed process (broken-pipe respawn) dies during
130
+ // this window via the stdin "end" handler, setting shuttingDown=true — the gate
131
+ // then skips acquireConversation and the conversation is never created.
132
+ // Default: 500ms (observed broken-pipe deaths are ~0s; 500ms is imperceptible).
133
+ // Set to 0 to disable (useful for tests / custom launchers).
134
+ const DEFAULT_STABILITY_DELAY_MS = 500;
135
+ const STABILITY_DELAY_MS: number = (() => {
136
+ const raw = process.env.VIBER_STABILITY_DELAY_MS;
137
+ if (raw === undefined) return DEFAULT_STABILITY_DELAY_MS;
138
+ const parsed = Number.parseInt(raw, 10);
139
+ if (!Number.isFinite(parsed) || parsed < 0) {
140
+ process.stderr.write(
141
+ `[viber-channel] Invalid VIBER_STABILITY_DELAY_MS=${raw}, ignoring.\n`
142
+ );
143
+ return DEFAULT_STABILITY_DELAY_MS;
144
+ }
145
+ process.stderr.write(
146
+ `[viber-channel] stability delay overridden to ${parsed}ms via VIBER_STABILITY_DELAY_MS\n`
147
+ );
148
+ return parsed;
149
+ })();
82
150
 
83
151
  function isProcessAlive(pid: number): boolean {
84
152
  try {
@@ -138,10 +206,24 @@ function releaseLock(): void {
138
206
  }
139
207
  }
140
208
 
209
+ // Token-refresh scheduler — null until the initial mint succeeds. Declared at
210
+ // module scope so the early exit handlers below (which fire before the mint
211
+ // completes on slow boots) can cancel it idempotently via optional-chaining.
212
+ let scheduler: TokenRefreshScheduler | null = null;
213
+
214
+ // Stability gate flag (#257): set true as soon as any shutdown path begins so
215
+ // the gate can observe an in-flight shutdown and abort conversation creation.
216
+ let shuttingDown = false;
217
+
141
218
  // Best-effort cleanup — on Windows, signals may not fire (TerminateProcess)
142
- process.on("exit", releaseLock);
143
- process.on("SIGINT", () => { releaseLock(); process.exit(0); });
144
- process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
219
+ process.on("exit", () => { scheduler?.cancel(); releaseLock(); });
220
+ // NOTE: shuttingDown is NOT set here. SIGINT/SIGTERM call process.exit(0) synchronously,
221
+ // so the event loop ends before any setTimeout (the stability gate) can observe the flag.
222
+ // Only the stdin `end` handler sets shuttingDown=true, because it does NOT call
223
+ // process.exit() synchronously — it fires before the MCP transport's read loop begins,
224
+ // giving the gate's setTimeout a chance to run and check the flag.
225
+ process.on("SIGINT", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
226
+ process.on("SIGTERM", () => { scheduler?.cancel(); releaseLock(); process.exit(0); });
145
227
 
146
228
  // stdin EOF — Claude Code closes its end of the pipe when the session
147
229
  // terminates (or the parent process is killed via TerminateProcess on
@@ -157,27 +239,28 @@ process.on("SIGTERM", () => { releaseLock(); process.exit(0); });
157
239
  // `end` fires on real EOF (parent closes write end) and is sufficient for
158
240
  // orphan detection. See plan #236 step-09 for the diagnosis.
159
241
  process.stdin.on("end", () => {
242
+ shuttingDown = true;
160
243
  process.stderr.write("[viber-channel] stdin closed (parent exited), shutting down\n");
244
+ scheduler?.cancel();
161
245
  releaseLock();
162
246
  process.exit(0);
163
247
  });
164
248
 
165
249
  // ---- Auth from .viber/auth.json ----
166
250
 
167
- import { loadAuth, updateAuthToken } from "./lib/auth.ts";
251
+ import { authFilePath, loadAuth } from "./lib/auth.ts";
168
252
  import { handleConversationTokenExpired } from "./lib/channel_errors.ts";
169
253
  import { buildSseUrl } from "./lib/urls.ts";
170
254
 
171
- let auth = loadAuth();
255
+ const auth = loadAuth();
172
256
 
173
257
  // ---- Fingerprint verification ----
174
-
175
- import { clientFingerprint } from "./lib/fingerprint.ts";
176
-
177
- const computedFp = clientFingerprint(process.cwd());
178
- if (computedFp !== auth.client_fingerprint) {
258
+ //
259
+ // LOCK_FINGERPRINT (computed above from process.cwd()) is the same value; this
260
+ // re-checks it against the fingerprint stored in auth.json and exits on drift.
261
+ if (LOCK_FINGERPRINT !== auth.client_fingerprint) {
179
262
  process.stderr.write(
180
- `[viber-channel] Folder fingerprint mismatch — \`.viber/auth.json\` was issued for a different machine/folder.\n` +
263
+ `[viber-channel] Folder fingerprint mismatch — \`${authFilePath()}\` was issued for a different machine/folder.\n` +
181
264
  `Re-run the connect flow at https://viber.dgypx.dev/projects.\n`
182
265
  );
183
266
  process.exit(1);
@@ -185,7 +268,12 @@ if (computedFp !== auth.client_fingerprint) {
185
268
 
186
269
  // ---- Imports for mint ----
187
270
 
188
- import { defaultLabel, mintConversation, ReverifyRequiredError } from "./lib/conversation.ts";
271
+ import {
272
+ defaultLabel,
273
+ refreshConversationToken,
274
+ RefreshHttpError,
275
+ RefreshNetworkError,
276
+ } from "./lib/conversation.ts";
189
277
  import { postMessage, ConversationTokenExpiredError, parseArtifact } from "./lib/messages.ts";
190
278
  import { cfAccessHeaders } from "./lib/cfAccess.ts";
191
279
 
@@ -204,6 +292,7 @@ const mcp = new Server(
204
292
  instructions: [
205
293
  'Voice transcripts arrive as <channel source="viber-channel"> events carrying the user\'s microphone speech.',
206
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.",
207
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.",
208
297
  "Available formats: `markdown` (default), `code`, `json`, `html`.",
209
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.",
@@ -320,8 +409,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
320
409
  };
321
410
  });
322
411
 
323
- // Prevent duplicate instances (#166)
324
- process.stderr.write(`[viber-channel] startup: pid=${process.pid}, acquiring lock\n`);
412
+ // Prevent duplicate instances (#166); fingerprint namespacing for different
413
+ // projects (#267); session-id namespacing for concurrent launches of the same
414
+ // project (#259). The fingerprint and session-id prefixes are logged so a user
415
+ // with multiple channels can tell their stderr logs apart.
416
+ const sessionIdLogged = LOCK_SESSION_ID === "" ? "<unset>" : LOCK_SESSION_ID.slice(0, 8);
417
+ process.stderr.write(
418
+ `[viber-channel] startup: pid=${process.pid}, fp=${LOCK_FINGERPRINT.slice(0, 8)}, sessionId=${sessionIdLogged}, acquiring lock\n`,
419
+ );
325
420
  const blockingPid = acquireLock();
326
421
  process.stderr.write(`[viber-channel] startup: lock acquired (blockingPid=${blockingPid})\n`);
327
422
 
@@ -345,52 +440,160 @@ if (blockingPid !== null) {
345
440
  process.exit(1);
346
441
  }
347
442
 
348
- // ---- Mint conversation token (after mcp.connect so reverify notifications can be sent) ----
443
+ // ---- Stability gate (#257): defer conversation creation so a doomed process exits first ----
444
+ //
445
+ // Claude Code respawns the channel on transport reconnects; a process that dies
446
+ // within the first second (broken-pipe) had already created a conversation
447
+ // because acquireConversation() ran immediately. The gate delays that call so
448
+ // the stdin "end" handler can set shuttingDown=true and let process.exit(0)
449
+ // race the timer. If the gate sees shuttingDown, it exits without creating a
450
+ // conversation. A healthy process proceeds after STABILITY_DELAY_MS with no
451
+ // user-perceptible delay (the user has not spoken yet at that point).
452
+
453
+ process.stderr.write(`[viber-channel] startup: stability gate (${STABILITY_DELAY_MS}ms)...\n`);
454
+ const gateOpen = await awaitStableStartup(STABILITY_DELAY_MS, () => shuttingDown);
455
+ if (!gateOpen) {
456
+ process.stderr.write(`[viber-channel] startup: stability gate detected shutdown — skipping conversation creation\n`);
457
+ process.exit(0);
458
+ }
459
+ process.stderr.write(`[viber-channel] startup: stability gate passed (${STABILITY_DELAY_MS}ms), acquiring conversation\n`);
460
+
461
+ // ---- Acquire conversation token (reattach or mint, after mcp.connect so a token-rejected notification can be sent) ----
349
462
 
350
463
  let CONVERSATION_TOKEN: string;
351
464
  let VOICE_BASE_URL: string;
352
465
  let CONVERSATION_ID: string;
353
466
 
354
- process.stderr.write(`[viber-channel] startup: calling mintConversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"})\n`);
467
+ process.stderr.write(`[viber-channel] startup: acquiring conversation (project=${auth.project_id}, target=${auth.target_conversation_id ?? "null"}, session=${SESSION_FILE})\n`);
355
468
  try {
356
- const minted = await mintConversation(
469
+ const minted = await acquireConversation(
470
+ SESSION_FILE,
357
471
  BASE_URL,
358
472
  auth.project_id,
359
473
  auth.project_token,
360
474
  auth.client_fingerprint,
361
475
  label,
362
476
  auth.target_conversation_id ?? null,
477
+ REATTACH_WINDOW_SECONDS,
363
478
  );
364
- process.stderr.write(`[viber-channel] startup: mint OK, conv_id=${minted.conversation_id}\n`);
365
479
 
366
- // Apply token rotation if the server issued a new project_token
367
- if (minted.new_project_token) {
368
- try {
369
- auth = updateAuthToken(process.cwd(), auth, minted.new_project_token);
370
- } catch (err) {
371
- process.stderr.write(`[viber-channel] Warning: failed to update .viber/auth.json: ${String(err)}\n`);
372
- // Don't exit — token is valid in memory; auth.json will be stale but channel keeps running
373
- }
374
- }
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.
375
482
 
376
483
  CONVERSATION_TOKEN = minted.conversation_token;
377
484
  VOICE_BASE_URL = minted.ws_url;
378
485
  CONVERSATION_ID = minted.conversation_id;
379
- } catch (err) {
380
- if (err instanceof ReverifyRequiredError) {
381
- await mcp.notification({
382
- method: "notifications/claude/channel",
383
- params: {
384
- content: `⚠️ Re-verification required (reason: ${err.reason}). Visit ${err.url} to re-authenticate, then restart this channel.`,
385
- meta: { source: "system", type: "reverify_required", url: err.url },
386
- },
387
- });
388
- process.exit(2);
486
+
487
+ // Schedule silent token refresh ahead of expiry (#237 step-03). On success
488
+ // we mutate CONVERSATION_TOKEN in place — getHeaders() reads it by closure,
489
+ // so subsequent SSE reconnects and send_message calls pick up the new token
490
+ // automatically. On failure we fall through to the existing
491
+ // handleConversationTokenExpired path (notification + exit code 3).
492
+ //
493
+ // VIBER_REFRESH_LEAD_SECONDS overrides the default 300s lead window — used
494
+ // for shortening the refresh-before-expiry gap during E2E manual testing
495
+ // (set close to TTL to force a refresh seconds after startup). Out of band
496
+ // for normal operation; the scheduler module's default applies when unset.
497
+ const leadOverride = process.env.VIBER_REFRESH_LEAD_SECONDS;
498
+ const leadSeconds = leadOverride ? Number.parseInt(leadOverride, 10) : undefined;
499
+ if (leadOverride !== undefined) {
500
+ if (!Number.isFinite(leadSeconds) || (leadSeconds as number) < 0) {
501
+ process.stderr.write(
502
+ `[viber-channel] Invalid VIBER_REFRESH_LEAD_SECONDS=${leadOverride}, ignoring.\n`
503
+ );
504
+ } else {
505
+ process.stderr.write(
506
+ `[viber-channel] token refresh lead overridden to ${leadSeconds}s via VIBER_REFRESH_LEAD_SECONDS\n`
507
+ );
508
+ }
389
509
  }
510
+ const validLead =
511
+ leadSeconds !== undefined && Number.isFinite(leadSeconds) && leadSeconds >= 0
512
+ ? leadSeconds
513
+ : undefined;
514
+
515
+ // Track the current token's expiry so the retry path can refuse to retry
516
+ // past it. Updated only after a successful refresh — failed attempts leave
517
+ // it pointing at the still-valid current token.
518
+ let currentExpiresAt = minted.expires_at;
519
+
520
+ scheduler = createTokenRefreshScheduler(
521
+ async () => {
522
+ // Retry transient failures (5xx, 408, 409, network) with exponential
523
+ // backoff so a momentary blip during the 5-min lead window doesn't kill
524
+ // a channel whose token is still valid. Permanent failures (401, 403)
525
+ // surface immediately.
526
+ const MAX_ATTEMPTS = 5;
527
+ const HEADROOM_SECONDS = 10;
528
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
529
+ try {
530
+ const refreshed = await refreshConversationToken(
531
+ BASE_URL,
532
+ CONVERSATION_ID,
533
+ CONVERSATION_TOKEN,
534
+ auth.client_fingerprint,
535
+ );
536
+ // SSE connection: the server accepts the new token immediately on
537
+ // subsequent requests, and any in-flight fetch still has the old
538
+ // token attached at the header level — the server keeps the open
539
+ // stream alive on the old token until its natural close. Safe to
540
+ // overwrite the module variable here.
541
+ CONVERSATION_TOKEN = refreshed.conversation_token;
542
+ currentExpiresAt = refreshed.expires_at;
543
+ return { expiresAt: refreshed.expires_at };
544
+ } catch (err) {
545
+ const retryable =
546
+ (err instanceof RefreshHttpError && err.retryable) ||
547
+ err instanceof RefreshNetworkError;
548
+ if (!retryable || attempt === MAX_ATTEMPTS) throw err;
549
+ // Bound retries by token validity: never sleep past expiry, leave
550
+ // a small headroom so the post-sleep request has time to land.
551
+ const proposedDelay = Math.min(30 * 2 ** (attempt - 1), 120);
552
+ const remaining = currentExpiresAt - Date.now() / 1000;
553
+ if (remaining - proposedDelay < HEADROOM_SECONDS) throw err;
554
+ process.stderr.write(
555
+ `[viber-channel] token refresh attempt ${attempt} failed (${String(err)}); retry in ${proposedDelay}s (${Math.round(remaining)}s until expiry)\n`,
556
+ );
557
+ await new Promise((r) => setTimeout(r, proposedDelay * 1000));
558
+ }
559
+ }
560
+ // Unreachable — the loop above either returns the refreshed state or
561
+ // throws on attempt === MAX_ATTEMPTS.
562
+ throw new Error("token refresh: retry loop exhausted without resolution");
563
+ },
564
+ async (err) => {
565
+ process.stderr.write(`[viber-channel] token refresh failed: ${String(err)}\n`);
566
+ // We don't call scheduler.cancel() here — we're inside the scheduler's
567
+ // failure path, and handleConversationTokenExpired ends the process anyway.
568
+ await handleConversationTokenExpired(mcp);
569
+ },
570
+ validLead !== undefined ? { leadSeconds: validLead } : undefined,
571
+ );
572
+ scheduler.start(minted.expires_at);
573
+ } catch (err) {
574
+ // The conversation mint failed. With the stable-token model (#274) a revoked
575
+ // or otherwise invalid project_token yields HTTP 401 — there is no reverify
576
+ // dance and no rotation, so the only recovery is to re-run the connect flow.
577
+ // Network errors and unexpected 5xx land here too; the message stays generic
578
+ // enough to cover them while pointing at the most common cause. The stderr
579
+ // line below is invisible in the Claude Code UI, which only sees the MCP
580
+ // server die ("tools fetch failed") — surface an actionable notification so
581
+ // the user knows what to do instead of failing opaquely. Clean exit, no retry.
390
582
  process.stderr.write(`[viber-channel] ${String(err)}\n`);
391
583
  process.stderr.write(
392
- `[viber-channel] If your project_token is no longer valid, run the connect flow again at https://viber.dgypx.dev/projects.\n`
584
+ `[viber-channel] If your project_token is no longer valid, run the connect flow again at ${BASE_URL}/projects.\n`
393
585
  );
586
+ await mcp.notification({
587
+ method: "notifications/claude/channel",
588
+ params: {
589
+ content:
590
+ `⚠️ Could not start the voice channel: ${String(err)}. ` +
591
+ `If your project_token was revoked, reconnect at ${BASE_URL}/projects and run ` +
592
+ `\`bunx viber-channel connect <url>\` again, then restart this channel. ` +
593
+ `Otherwise this may be a temporary network issue — restart the channel to retry.`,
594
+ meta: { source: "system", type: "project_token_invalid" },
595
+ },
596
+ });
394
597
  process.exit(1);
395
598
  }
396
599
 
@@ -435,6 +638,11 @@ async function pushTranscript(text: string, lang: string): Promise<void> {
435
638
  });
436
639
  }
437
640
 
641
+ // Drops the persisted echo of a user transcript already delivered ephemerally
642
+ // (the conversation event bus delivers it twice when a web UI is open — #221
643
+ // mechanism, surfaced by the #254 Active section that keeps the UI open).
644
+ const messageDedup = new MessageDedup();
645
+
438
646
  /**
439
647
  * Forward a saved conversation message from the event bus to Claude.
440
648
  *
@@ -452,6 +660,12 @@ async function pushMessage(msg: {
452
660
  artifact?: { content: string; format?: "markdown" | "code" | "json" | "html" } | null;
453
661
  }): Promise<void> {
454
662
  process.stderr.write(`[viber-channel] pushMessage ENTER: "${msg.content.slice(0, 60)}"\n`);
663
+ if (messageDedup.isDuplicate(msg)) {
664
+ process.stderr.write(
665
+ `[viber-channel] dropping duplicate persisted message (already delivered ephemerally)\n`,
666
+ );
667
+ return;
668
+ }
455
669
  // MCP notification handler (Claude Code v2.1.143) validates meta with Zod;
456
670
  // null fields are rejected as invalid type. Omit message_id when missing.
457
671
  const meta: Record<string, unknown> = { source: msg.source ?? "conversation" };
@@ -528,7 +742,9 @@ async function sseLoop(): Promise<void> {
528
742
  break;
529
743
 
530
744
  case "stop":
531
- process.stderr.write(`[viber-channel] Received stop signal, exiting.\n`);
745
+ process.stderr.write(`[viber-channel] Received stop signal, clearing session handle and exiting.\n`);
746
+ clearHandle(SESSION_FILE);
747
+ scheduler?.cancel();
532
748
  releaseLock();
533
749
  process.exit(0);
534
750
  break;