viber-channel 0.8.15 → 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.
- package/lib/agent_tools.ts +23 -0
- package/lib/bridge_core.ts +50 -55
- package/lib/bridge_lock.ts +323 -0
- package/lib/bridge_tool_host.ts +2 -1
- package/lib/lock_dir.ts +62 -0
- package/lib/lock_timing.ts +39 -0
- package/lib/lockfile.ts +8 -5
- package/lib/process_start.ts +156 -0
- package/lib/spawn_reason.ts +7 -1
- package/package.json +10 -3
- package/viber-channel.ts +70 -63
- package/viber-codex-bridge.ts +49 -15
- package/viber-gemma-bridge.ts +6 -5
package/lib/agent_tools.ts
CHANGED
|
@@ -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
|
package/lib/bridge_core.ts
CHANGED
|
@@ -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
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/bridge_tool_host.ts
CHANGED
|
@@ -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
|
package/lib/lock_dir.ts
ADDED
|
@@ -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
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
+
}
|
package/lib/spawn_reason.ts
CHANGED
|
@@ -175,7 +175,13 @@ export function parseSpawnResult(
|
|
|
175
175
|
*/
|
|
176
176
|
export function messageForReason(
|
|
177
177
|
reason: SpawnReason,
|
|
178
|
-
detail
|
|
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.
|
|
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
|
@@ -71,6 +71,8 @@ import { CLAUDE_TOOL_DEFS } from "./lib/claude_tool_defs.ts";
|
|
|
71
71
|
import { warnIfStale } from "./lib/version_check.ts";
|
|
72
72
|
import { capabilitiesText } from "./lib/capabilities.ts";
|
|
73
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";
|
|
74
76
|
|
|
75
77
|
// ---- CLI subcommand dispatch (must happen before lock acquire + loadAuth) ----
|
|
76
78
|
//
|
|
@@ -185,10 +187,10 @@ void warnIfStale();
|
|
|
185
187
|
// LOCK_FINGERPRINT is computed from the current folder (process.cwd()) rather
|
|
186
188
|
// than read from auth.json so the lock paths are available before loadAuth();
|
|
187
189
|
// it is verified equal to auth.client_fingerprint below — a mismatch exits.
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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();
|
|
192
194
|
// #480: the lock hash is keyed on the LOGICAL backend identity, never on the
|
|
193
195
|
// (possibly redirected, possibly normalized) API host — see lib/base_urls.ts.
|
|
194
196
|
const LOCK_BASE_URL = identityBaseUrl();
|
|
@@ -205,7 +207,10 @@ const LOCK_BASE_URL = identityBaseUrl();
|
|
|
205
207
|
// keeps sessionId="" so accidental duplicates stay blocked. See lockSessionId.
|
|
206
208
|
const LOCK_SESSION_ID = lockSessionId(process.env);
|
|
207
209
|
const LOCK_FINGERPRINT = clientFingerprint(process.cwd());
|
|
208
|
-
|
|
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());
|
|
209
214
|
// SESSION_FILE (the conversation handle path) is computed AFTER the instance is
|
|
210
215
|
// acquired (#269) — it is namespaced by the server-issued instance, not the
|
|
211
216
|
// local fingerprint/sessionId. Declared as a `let` near the mint below.
|
|
@@ -259,67 +264,60 @@ const STABILITY_DELAY_MS: number = (() => {
|
|
|
259
264
|
return parsed;
|
|
260
265
|
})();
|
|
261
266
|
|
|
262
|
-
function isProcessAlive(pid: number): boolean {
|
|
263
|
-
try {
|
|
264
|
-
process.kill(pid, 0);
|
|
265
|
-
return true;
|
|
266
|
-
} catch (err: any) {
|
|
267
|
-
if (err?.code === "EPERM") return true; // Process exists, no permission to signal
|
|
268
|
-
return false; // ESRCH or anything Bun-specific — treat as dead
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
267
|
|
|
272
268
|
/** Try to acquire lock. Returns null on success, or the blocking PID on conflict. */
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
`[viber-channel] Another Viber agent holds this folder (PID ${existingPid}), exiting. ` +
|
|
306
|
-
`To run a SECOND agent here, set VIBER_CHANNEL_LABEL=<name> in this MCP server's env ` +
|
|
307
|
-
`(it gets its own identity + lock) and relaunch.\n`
|
|
308
|
-
);
|
|
309
|
-
return existingPid;
|
|
310
|
-
}
|
|
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;
|
|
311
301
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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;
|
|
315
316
|
}
|
|
316
317
|
|
|
318
|
+
/** Release the lock we actually took. Never recomputes the path — see above. */
|
|
317
319
|
function releaseLock(): void {
|
|
318
|
-
|
|
319
|
-
unlinkSync(LOCK_FILE);
|
|
320
|
-
} catch {
|
|
321
|
-
// Best-effort — file may already be gone
|
|
322
|
-
}
|
|
320
|
+
lockSlot.releaseIfHeld();
|
|
323
321
|
}
|
|
324
322
|
|
|
325
323
|
// Token-refresh scheduler — null until the initial mint succeeds. Declared at
|
|
@@ -401,6 +399,8 @@ import {
|
|
|
401
399
|
listAgents as libListAgents,
|
|
402
400
|
messageAgent as libMessageAgent,
|
|
403
401
|
sendMessage as libSendMessage,
|
|
402
|
+
toMcpToolResult,
|
|
403
|
+
type AgentToolResult,
|
|
404
404
|
type AgentToolsContext,
|
|
405
405
|
} from "./lib/agent_tools.ts";
|
|
406
406
|
import { cfAccessHeaders } from "./lib/cfAccess.ts";
|
|
@@ -466,7 +466,12 @@ const channelToolsCtx: AgentToolsContext = {
|
|
|
466
466
|
// NOTE: CONVERSATION_TOKEN and CONVERSATION_ID are populated after
|
|
467
467
|
// mintConversation() runs (below). The handler closure captures the variables
|
|
468
468
|
// by reference — by the time Claude Code calls the tool, they are populated.
|
|
469
|
-
|
|
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> {
|
|
470
475
|
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
|
471
476
|
|
|
472
477
|
if (request.params.name === "list_agents") {
|
|
@@ -510,7 +515,9 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
510
515
|
content: [{ type: "text" as const, text: `Unexpected error: ${String(err)}` }],
|
|
511
516
|
};
|
|
512
517
|
}
|
|
513
|
-
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (request) => toMcpToolResult(await handleCallTool(request)));
|
|
514
521
|
|
|
515
522
|
// Prevent duplicate instances (#166); fingerprint namespacing for different
|
|
516
523
|
// projects (#267); session-id namespacing for concurrent launches of the same
|
|
@@ -651,7 +658,7 @@ try {
|
|
|
651
658
|
channelLabel: process.env.VIBER_CHANNEL_LABEL,
|
|
652
659
|
instanceToken: process.env.VIBER_INSTANCE_TOKEN,
|
|
653
660
|
instanceKey,
|
|
654
|
-
dir: LOCK_DIR,
|
|
661
|
+
dir: LOCK_DIR(),
|
|
655
662
|
});
|
|
656
663
|
SESSION_FILE = computeSessionFile(acquired.instance_key);
|
|
657
664
|
|
package/viber-codex-bridge.ts
CHANGED
|
@@ -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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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">;
|
package/viber-gemma-bridge.ts
CHANGED
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|