viber-channel 0.8.14 → 0.8.16

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.
@@ -19,6 +19,7 @@
19
19
  import { postMessage, parseArtifact, type Artifact } from "./messages.js";
20
20
  import { listPeersAuto, openDm, type OpenDmResult } from "./peers.js";
21
21
  import { ConversationTokenExpiredError } from "./messages.js";
22
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
22
23
 
23
24
  /** MCP tool-result shape (text content + optional error flag). */
24
25
  export interface AgentToolResult {
@@ -26,6 +27,28 @@ export interface AgentToolResult {
26
27
  content: { type: "text"; text: string }[];
27
28
  }
28
29
 
30
+ /**
31
+ * #489 step-04 — adapt a tool result to what the MCP SDK's handler signature wants.
32
+ *
33
+ * The SDK's `CallToolResult` carries an index signature (the MCP result object is
34
+ * open: `_meta` and future fields pass through), so `AgentToolResult` was NOT
35
+ * assignable to the SDK handler type — the TS2345 at `lib/bridge_tool_host.ts` and
36
+ * `viber-channel.ts`. Verified against the SDK schema, not against our output:
37
+ * `{ content: [{ type: "text", text }], isError }` IS a valid `CallToolResult`. The
38
+ * missing openness was the ONLY incompatibility, so there is no protocol divergence.
39
+ *
40
+ * The openness lives HERE, at the SDK boundary, and NOT on `AgentToolResult` — review
41
+ * measured that opening our own interface silently disables excess-property checking
42
+ * on it, so `{ content: […], isErorr: true }` would compile. Keeping our type closed
43
+ * preserves that check at every construction site; the single unavoidable widening is
44
+ * named, commented, and confined to this function.
45
+ */
46
+ export function toMcpToolResult(result: AgentToolResult): CallToolResult {
47
+ // No cast: the spread is a FRESH object type, which TypeScript grants an implicit
48
+ // index signature when assigning to the SDK's open result type.
49
+ return { ...result };
50
+ }
51
+
29
52
  /**
30
53
  * Live, by-reference access to the host channel's runtime state. Implemented by
31
54
  * both hosts so the shared tool logic never reads a stale snapshot. Every getter
@@ -16,6 +16,7 @@
16
16
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
17
  import { createTokenRefreshScheduler, type TokenRefreshScheduler } from "./token_refresh.js";
18
18
  import type { ApiBaseUrlResolver } from "./base_urls.js";
19
+ import { acquireLockFile, isProcessAlive } from "./bridge_lock.js";
19
20
  import { lockFilePath } from "./lockfile.js";
20
21
  import {
21
22
  type ConversationMintResponse,
@@ -159,24 +160,24 @@ export function planAwaitInviteFirstPass<T>(msgs: T[]): { forward: T[]; seed: T[
159
160
  return { forward: [msgs[msgs.length - 1]], seed: msgs.slice(0, -1) };
160
161
  }
161
162
 
162
- /** True if a PID names a live process (EPERM counts as alive). */
163
- export function isProcessAlive(pid: number): boolean {
164
- if (!Number.isInteger(pid) || pid <= 0) return false;
165
- try {
166
- process.kill(pid, 0);
167
- return true;
168
- } catch (err) {
169
- if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") return true;
170
- return false;
171
- }
172
- }
163
+ /**
164
+ * #489 step-02: re-exported from `lib/bridge_lock.ts`, which is now the single
165
+ * implementation. Kept exported here because callers and tests already import it
166
+ * from this module.
167
+ */
168
+ export { isProcessAlive };
173
169
 
174
170
  /**
175
- * Acquire the per-agent bridge lock (a PID file). `sessionId` is the identity
176
- * axis (e.g. `codex-agent:<instanceId>` / `gemma-agent:<instanceId>`), so two
177
- * processes with the SAME identity cannot both run. A stale lock (dead PID) is
178
- * reclaimed; a live one throws a BridgeShutdownError. `logPrefix` keeps each
179
- * bridge's log lines under its own tag.
171
+ * Acquire the per-agent bridge lock (a PID file). `sessionId` is the identity axis
172
+ * (e.g. `codex-agent:<instanceId>` / `gemma-agent:<instanceId>`), so two processes
173
+ * with the SAME identity cannot both run. A stale lock (dead PID) is reclaimed; a
174
+ * live one throws a BridgeShutdownError. `logPrefix` keeps each bridge's log lines
175
+ * under its own tag.
176
+ *
177
+ * #489 step-02: a thin adapter now. The loop, the payload and the reclaim decision
178
+ * live in `lib/bridge_lock.ts`, shared with the MCP client — the duplication was one
179
+ * of the defects of #489. What stays HERE is this site's error protocol: throw, so
180
+ * the bridge dies rather than reporting a pid to a human.
180
181
  */
181
182
  export function acquireBridgeLock(opts: {
182
183
  /**
@@ -190,49 +191,37 @@ export function acquireBridgeLock(opts: {
190
191
  sessionId: string;
191
192
  lockDir: string;
192
193
  logPrefix: string;
194
+ /** Called if the lock is lost mid-run — the caller tears the bridge down. */
195
+ onLost?: (reason: string) => void;
193
196
  }): BridgeLock {
194
197
  const { identityBaseUrl, fingerprint, sessionId, lockDir, logPrefix } = opts;
195
- mkdirSync(lockDir, { recursive: true });
196
198
  const path = lockFilePath(identityBaseUrl, fingerprint, sessionId, lockDir);
197
- const release = (): void => {
198
- try {
199
- unlinkSync(path);
200
- } catch {
201
- // Best effort: the file may already be gone.
202
- }
203
- };
204
-
205
- for (let attempt = 0; attempt < 2; attempt++) {
206
- try {
207
- writeFileSync(path, `${process.pid}\n`, { flag: "wx" });
208
- process.stderr.write(`${logPrefix} lock acquired: ${path}\n`);
209
- return { path, release };
210
- } catch (err) {
211
- if (typeof err !== "object" || err === null || !("code" in err) || err.code !== "EEXIST") {
212
- throw err;
213
- }
214
-
215
- let existingPid = Number.NaN;
216
- try {
217
- existingPid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
218
- } catch {
219
- release();
220
- continue;
221
- }
222
-
223
- if (!Number.isFinite(existingPid) || !isProcessAlive(existingPid)) {
224
- release();
225
- continue;
226
- }
227
-
228
- throw new BridgeShutdownError(
229
- `Another bridge already holds the lock ${sessionId} (PID ${existingPid})`,
230
- 1,
231
- );
232
- }
199
+ const result = acquireLockFile({
200
+ lockFile: path,
201
+ // #489 step-03 (review): losing the lock is a FENCING signal, not a log line. A
202
+ // dispossessed bridge that keeps running has no authority over its identity, which
203
+ // is the unbounded duplicate this design promises not to create. Withdraw the same
204
+ // way the parent watchdog does: request shutdown and let the normal teardown run.
205
+ onLost: (reason) => {
206
+ process.stderr.write(`${logPrefix} ${reason} — withdrawing
207
+ `);
208
+ opts.onLost?.(reason);
209
+ },
210
+ });
211
+ if (result.ok) {
212
+ process.stderr.write(`${logPrefix} lock acquired: ${path}
213
+ `);
214
+ // The release closure captures the path it actually took — it never
215
+ // recomputes it from the environment (#489 step-02, raised in review).
216
+ return { path, release: result.release };
233
217
  }
234
-
235
- throw new BridgeShutdownError(`Failed to acquire bridge lock ${sessionId}`, 1);
218
+ if (result.blockedBy === -1) {
219
+ throw new BridgeShutdownError(`Failed to acquire bridge lock ${sessionId}`, 1);
220
+ }
221
+ throw new BridgeShutdownError(
222
+ `Another bridge already holds the lock ${sessionId} (PID ${result.blockedBy})`,
223
+ 1,
224
+ );
236
225
  }
237
226
 
238
227
  /** Optional VIBER_REFRESH_LEAD_SECONDS override (clamped to ≥ 0). */
@@ -1159,6 +1148,12 @@ export async function acquireBridgeIdentity(opts: {
1159
1148
  sessionId: `${sessionTag}-agent:${acquired.instance_key}`,
1160
1149
  lockDir,
1161
1150
  logPrefix,
1151
+ // #489 step-03 (review A): fencing — a dispossessed bridge must not keep running.
1152
+ onLost: (reason) => {
1153
+ process.stderr.write(`${logPrefix} ${reason} — withdrawing to avoid a duplicate
1154
+ `);
1155
+ process.exit(1);
1156
+ },
1162
1157
  });
1163
1158
  return {
1164
1159
  instanceKey: acquired.instance_key,
@@ -0,0 +1,323 @@
1
+ /**
2
+ * bridge_lock.ts — the ONE implementation of the per-folder / per-agent lock
3
+ * (#489 step-02).
4
+ *
5
+ * Before this file, the same invariant was implemented TWICE, with real divergences:
6
+ *
7
+ * | | viber-channel.ts (MCP client) | lib/bridge_core.ts (bridges) |
8
+ * |----------------|-------------------------------|------------------------------|
9
+ * | liveness probe | local isProcessAlive | exported isProcessAlive |
10
+ * | `pid <= 0` | ABSENT | present |
11
+ * | on conflict | returns the blocking pid | throws BridgeShutdownError |
12
+ *
13
+ * Fixing only the bridge would have left the Claude MCP client broken, so both go
14
+ * through here. The two error protocols are PRESERVED on purpose and NOT unified:
15
+ * the MCP client prints the blocking pid for the user, the bridge dies. Only the
16
+ * decision — is this lock reclaimable? — is shared, and it is a PURE function so it
17
+ * can be exhausted by tests without touching a filesystem.
18
+ *
19
+ * Dependencies (liveness, clock, fs) are injected with real defaults: step-03 needs
20
+ * to drive them, and a probe hard-wired to `process.kill` is precisely what made the
21
+ * recycled-PID case untestable.
22
+ */
23
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { dirname } from "node:path";
25
+ import lockfile from "proper-lockfile";
26
+ import { classifyLegacyHolder, processStartTime } from "./process_start.js";
27
+ import { lockTimingOptions } from "./lock_timing.js";
28
+
29
+ /** True if a PID names a live process (EPERM counts as alive: it exists, it is not ours). */
30
+ export function isProcessAlive(pid: number): boolean {
31
+ // The `pid <= 0` guard came from the bridge side only; the MCP client lacked it.
32
+ // Negative/zero pids have platform-specific meanings in kill(2) (process groups),
33
+ // none of which is "the holder of this lock".
34
+ if (!Number.isInteger(pid) || pid <= 0) return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (err) {
39
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "EPERM") return true;
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** What a reader concludes about an existing lock file. */
45
+ export type LockVerdict =
46
+ | { kind: "reclaimable"; why: string }
47
+ | { kind: "held"; pid: number };
48
+
49
+ /** Everything the decision needs, so it stays pure and fully testable. */
50
+ export interface LockDecisionInput {
51
+ /** Raw file contents, or `null` when unreadable/absent. */
52
+ payload: string | null;
53
+ isAlive: (pid: number) => boolean;
54
+ }
55
+
56
+ /**
57
+ * Decide whether an existing lock can be taken over. PURE — no I/O, no clock.
58
+ *
59
+ * Today's rule is exactly the historical one, so step-02 stays a refactor: a lock is
60
+ * reclaimable when its payload is unreadable or its PID is not alive. Step-03 changes
61
+ * THIS function, and the tests it already has become the regression net.
62
+ */
63
+ export function decideLock(input: LockDecisionInput): LockVerdict {
64
+ const { payload, isAlive } = input;
65
+ if (payload === null) return { kind: "reclaimable", why: "unreadable" };
66
+ const pid = Number.parseInt(payload.trim(), 10);
67
+ if (!Number.isFinite(pid)) return { kind: "reclaimable", why: "malformed payload" };
68
+ if (!isAlive(pid)) return { kind: "reclaimable", why: `pid ${pid} is not alive` };
69
+ return { kind: "held", pid };
70
+ }
71
+
72
+ export interface AcquireLockFileOptions {
73
+ /** Absolute path of the lock file. */
74
+ lockFile: string;
75
+ /**
76
+ * Liveness probe, used ONLY for the legacy-payload path below. The authoritative
77
+ * mechanism no longer looks at PIDs at all — see the module header.
78
+ */
79
+ isAlive?: (pid: number) => boolean;
80
+ /**
81
+ * Process start time for a PID, epoch ms, or `undefined` when unknowable. Resolves
82
+ * the legacy/recycled ambiguity documented below BY PROOF, not by resemblance.
83
+ */
84
+ startTimeOf?: (pid: number) => number | undefined;
85
+ /**
86
+ * Called when the library reports our lock COMPROMISED (its directory vanished under
87
+ * us — someone reclaimed it after the staleness window). The default logs; every real
88
+ * caller passes a controlled withdrawal, because continuing without authority is the
89
+ * duplicate this design promises to bound.
90
+ */
91
+ onLost?: (reason: string) => void;
92
+ /** PID written for diagnostics and for older clients to see. Defaults to ours. */
93
+ pid?: number;
94
+ /**
95
+ * Staleness regime. Defaults to the PINNED values of `lib/lock_timing.ts`; injected
96
+ * only by tests, which cannot wait 5 minutes to prove that a crashed holder's lock
97
+ * becomes reclaimable.
98
+ */
99
+ timing?: { stale: number; update: number };
100
+ }
101
+
102
+ export type AcquireLockFileResult =
103
+ | { ok: true; release: () => void }
104
+ | { ok: false; blockedBy: number };
105
+
106
+ /**
107
+ * Read the legacy PID payload, if any. `null` when absent or unusable.
108
+ */
109
+ function readLegacyPid(lockFile: string): number | null {
110
+ try {
111
+ const pid = Number.parseInt(readFileSync(lockFile, "utf-8").trim(), 10);
112
+ return Number.isFinite(pid) ? pid : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Take the lock.
120
+ *
121
+ * TWO mechanisms, on purpose, and the order matters:
122
+ *
123
+ * 1. **Legacy PID file, checked FIRST.** A client from before #489 advertises itself
124
+ * only by writing its PID into `lockFile`; it knows nothing about the directory
125
+ * `proper-lockfile` uses. If we skipped this check, a new client would see no
126
+ * `.lock` directory, acquire, and run ALONGSIDE a live old holder — #311 re-opened
127
+ * for the whole duration of a mixed deployment (and mixed versions do exist here:
128
+ * the embedded plugin and vibe-master run the same source, npm is the only
129
+ * authority — #503). So: legacy payload + LIVE pid = held. Reviewers required
130
+ * exactly this, and only on a live pid — a dead one is reclaimable, which is what
131
+ * makes the 81 residual locks disappear.
132
+ *
133
+ * 2. **`proper-lockfile` for everything else** — the authority. Its lock carries NO
134
+ * PID: mutual exclusion is a `mkdir` CAS and liveness is the mtime it refreshes.
135
+ * That is why the #489 defect does not come back — a recycled PID cannot make a
136
+ * dead holder look alive, because no PID is consulted at all.
137
+ *
138
+ * After acquiring we still WRITE our pid into `lockFile`, for two reasons: an older
139
+ * client must be able to see that the folder is taken, and the message shown to a human
140
+ * ("another agent holds this folder, PID N") stays useful. That PID is diagnostic
141
+ * only — it is never read as a liveness signal by this code.
142
+ */
143
+ export function acquireLockFile(opts: AcquireLockFileOptions): AcquireLockFileResult {
144
+ const { lockFile } = opts;
145
+ const isAlive = opts.isAlive ?? isProcessAlive;
146
+ const pid = opts.pid ?? process.pid;
147
+ const onLost = opts.onLost ?? ((reason: string) => process.stderr.write(`[viber-lock] ${reason}
148
+ `));
149
+
150
+ mkdirSync(dirname(lockFile), { recursive: true });
151
+
152
+ const legacyPid = readLegacyPid(lockFile);
153
+ if (legacyPid !== null && !existsSync(`${lockFile}.lock`) && isAlive(legacyPid)) {
154
+ // ── The one genuinely ambiguous state, and how it is settled ──────────────
155
+ //
156
+ // On disk these are IDENTICAL: a pre-#489 client still running (pid file, no lock
157
+ // directory, pid alive) and a leftover pid file whose number the OS recycled. Both
158
+ // reviewer requirements land here and pull opposite ways — "respect a live legacy
159
+ // holder" vs "never block permanently on a recycled pid", the latter being why #489
160
+ // exists at all, since the reported case WAS a leftover file.
161
+ //
162
+ // Settled by PROOF, not resemblance. A first attempt compared process NAMES, and
163
+ // review demolished it with a measurement: 110 live bun/node processes on the dev
164
+ // machine, so a recycled pid landing on any other bun was read as a live holder and
165
+ // the lock stayed immortal — the #489 bug surviving in its likeliest case.
166
+ //
167
+ // The proof is a timestamp the same query already returns: a real holder existed
168
+ // BEFORE it wrote its lock file; a recycled pid started AFTER the file was written.
169
+ // See lib/process_start.ts.
170
+ const probe = opts.startTimeOf ?? processStartTime;
171
+ let fileMtimeMs = 0;
172
+ try {
173
+ fileMtimeMs = statSync(lockFile).mtimeMs;
174
+ } catch {
175
+ fileMtimeMs = 0;
176
+ }
177
+ const verdict = classifyLegacyHolder({ startedAt: probe(legacyPid), fileMtimeMs });
178
+ // "unknown" stays conservative: refusing to start is recoverable, two agents on one
179
+ // identity is not.
180
+ if (verdict !== "recycled") {
181
+ return { ok: false, blockedBy: legacyPid };
182
+ }
183
+ }
184
+
185
+ let release: () => void;
186
+ try {
187
+ release = lockfile.lockSync(lockFile, {
188
+ ...(opts.timing ?? lockTimingOptions()),
189
+ retries: 0,
190
+ /**
191
+ * MUST be provided. `proper-lockfile`'s default handler THROWS when its lock
192
+ * directory disappears under a live holder (another process reclaimed it after
193
+ * the staleness window, or something cleaned the directory). That throw happens
194
+ * inside the library's own refresh timer, i.e. outside any of our try/catch —
195
+ * an uncaught exception that would take the whole agent down. Hit for real while
196
+ * building the crash-reclaim test.
197
+ *
198
+ * But swallowing it is just as wrong: a dispossessed holder that keeps running has
199
+ * no authority over its identity while another process may already hold it — the
200
+ * unbounded duplicate this design promises not to create. So we hand it to
201
+ * `onLost`, and every entry point wires that to a controlled withdrawal (the shape
202
+ * `onParentGone` uses in viber-channel.ts).
203
+ */
204
+ onCompromised: (err: Error) => {
205
+ onLost(`lock compromised: ${err.message}`);
206
+ },
207
+ // The target need not exist: we lock a PATH, and the pid file below is written
208
+ // only once the lock is ours.
209
+ realpath: false,
210
+ });
211
+ } catch (err) {
212
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ELOCKED") {
213
+ // Held by a live holder. Report its pid when the diagnostic file has one.
214
+ return { ok: false, blockedBy: readLegacyPid(lockFile) ?? -1 };
215
+ }
216
+ throw err;
217
+ }
218
+
219
+ try {
220
+ writeFileSync(lockFile, `${pid}\n`);
221
+ } catch {
222
+ // Diagnostics only — never fail an acquisition over it.
223
+ }
224
+
225
+ // #489 step-03 (review C): REPAIR the diagnostic pid file periodically.
226
+ //
227
+ // Review measured the real severity of losing it: the library's directory still holds
228
+ // the authority, but a pre-#489 client reads ONLY this file — so if it disappears,
229
+ // that old client walks in and stays for B's whole lifetime, not for microseconds.
230
+ // Re-creating it on a beat bounds the exposure to one interval. Same cadence as the
231
+ // library's own refresh; `unref` so it can never keep a process alive.
232
+ const repair = setInterval(() => {
233
+ try {
234
+ if (readLegacyPid(lockFile) !== pid) writeFileSync(lockFile, `${pid}
235
+ `);
236
+ } catch {
237
+ // Diagnostic only.
238
+ }
239
+ }, (opts.timing ?? lockTimingOptions()).update);
240
+ repair.unref?.();
241
+
242
+ let released = false;
243
+ return {
244
+ ok: true,
245
+ release: () => {
246
+ // Idempotent: exit handlers can fire twice, and releasing twice must not throw.
247
+ if (released) return;
248
+ released = true;
249
+ clearInterval(repair);
250
+ try {
251
+ release();
252
+ } catch {
253
+ // Already gone / reclaimed elsewhere.
254
+ }
255
+ // #489 step-03 (review): remove the diagnostic pid file ONLY IF IT IS STILL OURS.
256
+ //
257
+ // Review asked for no unlink at all, to close this window: we release the
258
+ // library's lock, B acquires and writes ITS pid, and our unlink would delete B's
259
+ // file — after which an old client, which only reads that file, would miss B and
260
+ // start in parallel.
261
+ //
262
+ // Measured consequence of never unlinking, though: our own pid file survives our
263
+ // release, our process is still alive and STARTED BEFORE the file, so the
264
+ // start-time proof classifies us as a live legacy holder — and the folder is
265
+ // blocked for good. A DETERMINISTIC immortal lock, i.e. the #489 bug re-created by
266
+ // its own fix. Two tests caught it.
267
+ //
268
+ // So: compare, then unlink. The residual race is a read-then-unlink of a few
269
+ // microseconds whose worst outcome is a MISSING diagnostic file (bounded, and the
270
+ // library's directory still holds the authority), against a permanent block. The
271
+ // window is stated rather than hidden.
272
+ try {
273
+ if (readLegacyPid(lockFile) === pid) unlinkSync(lockFile);
274
+ } catch {
275
+ // Diagnostic only — never fail a release over it.
276
+ }
277
+ },
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Hold the release of a lock we ACTUALLY took — nothing else (#489 step-02).
283
+ *
284
+ * This exists because of a real bug in the pre-#489 code, found in review and
285
+ * verified on `e67a2e0`:
286
+ *
287
+ * - `process.on("exit", … releaseLock())` was registered at MODULE scope
288
+ * (`viber-channel.ts:335`), therefore BEFORE `acquireLock()` ran (l.523);
289
+ * - the old `releaseLock()` did an UNCONDITIONAL `unlinkSync(LOCK_FILE)`;
290
+ * - a launch that LOST the race went on to `process.exit(1)`, firing that handler.
291
+ *
292
+ * So every accidental duplicate ended by DELETING THE WINNER'S LOCK: the folder went
293
+ * unprotected while the winner was still running, and a third launch acquired freely.
294
+ * The invariant this lock exists to hold (#166/#311) was broken by its own error path.
295
+ *
296
+ * The slot makes the guarantee structural instead of incidental: `releaseIfHeld()` is
297
+ * a no-op unless an acquisition succeeded and handed over its release. Kept HERE, in
298
+ * the tested module, rather than as a bare `let` in the entry point that no test can
299
+ * import.
300
+ */
301
+ export interface LockSlot {
302
+ /** Record the release of a successful acquisition. */
303
+ adopt: (release: () => void) => void;
304
+ /** Release only if we hold it. Idempotent — exit handlers can fire twice. */
305
+ releaseIfHeld: () => void;
306
+ /** True once an acquisition has been adopted and not yet released. */
307
+ isHeld: () => boolean;
308
+ }
309
+
310
+ export function createLockSlot(): LockSlot {
311
+ let release: (() => void) | null = null;
312
+ return {
313
+ adopt: (r) => {
314
+ release = r;
315
+ },
316
+ releaseIfHeld: () => {
317
+ const r = release;
318
+ release = null;
319
+ r?.();
320
+ },
321
+ isHeld: () => release !== null,
322
+ };
323
+ }
@@ -27,6 +27,7 @@ import {
27
27
  messageAgent,
28
28
  sendMessage,
29
29
  type AgentToolsContext,
30
+ toMcpToolResult,
30
31
  type AgentToolResult,
31
32
  } from "./agent_tools.js";
32
33
  import { CONTRACT_STYLE_NEUTRAL, CONTRACT_STYLE_TOOLS } from "./channel_instructions.js";
@@ -166,7 +167,7 @@ function buildServer(opts: BridgeToolHostOptions): Server {
166
167
  mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
167
168
  const args = (request.params.arguments ?? {}) as Record<string, unknown>;
168
169
  try {
169
- return await dispatchBridgeTool(request.params.name, args, opts);
170
+ return toMcpToolResult(await dispatchBridgeTool(request.params.name, args, opts));
170
171
  } catch (err) {
171
172
  // The shared lib rethrows ConversationTokenExpiredError (host-specific
172
173
  // teardown). In the bridge we surface it as a tool error rather than
@@ -0,0 +1,62 @@
1
+ /**
2
+ * lock_dir.ts — resolve the directory holding this machine's channel locks and
3
+ * conversation handles (#489 step-01).
4
+ *
5
+ * Three entry points (`viber-channel.ts`, `viber-codex-bridge.ts`,
6
+ * `viber-gemma-bridge.ts`) each carried the SAME copy-pasted expression as a
7
+ * module-level `const`. Two consequences, both paid by #489:
8
+ *
9
+ * 1. resolved at IMPORT time, so nothing could redirect it afterwards — a test
10
+ * importing any of those modules read and WROTE the real `%APPDATA%/viber`,
11
+ * took a real lock, and never released it;
12
+ * 2. three copies to keep in step.
13
+ *
14
+ * Resolution is therefore LAZY (called at each use) and lives here once.
15
+ *
16
+ * ⚠ This directory holds more than locks: the conversation handles of #269
17
+ * (`sessionFilePath`) live here too. Redirecting it moves the handles as well, and
18
+ * an agent that loses its handle RE-MINTS its conversation. That is why the
19
+ * override is opt-in, and why it is logged when set.
20
+ */
21
+ import { join } from "node:path";
22
+
23
+ /** Env var that redirects locks + handles. Tests set it; production never does. */
24
+ export const LOCK_DIR_ENV = "VIBER_LOCK_DIR";
25
+
26
+ let announced = false;
27
+
28
+ /**
29
+ * The lock/handle directory: `$VIBER_LOCK_DIR` when set and non-blank, else the
30
+ * historical default — `%APPDATA%/viber` on Windows, `~/.config/viber` elsewhere.
31
+ *
32
+ * The default is byte-for-byte what the three copies computed before this file
33
+ * existed: a lock path that shifts re-opens the #311/#293 duplicate window, and a
34
+ * handle path that shifts makes agents re-mint their conversations (#269).
35
+ *
36
+ * A blank value is treated as ABSENT, not as "the current directory" — a launcher
37
+ * exporting an empty variable must not silently scatter locks into the cwd.
38
+ */
39
+ export function resolveLockDir(
40
+ env: NodeJS.ProcessEnv = process.env,
41
+ log: (msg: string) => void = (m) => process.stderr.write(m),
42
+ ): string {
43
+ const override = env[LOCK_DIR_ENV];
44
+ if (override !== undefined && override.trim() !== "") {
45
+ // Announce ONCE per process. A stray variable in an environment would
46
+ // otherwise split the locks in silence and re-open #311 with no trace in the
47
+ // logs — the failure mode is invisible, so the log line is the safety net.
48
+ if (!announced) {
49
+ announced = true;
50
+ log(`[viber-lock] ${LOCK_DIR_ENV} set — locks and handles under ${override.trim()}\n`);
51
+ }
52
+ return override.trim();
53
+ }
54
+ return env.APPDATA
55
+ ? join(env.APPDATA, "viber")
56
+ : join(env.HOME ?? "/tmp", ".config", "viber");
57
+ }
58
+
59
+ /** Test seam: forget that the override was announced. */
60
+ export function resetLockDirAnnounce(): void {
61
+ announced = false;
62
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * lock_timing.ts — the two numbers that decide when a lock is considered abandoned
3
+ * (#489 step-03).
4
+ *
5
+ * They live in their own named module, and they are passed EXPLICITLY to
6
+ * `proper-lockfile`, because the library derives `update` from `stale` when you omit
7
+ * it. The regime that ships must be the one we chose and wrote down — a review
8
+ * requirement, and the same reasoning that made us justify the TTL of the rejected
9
+ * home-grown protocol.
10
+ */
11
+
12
+ /**
13
+ * How long a lock may go un-refreshed before another process may take it over.
14
+ *
15
+ * 5 minutes. The trade-off, stated plainly: a holder frozen longer than this loses its
16
+ * lock, which re-opens the #311/#293 duplicate window for that process. The window is
17
+ * bounded and self-healing, and it replaces a failure that was PERMANENT — the #489
18
+ * bug, where a recycled PID made a dead holder look alive forever. Bounded beats
19
+ * infinite.
20
+ *
21
+ * Not shorter: a machine under load (three agent teams + Whisper on one GPU box, which
22
+ * is this project's normal state) can stall a process for tens of seconds.
23
+ */
24
+ export const LOCK_STALE_MS = 5 * 60 * 1000;
25
+
26
+ /**
27
+ * How often the holder refreshes its lock's mtime.
28
+ *
29
+ * 60 s — one twelfth of `LOCK_STALE_MS`, so five consecutive missed refreshes are
30
+ * needed before a live holder can be dispossessed. Explicit rather than inherited:
31
+ * `proper-lockfile` would default it to `stale / 2`, i.e. 150 s here, leaving only two
32
+ * refreshes of margin.
33
+ */
34
+ export const LOCK_UPDATE_MS = 60 * 1000;
35
+
36
+ /** Both values in the shape `proper-lockfile` expects. */
37
+ export function lockTimingOptions(): { stale: number; update: number } {
38
+ return { stale: LOCK_STALE_MS, update: LOCK_UPDATE_MS };
39
+ }
package/lib/lockfile.ts CHANGED
@@ -43,11 +43,14 @@ export function lockFilePath(
43
43
  sessionId: string,
44
44
  lockDir: string,
45
45
  ): string {
46
- // #480: this repo has NO typecheck over viber-channel (no tsconfig), so a
47
- // caller omitting the field in an object literal ships silently and we hash
48
- // the string "undefined" — every agent then converges on the SAME lock and
49
- // handle, cross-talking between projects. Cheap runtime guard instead of
50
- // trusting a compiler that never runs.
46
+ // #480 wrote this guard because "this repo has NO typecheck over viber-channel".
47
+ // #489 added one (tsconfig.json + `bun run typecheck`, enforced in CI), so that
48
+ // justification is gone — but the guard STAYS, for reasons the compiler cannot
49
+ // cover: a JS caller, a value crossing a JSON/env boundary, or any `unknown`
50
+ // narrowed wrongly still reaches here at runtime. If a field is omitted we hash
51
+ // the string "undefined", and every agent converges on the SAME lock and handle,
52
+ // cross-talking between projects. That failure is silent and expensive; the check
53
+ // is one comparison.
51
54
  assertHashKey(baseUrl, "lockFilePath(baseUrl)", { allowEmpty: false });
52
55
  assertHashKey(fingerprint, "lockFilePath(fingerprint)");
53
56
  assertHashKey(sessionId, "lockFilePath(sessionId)");
@@ -0,0 +1,156 @@
1
+ /**
2
+ * process_start.ts — when did a PID's process actually start? (#489 step-03, review)
3
+ *
4
+ * This is what turns "is this a recycled PID?" from a guess into a proof.
5
+ *
6
+ * The first attempt classified a legacy lock holder by process NAME (`bun`/`node`).
7
+ * Review killed it with a measurement: 110 live bun/node processes on the dev machine,
8
+ * so a recycled PID landing on ANY other bun — an agent, the vctl daemon, a bridge,
9
+ * vite — was classified as a live legacy holder and the lock stayed immortal. That is
10
+ * the #489 bug surviving in its most likely case.
11
+ *
12
+ * The decisive signal costs nothing extra, because the same CIM query already returns
13
+ * it:
14
+ *
15
+ * - a REAL legacy holder existed BEFORE it wrote its lock file
16
+ * → its start time is EARLIER than the file's mtime;
17
+ * - a RECYCLED pid started AFTER the file was written — that is what recycling means
18
+ * → its start time is LATER than the file's mtime.
19
+ *
20
+ * Implemented on both platform families so the guarantee is universal: on Windows the
21
+ * CIM `CreationDate`, on Linux `/proc/<pid>/stat` field 22 against `/proc/stat` btime.
22
+ * Returning `undefined` anywhere keeps the caller's conservative answer, but it is now
23
+ * the exception rather than the rule off Windows.
24
+ */
25
+ import { execSync } from "node:child_process";
26
+ import { readFileSync } from "node:fs";
27
+
28
+ /**
29
+ * Filesystem mtimes and OS start-times do not share a clock granularity, and a real
30
+ * holder writes its lock file milliseconds after starting. Allow a few seconds before
31
+ * calling a process "younger than the file".
32
+ */
33
+ export const START_TIME_TOLERANCE_MS = 5_000;
34
+
35
+ /** Parse a WMI/CIM datetime (`20260730031245.123456+000`) into epoch ms. */
36
+ export function parseCimDate(raw: string): number | undefined {
37
+ const m = raw.trim().match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-]\d+)?$/);
38
+ if (!m) {
39
+ // Some locales/PowerShell versions hand back a printable date instead.
40
+ const parsed = Date.parse(raw.trim());
41
+ return Number.isFinite(parsed) ? parsed : undefined;
42
+ }
43
+ const [, y, mo, d, h, mi, s, us, tz] = m;
44
+ const offsetMinutes = tz === undefined ? 0 : Number.parseInt(tz, 10);
45
+ const utc = Date.UTC(
46
+ Number(y), Number(mo) - 1, Number(d),
47
+ Number(h), Number(mi), Number(s),
48
+ Math.floor(Number(us) / 1000),
49
+ );
50
+ return utc - offsetMinutes * 60_000;
51
+ }
52
+
53
+ /**
54
+ * Windows: CreationDate via a filtered CIM query (the probe #298 P2b already uses).
55
+ *
56
+ * ⚠ We ask PowerShell for the EPOCH, not for a date, and that is deliberate: on this
57
+ * machine CIM renders `CreationDate` in the system LOCALE — measured, it printed
58
+ * `15 juillet 2026 03:16:03`. Parsing that text is a locale trap that would return
59
+ * `undefined` on some machines and a wrong instant on others, making the lock's verdict
60
+ * depend on the OS language. Letting PowerShell convert removes the ambiguity.
61
+ */
62
+ /**
63
+ * Absolute path to PowerShell when the OS root is known.
64
+ *
65
+ * Not paranoia: running the whole suite, this probe started failing in ~5 ms — far too
66
+ * fast for a spawn — with `powershell` unresolvable. Resolving from `SystemRoot` removes
67
+ * the dependency on `PATH` entirely; the repo fights the same class of problem in
68
+ * `codex_shell_path.ts`. `SystemRoot` is read ONCE at module load, before anything can
69
+ * rewrite it. (No `env:` is passed to `execSync` — the absolute path is what makes the
70
+ * PATH irrelevant, and claiming more than that would be inaccurate.)
71
+ */
72
+ const OS_ROOT_AT_STARTUP = process.env.SystemRoot ?? process.env.windir;
73
+
74
+ function powershellCommand(): string {
75
+ return OS_ROOT_AT_STARTUP === undefined
76
+ ? "powershell"
77
+ : `"${OS_ROOT_AT_STARTUP}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"`;
78
+ }
79
+
80
+ function startTimeWin32(pid: number): number | undefined {
81
+ try {
82
+ const out = execSync(
83
+ `${powershellCommand()} -NoProfile -Command "$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; ` +
84
+ `if ($p) { [int64]($p.CreationDate.ToUniversalTime() - [datetime]'1970-01-01').TotalMilliseconds }"`,
85
+ { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] },
86
+ );
87
+ const line = out.split(/\r?\n/).find((l) => l.trim() !== "");
88
+ if (line === undefined) return undefined;
89
+ const ms = Number.parseInt(line.trim(), 10);
90
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** Linux: field 22 of /proc/<pid>/stat (ticks since boot) + /proc/stat btime. */
97
+ function startTimeLinux(pid: number): number | undefined {
98
+ try {
99
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
100
+ // The comm field can contain spaces and parentheses — split after the last ')'.
101
+ const after = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
102
+ // After comm+state, field 22 (starttime) is index 19 of the remainder.
103
+ const ticks = Number.parseInt(after[19] ?? "", 10);
104
+ if (!Number.isFinite(ticks)) return undefined;
105
+ const btimeLine = readFileSync("/proc/stat", "utf-8")
106
+ .split("\n")
107
+ .find((l) => l.startsWith("btime "));
108
+ if (btimeLine === undefined) return undefined;
109
+ const btime = Number.parseInt(btimeLine.slice(6).trim(), 10);
110
+ if (!Number.isFinite(btime)) return undefined;
111
+ // USER_HZ is 100 on every Linux this project runs on (incl. the CI runners).
112
+ return btime * 1000 + (ticks / 100) * 1000;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /** Epoch ms at which `pid`'s process started, or `undefined` if it cannot be known. */
119
+ export function processStartTime(pid: number, now: number = Date.now()): number | undefined {
120
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
121
+ const raw =
122
+ process.platform === "win32"
123
+ ? startTimeWin32(pid)
124
+ : process.platform === "linux"
125
+ ? startTimeLinux(pid)
126
+ : undefined;
127
+ if (raw === undefined) return undefined;
128
+ // A start time in the FUTURE is absurd, and it is the dangerous direction: the Linux
129
+ // path assumes USER_HZ = 100, and if a kernel used 1000 the computed instant would
130
+ // land ahead of now — `classifyLegacyHolder` would answer "recycled" and we would
131
+ // DISPOSSESS LIVE HOLDERS. That failure mode is fail-OPEN, so refuse the value and
132
+ // fall back to "unknown", which is fail-closed like every other unknown here.
133
+ if (raw > now + START_TIME_TOLERANCE_MS) return undefined;
134
+ return raw;
135
+ }
136
+
137
+ export type HolderVerdict = "recycled" | "legacy-holder" | "unknown";
138
+
139
+ /**
140
+ * Decide what a live PID in a legacy lock file actually is. PURE — the two timestamps
141
+ * are supplied, so every branch is unit-testable without a real process.
142
+ */
143
+ export function classifyLegacyHolder(input: {
144
+ /** Process start time, or undefined when unknowable. */
145
+ startedAt: number | undefined;
146
+ /** mtime of the lock file that names this pid. */
147
+ fileMtimeMs: number;
148
+ toleranceMs?: number;
149
+ }): HolderVerdict {
150
+ const { startedAt, fileMtimeMs } = input;
151
+ const tolerance = input.toleranceMs ?? START_TIME_TOLERANCE_MS;
152
+ if (startedAt === undefined) return "unknown";
153
+ // Started measurably AFTER the file was written → it cannot be the writer.
154
+ if (startedAt > fileMtimeMs + tolerance) return "recycled";
155
+ return "legacy-holder";
156
+ }
@@ -175,7 +175,13 @@ export function parseSpawnResult(
175
175
  */
176
176
  export function messageForReason(
177
177
  reason: SpawnReason,
178
- detail?: string,
178
+ // #489 step-04: `detail` was declared OPTIONAL before the REQUIRED `roster`
179
+ // (TS1016), which no compiler ever saw here. Latent but armed: any caller
180
+ // omitting `detail` would have shifted `roster` into it. Today's callers all
181
+ // pass three arguments (lib/runner_exec.ts:517 + the tests), so nothing was
182
+ // broken — the trap was set for the next caller. Made required, which matches
183
+ // every existing call site.
184
+ detail: string | undefined,
179
185
  roster: readonly string[],
180
186
  logRef?: string,
181
187
  ): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viber-channel",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
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": {
@@ -38,9 +38,16 @@
38
38
  "scripts": {
39
39
  "start": "bun run viber-channel.ts",
40
40
  "start:codex-bridge": "bun run viber-codex-bridge.ts",
41
- "test": "bun test"
41
+ "test": "bun test",
42
+ "typecheck": "tsc --noEmit"
42
43
  },
43
44
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.0.0"
45
+ "@modelcontextprotocol/sdk": "^1.0.0",
46
+ "proper-lockfile": "^4.1.2"
47
+ },
48
+ "devDependencies": {
49
+ "@types/bun": "1.3.14",
50
+ "@types/proper-lockfile": "^4.1.4",
51
+ "typescript": "5.9.3"
45
52
  }
46
53
  }
package/viber-channel.ts CHANGED
@@ -6,7 +6,10 @@
6
6
  * from the npm registry to decide whether a machine's global install has drifted (#503).
7
7
  * It is therefore a PUBLISHED contract, not just a number: the `channel-v*` tag, the
8
8
  * package version and vibe-master's `EXPECTED_VIBER_CHANNEL_VERSION` floor move together
9
- * (#423), and any version published here reaches clients without a vibe-master release.
9
+ * (#423). From vibe-master 0.7.7 on, that contract lets a version published here reach
10
+ * clients WITHOUT a vibe-master release: the floor only sets the minimum, the registry
11
+ * sets the target. Measured evidence belongs in the issue/plan that measures it — not in
12
+ * the package whose publication is what makes the measurement possible.
10
13
  *
11
14
  * Reads .viber/auth.json for authentication, then connects to
12
15
  * /api/conversations/<id>/events SSE stream and pushes transcripts and
@@ -68,6 +71,8 @@ import { CLAUDE_TOOL_DEFS } from "./lib/claude_tool_defs.ts";
68
71
  import { warnIfStale } from "./lib/version_check.ts";
69
72
  import { capabilitiesText } from "./lib/capabilities.ts";
70
73
  import { BOW_OUT_MESSAGE, shouldBowOutToPlugin } from "./lib/cli_bow_out.ts";
74
+ import { resolveLockDir } from "./lib/lock_dir.ts";
75
+ import { acquireLockFile, createLockSlot } from "./lib/bridge_lock.ts";
71
76
 
72
77
  // ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
73
78
  //
@@ -182,10 +187,10 @@ void warnIfStale();
182
187
  // LOCK_FINGERPRINT is computed from the current folder (process.cwd()) rather
183
188
  // than read from auth.json so the lock paths are available before loadAuth();
184
189
  // it is verified equal to auth.client_fingerprint below — a mismatch exits.
185
- const LOCK_DIR =
186
- process.env.APPDATA
187
- ? join(process.env.APPDATA, "viber")
188
- : join(process.env.HOME ?? "/tmp", ".config", "viber");
190
+ // #489 step-01: LAZY, not a module-level const — a const froze the path at import,
191
+ // so nothing could redirect it and tests wrote into the real %APPDATA%/viber. Default
192
+ // is unchanged, byte for byte. See lib/lock_dir.ts.
193
+ const LOCK_DIR = (): string => resolveLockDir();
189
194
  // #480: the lock hash is keyed on the LOGICAL backend identity, never on the
190
195
  // (possibly redirected, possibly normalized) API host — see lib/base_urls.ts.
191
196
  const LOCK_BASE_URL = identityBaseUrl();
@@ -202,7 +207,10 @@ const LOCK_BASE_URL = identityBaseUrl();
202
207
  // keeps sessionId="" so accidental duplicates stay blocked. See lockSessionId.
203
208
  const LOCK_SESSION_ID = lockSessionId(process.env);
204
209
  const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
205
- const LOCK_FILE = lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR);
210
+ // #489 step-01: lazy, like LOCK_DIR — computing it at import froze the directory
211
+ // before anything could redirect it.
212
+ const LOCK_FILE = (): string =>
213
+ lockFilePath(LOCK_BASE_URL, LOCK_FINGERPRINT, LOCK_SESSION_ID, LOCK_DIR());
206
214
  // SESSION_FILE (the conversation handle path) is computed AFTER the instance is
207
215
  // acquired (#269) — it is namespaced by the server-issued instance, not the
208
216
  // local fingerprint/sessionId. Declared as a `let` near the mint below.
@@ -256,67 +264,60 @@ const STABILITY_DELAY_MS: number = (() => {
256
264
  return parsed;
257
265
  })();
258
266
 
259
- function isProcessAlive(pid: number): boolean {
260
- try {
261
- process.kill(pid, 0);
262
- return true;
263
- } catch (err: any) {
264
- if (err?.code === "EPERM") return true; // Process exists, no permission to signal
265
- return false; // ESRCH or anything Bun-specific — treat as dead
266
- }
267
- }
268
267
 
269
268
  /** Try to acquire lock. Returns null on success, or the blocking PID on conflict. */
270
- function acquireLock(): number | null {
271
- mkdirSync(LOCK_DIR, { recursive: true });
272
- const ourPid = String(process.pid);
273
-
274
- for (let attempt = 0; attempt < 2; attempt++) {
275
- try {
276
- writeFileSync(LOCK_FILE, ourPid + "\n", { flag: "wx" });
277
- return null; // Lock acquired
278
- } catch (err: any) {
279
- if (err?.code !== "EEXIST") throw err;
280
-
281
- // Lock file exists — check if the holder is still alive
282
- let existingPid: number;
283
- try {
284
- existingPid = parseInt(readFileSync(LOCK_FILE, "utf-8").trim(), 10);
285
- } catch {
286
- // Corrupt or unreadable — treat as stale
287
- releaseLock();
288
- continue;
289
- }
290
-
291
- if (isNaN(existingPid) || !isProcessAlive(existingPid)) {
292
- // Stale lock — remove and retry
293
- releaseLock();
294
- continue;
295
- }
296
-
297
- // Another live channel holds this folder's lock. This blocks accidental
298
- // duplicate spawns (#166/#259) — but it also blocks a deliberate 2nd agent
299
- // in the same folder (#293). Make the message actionable: a labelled launch
300
- // gets its own identity AND lock (see LOCK_SESSION_ID above).
301
- process.stderr.write(
302
- `[viber-channel] Another Viber agent holds this folder (PID ${existingPid}), exiting. ` +
303
- `To run a SECOND agent here, set VIBER_CHANNEL_LABEL=<name> in this MCP server's env ` +
304
- `(it gets its own identity + lock) and relaunch.\n`
305
- );
306
- return existingPid;
307
- }
269
+ /**
270
+ * #489 step-02: thin adapter over `lib/bridge_lock.ts`. The loop, the payload and the
271
+ * reclaim decision now live there, shared with the bridges — this site keeps only its
272
+ * OWN error protocol: return the blocking pid so the message below can name it.
273
+ *
274
+ * The acquired path is CAPTURED in `lockRelease`, and `releaseLock()` calls that
275
+ * closure. It used to recompute `LOCK_FILE()` on every exit path (8 of them: the
276
+ * `exit`/SIGINT/SIGTERM handlers plus 5 inline returns), which re-read `process.env`
277
+ * — so once step-01 made the directory redirectable, a variable changing between
278
+ * acquisition and exit would have unlinked a DIFFERENT path: the real lock leaking and
279
+ * a foreign lock deleted. Raised by both reviewers; the capture is the fix.
280
+ */
281
+ const lockSlot = createLockSlot();
282
+
283
+ function acquireLock(lockFile: string = LOCK_FILE()): number | null {
284
+ const result = acquireLockFile({
285
+ lockFile,
286
+ // #489 step-03 (review A): losing the lock mid-run is a FENCING signal — another
287
+ // process may already hold this folder's identity, so continuing would be the
288
+ // unbounded duplicate the design promises not to create. Withdraw on exactly the
289
+ // path onParentGone uses below; exit(1) because this is not a clean shutdown.
290
+ onLost: (reason) => {
291
+ process.stderr.write(`[viber-channel] ${reason} — withdrawing to avoid a duplicate
292
+ `);
293
+ scheduler?.cancel();
294
+ releaseLock();
295
+ process.exit(1);
296
+ },
297
+ });
298
+ if (result.ok) {
299
+ lockSlot.adopt(result.release);
300
+ return null;
308
301
  }
309
-
310
- process.stderr.write("[viber-channel] Failed to acquire lock after retries, exiting.\n");
311
- return -1; // Failed to acquire
302
+ if (result.blockedBy === -1) {
303
+ process.stderr.write("[viber-channel] Failed to acquire lock after retries, exiting.\n");
304
+ return -1;
305
+ }
306
+ // Another live channel holds this folder's lock. This blocks accidental duplicate
307
+ // spawns (#166/#259) — but it also blocks a deliberate 2nd agent in the same folder
308
+ // (#293). Keep the message actionable: a labelled launch gets its own identity AND
309
+ // lock (see LOCK_SESSION_ID above).
310
+ process.stderr.write(
311
+ `[viber-channel] Another Viber agent holds this folder (PID ${result.blockedBy}), exiting. ` +
312
+ `To run a SECOND agent here, set VIBER_CHANNEL_LABEL=<name> in this MCP server's env ` +
313
+ `(it gets its own identity + lock) and relaunch.\n`
314
+ );
315
+ return result.blockedBy;
312
316
  }
313
317
 
318
+ /** Release the lock we actually took. Never recomputes the path — see above. */
314
319
  function releaseLock(): void {
315
- try {
316
- unlinkSync(LOCK_FILE);
317
- } catch {
318
- // Best-effort — file may already be gone
319
- }
320
+ lockSlot.releaseIfHeld();
320
321
  }
321
322
 
322
323
  // Token-refresh scheduler — null until the initial mint succeeds. Declared at
@@ -398,6 +399,8 @@ import {
398
399
  listAgents as libListAgents,
399
400
  messageAgent as libMessageAgent,
400
401
  sendMessage as libSendMessage,
402
+ toMcpToolResult,
403
+ type AgentToolResult,
401
404
  type AgentToolsContext,
402
405
  } from "./lib/agent_tools.ts";
403
406
  import { cfAccessHeaders } from "./lib/cfAccess.ts";
@@ -463,7 +466,12 @@ const channelToolsCtx: AgentToolsContext = {
463
466
  // NOTE: CONVERSATION_TOKEN and CONVERSATION_ID are populated after
464
467
  // mintConversation() runs (below). The handler closure captures the variables
465
468
  // by reference — by the time Claude Code calls the tool, they are populated.
466
- mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
469
+ // #489 step-04: the dispatch keeps returning our CLOSED `AgentToolResult`, so every
470
+ // `return` below is still excess-property checked. Only the registration crosses to
471
+ // the SDK's open result type, through the named adapter — see toMcpToolResult.
472
+ async function handleCallTool(
473
+ request: { params: { name: string; arguments?: Record<string, unknown> } },
474
+ ): Promise<AgentToolResult> {
467
475
  const args = (request.params.arguments ?? {}) as Record<string, unknown>;
468
476
 
469
477
  if (request.params.name === "list_agents") {
@@ -507,7 +515,9 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
507
515
  content: [{ type: "text" as const, text: `Unexpected error: ${String(err)}` }],
508
516
  };
509
517
  }
510
- });
518
+ }
519
+
520
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => toMcpToolResult(await handleCallTool(request)));
511
521
 
512
522
  // Prevent duplicate instances (#166); fingerprint namespacing for different
513
523
  // projects (#267); session-id namespacing for concurrent launches of the same
@@ -648,7 +658,7 @@ try {
648
658
  channelLabel: process.env.VIBER_CHANNEL_LABEL,
649
659
  instanceToken: process.env.VIBER_INSTANCE_TOKEN,
650
660
  instanceKey,
651
- dir: LOCK_DIR,
661
+ dir: LOCK_DIR(),
652
662
  });
653
663
  SESSION_FILE = computeSessionFile(acquired.instance_key);
654
664
 
@@ -23,7 +23,7 @@ import { ApiBaseUrlResolver, identityBaseUrl } from "./lib/base_urls.ts";
23
23
  import { isTrustedViberOrigin } from "./lib/urls.ts";
24
24
  import { sessionFilePath, writeHandle } from "./lib/channel_session.ts";
25
25
  import { clientFingerprint } from "./lib/fingerprint.ts";
26
- import { acquireInstance, instanceKindFromEnv, maybeAttachTeam, registerInstance } from "./lib/instance.ts";
26
+ import { acquireInstance, instanceKindFromEnv, maybeAttachTeam, registerInstance, type AcquiredInstance } from "./lib/instance.ts";
27
27
  import {
28
28
  acquireBridgeLock as coreAcquireBridgeLock,
29
29
  agentIdentityFromEnv,
@@ -70,6 +70,7 @@ import { startInstanceHeartbeat } from "./lib/heartbeat.ts";
70
70
  import { startBridgeToolHost, type BridgeToolHost } from "./lib/bridge_tool_host.ts";
71
71
  import type { AgentToolsContext } from "./lib/agent_tools.ts";
72
72
  import type { OpenDmResult } from "./lib/peers.ts";
73
+ import { resolveLockDir } from "./lib/lock_dir.ts";
73
74
 
74
75
  // #480: transport host vs logical identity. IDENTITY_URL is what the session and
75
76
  // bridge-lock hashes are keyed on (this file builds several of them, below) —
@@ -77,10 +78,10 @@ import type { OpenDmResult } from "./lib/peers.ts";
77
78
  const API = new ApiBaseUrlResolver(isTrustedViberOrigin);
78
79
  const apiBase = (): string => API.current();
79
80
  const IDENTITY_URL = identityBaseUrl();
80
- const LOCK_DIR =
81
- process.env.APPDATA
82
- ? join(process.env.APPDATA, "viber")
83
- : join(process.env.HOME ?? "/tmp", ".config", "viber");
81
+ // #489 step-01: LAZY, not a module-level const — a const froze the path at import,
82
+ // so nothing could redirect it and tests wrote into the real %APPDATA%/viber. Default
83
+ // is unchanged, byte for byte. See lib/lock_dir.ts.
84
+ const LOCK_DIR = (): string => resolveLockDir();
84
85
 
85
86
  interface BridgeOptions {
86
87
  conversationId: string;
@@ -236,7 +237,26 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
236
237
  // pass IDENTITY_URL. Named accordingly so a future edit can't quietly hand it
237
238
  // apiBase() and move every lock path.
238
239
  function acquireBridgeLock(identityBaseUrl: string, fingerprint: string, sessionId: string): BridgeLock {
239
- return coreAcquireBridgeLock({ identityBaseUrl, fingerprint, sessionId, lockDir: LOCK_DIR, logPrefix: LOG_PREFIX });
240
+ return coreAcquireBridgeLock({
241
+ identityBaseUrl,
242
+ fingerprint,
243
+ sessionId,
244
+ lockDir: LOCK_DIR(),
245
+ logPrefix: LOG_PREFIX,
246
+ // #489 step-03 (review A): losing the lock mid-run is a FENCING signal. A
247
+ // dispossessed bridge has no authority over its identity any more, and another
248
+ // process may already hold it — continuing would be the unbounded duplicate this
249
+ // design promises not to create. Withdraw exactly as the parent watchdog does.
250
+ // There is no process-wide shutdown controller on this path (the AbortControllers
251
+ // are per-conversation), so withdraw the same way the channel's watchdog does:
252
+ // announce, drop the lock, exit non-zero. The supervisor restarts a bridge that
253
+ // exits, which is the self-healing half of "bounded duplicate".
254
+ onLost: (reason) => {
255
+ process.stderr.write(`${LOG_PREFIX} ${reason} — withdrawing to avoid a duplicate
256
+ `);
257
+ process.exit(1);
258
+ },
259
+ });
240
260
  }
241
261
 
242
262
  // shouldHandleMessage, planAwaitInviteFirstPass, senderLabel moved to
@@ -918,7 +938,14 @@ async function acquireBridgeInstance(
918
938
  fingerprint: string,
919
939
  label: string,
920
940
  options: BridgeOptions,
921
- ): Promise<{ instance_token: string; instance_key: string }> {
941
+ // #489 step-04: this used to be typed `{ instance_token; instance_key }`, which
942
+ // DROPPED `api_base_url` — and the fresh-register path below dropped it at
943
+ // runtime too. Callers then did `API.offer(acquired.api_base_url)` with
944
+ // undefined, and `offer()` accepts undefined silently (lib/base_urls.ts), so the
945
+ // #480 announced-host adoption never happened at register time on this path. It
946
+ // self-healed at the next mint/join offer, which is why nobody saw it. Found by
947
+ // the typecheck this step introduces.
948
+ ): Promise<AcquiredInstance> {
922
949
  if (process.env.VIBER_INSTANCE_TOKEN !== undefined && process.env.VIBER_INSTANCE_TOKEN.trim() !== "") {
923
950
  process.stderr.write("[viber-codex-bridge] instance: using VIBER_INSTANCE_TOKEN from env\n");
924
951
  return acquireInstance(baseUrl, auth, fingerprint);
@@ -937,7 +964,14 @@ async function acquireBridgeInstance(
937
964
  // #398 (R-CODEX-FRESH): self-attach here too so codex is teamed structurally,
938
965
  // not only via the VIBER_INSTANCE_TOKEN branch above (parity with claude).
939
966
  await maybeAttachTeam(baseUrl, auth.project_id, auth.project_token, fingerprint, reg.instance_id);
940
- return { instance_token: reg.instance_token, instance_key: reg.instance_id };
967
+ // api_base_url: the host the server just announced. Dropping it here is what
968
+ // silently killed the #480 adoption on this path (see the return type above).
969
+ return {
970
+ instance_token: reg.instance_token,
971
+ instance_key: reg.instance_id,
972
+ instance_id: reg.instance_id,
973
+ api_base_url: reg.api_base_url,
974
+ };
941
975
  }
942
976
 
943
977
  function loadBridgeAuthAndFingerprint(): { auth: AuthJson; fingerprint: string } {
@@ -964,7 +998,7 @@ async function acquireTargetConversation(
964
998
  auth: AuthJson;
965
999
  bridgeLock: BridgeLock;
966
1000
  }> {
967
- mkdirSync(LOCK_DIR, { recursive: true });
1001
+ mkdirSync(LOCK_DIR(), { recursive: true });
968
1002
 
969
1003
  const label = process.env.VIBER_CODEX_BRIDGE_LABEL ?? `Codex bridge • ${defaultLabel(process.cwd())}`;
970
1004
  const acquired = await acquireBridgeInstance(apiBase(), auth, fingerprint, label, options);
@@ -973,7 +1007,7 @@ async function acquireTargetConversation(
973
1007
  API.offer(acquired.api_base_url);
974
1008
  let instanceToken = acquired.instance_token;
975
1009
  let instanceKey = acquired.instance_key;
976
- let sessionPath = sessionFilePath(IDENTITY_URL, `${instanceKey}:codex:${targetConversationId}`, LOCK_DIR);
1010
+ let sessionPath = sessionFilePath(IDENTITY_URL, `${instanceKey}:codex:${targetConversationId}`, LOCK_DIR());
977
1011
  // #280 step-12: take the per-agent lock BEFORE reattach. reattach rotates the
978
1012
  // per-membership token, so a losing duplicate launch of the SAME identity must be
979
1013
  // stopped before it can rotate the winner's token (Codex review P2-B).
@@ -1015,7 +1049,7 @@ async function acquireTargetConversation(
1015
1049
  const reLock = acquireBridgeLock(IDENTITY_URL, fingerprint, `codex-agent:${instanceKey}`);
1016
1050
  bridgeLock.release();
1017
1051
  bridgeLock = reLock;
1018
- sessionPath = sessionFilePath(IDENTITY_URL, `${instanceKey}:codex:${targetConversationId}`, LOCK_DIR);
1052
+ sessionPath = sessionFilePath(IDENTITY_URL, `${instanceKey}:codex:${targetConversationId}`, LOCK_DIR());
1019
1053
  const minted = await reattachConversation(
1020
1054
  apiBase(),
1021
1055
  auth.project_id,
@@ -1107,7 +1141,7 @@ async function acquireBridgeIdentity(
1107
1141
  auth: AuthJson,
1108
1142
  fingerprint: string,
1109
1143
  ): Promise<{ instanceKey: string; instanceToken: string; bridgeLock: BridgeLock }> {
1110
- mkdirSync(LOCK_DIR, { recursive: true });
1144
+ mkdirSync(LOCK_DIR(), { recursive: true });
1111
1145
  const label = process.env.VIBER_CODEX_BRIDGE_LABEL ?? `Codex bridge • ${defaultLabel(process.cwd())}`;
1112
1146
  const acquired = await acquireBridgeInstance(apiBase(), auth, fingerprint, label, options);
1113
1147
  // #480: this exported helper starts a control stream + heartbeat right after,
@@ -1160,7 +1194,7 @@ export async function acquireConversationViaInvite(
1160
1194
  {
1161
1195
  log: (m) => process.stderr.write(m),
1162
1196
  onJoin: (minted) => {
1163
- const sessionPath = sessionFilePath(IDENTITY_URL, `${instanceKey}:codex:${minted.conversation_id}`, LOCK_DIR);
1197
+ const sessionPath = sessionFilePath(IDENTITY_URL, `${instanceKey}:codex:${minted.conversation_id}`, LOCK_DIR());
1164
1198
  API.offer(minted.api_base_url);
1165
1199
  writeHandle(sessionPath, minted.conversation_id);
1166
1200
  },
@@ -1339,7 +1373,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1339
1373
  await runAwaitInviteBridge({
1340
1374
  api: API,
1341
1375
  identityBaseUrl: IDENTITY_URL,
1342
- lockDir: LOCK_DIR,
1376
+ lockDir: LOCK_DIR(),
1343
1377
  logPrefix: LOG_PREFIX,
1344
1378
  sessionTag: "codex",
1345
1379
  label: process.env.VIBER_CODEX_BRIDGE_LABEL ?? `Codex bridge • ${defaultLabel(process.cwd())}`,
@@ -1395,7 +1429,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1395
1429
  parentSignal: shutdown.signal,
1396
1430
  api: API,
1397
1431
  identityBaseUrl: IDENTITY_URL,
1398
- lockDir: LOCK_DIR,
1432
+ lockDir: LOCK_DIR(),
1399
1433
  logPrefix: LOG_PREFIX,
1400
1434
  sessionTag: "codex",
1401
1435
  } satisfies Omit<ConversationStreamDeps, "makeRunTurn">;
@@ -30,6 +30,7 @@ import {
30
30
  type RunTurn,
31
31
  runAwaitInviteBridge,
32
32
  } from "./lib/bridge_core.ts";
33
+ import { resolveLockDir } from "./lib/lock_dir.ts";
33
34
  import {
34
35
  DEFAULT_GEMMA_MODEL,
35
36
  type ChatMessage,
@@ -43,10 +44,10 @@ const LOG_PREFIX = "[viber-gemma-bridge]";
43
44
  const API = new ApiBaseUrlResolver(isTrustedViberOrigin);
44
45
  const apiBase = (): string => API.current();
45
46
  const IDENTITY_URL = identityBaseUrl();
46
- const LOCK_DIR =
47
- process.env.APPDATA
48
- ? join(process.env.APPDATA, "viber")
49
- : join(process.env.HOME ?? "/tmp", ".config", "viber");
47
+ // #489 step-01: LAZY, not a module-level const — a const froze the path at import,
48
+ // so nothing could redirect it and tests wrote into the real %APPDATA%/viber. Default
49
+ // is unchanged, byte for byte. See lib/lock_dir.ts.
50
+ const LOCK_DIR = (): string => resolveLockDir();
50
51
 
51
52
  /**
52
53
  * Concise voice-friendly system prompt (ported from viber/standalone_ai.py).
@@ -167,7 +168,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
167
168
  await runAwaitInviteBridge({
168
169
  api: API,
169
170
  identityBaseUrl: IDENTITY_URL,
170
- lockDir: LOCK_DIR,
171
+ lockDir: LOCK_DIR(),
171
172
  logPrefix: LOG_PREFIX,
172
173
  sessionTag: "gemma",
173
174
  label,