cli-wechat-bridge 1.0.5
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/LICENSE.txt +21 -0
- package/README.md +637 -0
- package/bin/_run-entry.mjs +35 -0
- package/bin/wechat-bridge-claude.mjs +5 -0
- package/bin/wechat-bridge-codex.mjs +5 -0
- package/bin/wechat-bridge-opencode.mjs +5 -0
- package/bin/wechat-bridge-shell.mjs +5 -0
- package/bin/wechat-bridge.mjs +5 -0
- package/bin/wechat-check-update.mjs +5 -0
- package/bin/wechat-claude-start.mjs +5 -0
- package/bin/wechat-claude.mjs +5 -0
- package/bin/wechat-codex-start.mjs +5 -0
- package/bin/wechat-codex.mjs +5 -0
- package/bin/wechat-daemon.mjs +5 -0
- package/bin/wechat-opencode-start.mjs +5 -0
- package/bin/wechat-opencode.mjs +5 -0
- package/bin/wechat-setup.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +95 -0
- package/dist/bridge/bridge-adapters.claude.js +829 -0
- package/dist/bridge/bridge-adapters.codex.js +2228 -0
- package/dist/bridge/bridge-adapters.core.js +717 -0
- package/dist/bridge/bridge-adapters.js +26 -0
- package/dist/bridge/bridge-adapters.opencode.js +2129 -0
- package/dist/bridge/bridge-adapters.shared.js +1005 -0
- package/dist/bridge/bridge-adapters.shell.js +363 -0
- package/dist/bridge/bridge-controller.js +48 -0
- package/dist/bridge/bridge-final-reply.js +46 -0
- package/dist/bridge/bridge-process-reaper.js +348 -0
- package/dist/bridge/bridge-state.js +362 -0
- package/dist/bridge/bridge-types.js +1 -0
- package/dist/bridge/bridge-utils.js +1240 -0
- package/dist/bridge/claude-hook.js +82 -0
- package/dist/bridge/claude-hooks.js +267 -0
- package/dist/bridge/wechat-bridge.js +1026 -0
- package/dist/commands/check-update.js +30 -0
- package/dist/companion/codex-panel-link.js +72 -0
- package/dist/companion/codex-panel.js +179 -0
- package/dist/companion/codex-remote-client.js +124 -0
- package/dist/companion/local-companion-link.js +240 -0
- package/dist/companion/local-companion-start.js +420 -0
- package/dist/companion/local-companion.js +424 -0
- package/dist/daemon/daemon-link.js +175 -0
- package/dist/daemon/wechat-daemon.js +1202 -0
- package/dist/media/media-types.js +1 -0
- package/dist/runtime/create-runtime-host.js +12 -0
- package/dist/runtime/legacy-adapter-runtime.js +46 -0
- package/dist/runtime/runtime-types.js +5 -0
- package/dist/utils/version-checker.js +161 -0
- package/dist/wechat/channel-config.js +196 -0
- package/dist/wechat/setup.js +283 -0
- package/dist/wechat/standalone-bot.js +355 -0
- package/dist/wechat/wechat-channel.js +492 -0
- package/dist/wechat/wechat-transport.js +1213 -0
- package/package.json +101 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const PEER_BRIDGE_EXIT_TIMEOUT_MS = 4_000;
|
|
3
|
+
const PEER_BRIDGE_EXIT_POLL_MS = 100;
|
|
4
|
+
function isPidAlive(pid) {
|
|
5
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function sleep(ms) {
|
|
17
|
+
if (ms <= 0) {
|
|
18
|
+
return Promise.resolve();
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
export function isWechatBridgeCommandLine(commandLine) {
|
|
26
|
+
return (/wechat-bridge\.(?:ts|js|mjs)/i.test(commandLine) &&
|
|
27
|
+
/(?:^|\s)--adapter(?:\s|=)/i.test(commandLine));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Detects `opencode serve` command lines spawned by the bridge adapter.
|
|
31
|
+
* Matches patterns like:
|
|
32
|
+
* opencode serve --port 12345 --hostname 127.0.0.1
|
|
33
|
+
* opencode.exe serve --port 12345
|
|
34
|
+
* Also matches wrapper invocations (opencode.cmd / opencode.bat) on Windows.
|
|
35
|
+
*/
|
|
36
|
+
export function isOpencodeServeCommandLine(commandLine) {
|
|
37
|
+
return /\bopencode(?:\.exe|\.cmd|\.bat)?\b.*\bserve\b/i.test(commandLine);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Detects `opencode attach` command lines spawned by the local OpenCode companion.
|
|
41
|
+
* Matches patterns like:
|
|
42
|
+
* opencode attach http://127.0.0.1:12345
|
|
43
|
+
* opencode.exe attach http://127.0.0.1:12345 --session ses_123
|
|
44
|
+
*/
|
|
45
|
+
export function isOpencodeAttachCommandLine(commandLine) {
|
|
46
|
+
return /\bopencode(?:\.exe|\.cmd|\.bat)?\b.*\battach\b/i.test(commandLine);
|
|
47
|
+
}
|
|
48
|
+
function normalizeBridgeProcessRecord(value) {
|
|
49
|
+
if (!isRecord(value)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const pid = typeof value.ProcessId === "number"
|
|
53
|
+
? value.ProcessId
|
|
54
|
+
: typeof value.pid === "number"
|
|
55
|
+
? value.pid
|
|
56
|
+
: Number.NaN;
|
|
57
|
+
const commandLine = typeof value.CommandLine === "string"
|
|
58
|
+
? value.CommandLine
|
|
59
|
+
: typeof value.commandLine === "string"
|
|
60
|
+
? value.commandLine
|
|
61
|
+
: "";
|
|
62
|
+
if (!Number.isInteger(pid) || pid <= 0 || !commandLine) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const record = {
|
|
66
|
+
pid,
|
|
67
|
+
commandLine,
|
|
68
|
+
};
|
|
69
|
+
const parentPid = typeof value.ParentProcessId === "number"
|
|
70
|
+
? value.ParentProcessId
|
|
71
|
+
: typeof value.parentPid === "number"
|
|
72
|
+
? value.parentPid
|
|
73
|
+
: undefined;
|
|
74
|
+
if (typeof parentPid === "number" && Number.isInteger(parentPid) && parentPid > 0) {
|
|
75
|
+
record.parentPid = parentPid;
|
|
76
|
+
}
|
|
77
|
+
const name = typeof value.Name === "string"
|
|
78
|
+
? value.Name
|
|
79
|
+
: typeof value.name === "string"
|
|
80
|
+
? value.name
|
|
81
|
+
: undefined;
|
|
82
|
+
if (name) {
|
|
83
|
+
record.name = name;
|
|
84
|
+
}
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
|
|
88
|
+
const trimmed = stdout.trim();
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(trimmed);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
100
|
+
return values
|
|
101
|
+
.map(normalizeBridgeProcessRecord)
|
|
102
|
+
.filter((record) => Boolean(record))
|
|
103
|
+
.filter((record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine));
|
|
104
|
+
}
|
|
105
|
+
export function parsePosixBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
|
|
106
|
+
return stdout
|
|
107
|
+
.split(/\r?\n/)
|
|
108
|
+
.map((line) => line.trim())
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.map((line) => {
|
|
111
|
+
const match = /^(\d+)\s+(.*)$/.exec(line);
|
|
112
|
+
if (!match) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const pid = Number(match[1]);
|
|
116
|
+
const commandLine = match[2] ?? "";
|
|
117
|
+
if (!Number.isInteger(pid) || pid <= 0 || !commandLine) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
pid,
|
|
122
|
+
commandLine,
|
|
123
|
+
};
|
|
124
|
+
})
|
|
125
|
+
.filter((record) => Boolean(record))
|
|
126
|
+
.filter((record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine));
|
|
127
|
+
}
|
|
128
|
+
function listWindowsBridgeProcesses(currentPid = process.pid) {
|
|
129
|
+
const probe = spawnSync("powershell.exe", [
|
|
130
|
+
"-NoLogo",
|
|
131
|
+
"-NoProfile",
|
|
132
|
+
"-NonInteractive",
|
|
133
|
+
"-ExecutionPolicy",
|
|
134
|
+
"Bypass",
|
|
135
|
+
"-Command",
|
|
136
|
+
[
|
|
137
|
+
"$ErrorActionPreference='Stop'",
|
|
138
|
+
"Get-CimInstance Win32_Process",
|
|
139
|
+
"| Where-Object { $_.CommandLine }",
|
|
140
|
+
"| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
|
|
141
|
+
"| ConvertTo-Json -Compress",
|
|
142
|
+
].join(" "),
|
|
143
|
+
], {
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
timeout: 8_000,
|
|
147
|
+
});
|
|
148
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
return parseWindowsBridgeProcessProbeOutput(probe.stdout, currentPid);
|
|
152
|
+
}
|
|
153
|
+
function listPosixBridgeProcesses(currentPid = process.pid) {
|
|
154
|
+
const probe = spawnSync("ps", ["-ax", "-o", "pid=", "-o", "command="], {
|
|
155
|
+
encoding: "utf8",
|
|
156
|
+
timeout: 8_000,
|
|
157
|
+
});
|
|
158
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
return parsePosixBridgeProcessProbeOutput(probe.stdout, currentPid);
|
|
162
|
+
}
|
|
163
|
+
export function listPeerBridgeProcesses(currentPid = process.pid) {
|
|
164
|
+
return process.platform === "win32"
|
|
165
|
+
? listWindowsBridgeProcesses(currentPid)
|
|
166
|
+
: listPosixBridgeProcesses(currentPid);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* List all managed OpenCode child processes (`serve` and `attach`) whose parent
|
|
170
|
+
* PID is no longer alive. These are orphaned processes left behind when a bridge
|
|
171
|
+
* or local companion crashed or was killed without cleaning up its child
|
|
172
|
+
* OpenCode processes.
|
|
173
|
+
*/
|
|
174
|
+
export function listOrphanedOpencodeProcesses(currentPid = process.pid) {
|
|
175
|
+
const all = listAllProcessesRaw(currentPid);
|
|
176
|
+
return all.filter((record) => {
|
|
177
|
+
if (!isOpencodeServeCommandLine(record.commandLine) &&
|
|
178
|
+
!isOpencodeAttachCommandLine(record.commandLine)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (!record.parentPid) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (record.parentPid === currentPid) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return !isPidAlive(record.parentPid);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function listAllProcessesRaw(currentPid = process.pid) {
|
|
191
|
+
if (process.platform === "win32") {
|
|
192
|
+
const probe = spawnSync("powershell.exe", [
|
|
193
|
+
"-NoLogo",
|
|
194
|
+
"-NoProfile",
|
|
195
|
+
"-NonInteractive",
|
|
196
|
+
"-ExecutionPolicy",
|
|
197
|
+
"Bypass",
|
|
198
|
+
"-Command",
|
|
199
|
+
[
|
|
200
|
+
"$ErrorActionPreference='Stop'",
|
|
201
|
+
"Get-CimInstance Win32_Process",
|
|
202
|
+
"| Where-Object { $_.CommandLine }",
|
|
203
|
+
"| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
|
|
204
|
+
"| ConvertTo-Json -Compress",
|
|
205
|
+
].join(" "),
|
|
206
|
+
], {
|
|
207
|
+
encoding: "utf8",
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
timeout: 8_000,
|
|
210
|
+
});
|
|
211
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const trimmed = probe.stdout.trim();
|
|
215
|
+
if (!trimmed)
|
|
216
|
+
return [];
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(trimmed);
|
|
219
|
+
const values = Array.isArray(parsed) ? parsed : [parsed];
|
|
220
|
+
return values
|
|
221
|
+
.map(normalizeBridgeProcessRecord)
|
|
222
|
+
.filter((r) => Boolean(r))
|
|
223
|
+
.filter((r) => r.pid !== currentPid);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const probe = spawnSync("ps", ["-ax", "-o", "pid=", "-o", "ppid=", "-o", "command="], {
|
|
230
|
+
encoding: "utf8",
|
|
231
|
+
timeout: 8_000,
|
|
232
|
+
});
|
|
233
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
return probe.stdout
|
|
237
|
+
.split(/\r?\n/)
|
|
238
|
+
.map((line) => line.trim())
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.map((line) => {
|
|
241
|
+
const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
242
|
+
if (!match)
|
|
243
|
+
return null;
|
|
244
|
+
const pid = Number(match[1]);
|
|
245
|
+
const parentPid = Number(match[2]);
|
|
246
|
+
const commandLine = match[3] ?? "";
|
|
247
|
+
if (!Number.isInteger(pid) || pid <= 0 || !commandLine)
|
|
248
|
+
return null;
|
|
249
|
+
const record = { pid, commandLine };
|
|
250
|
+
if (Number.isInteger(parentPid) && parentPid > 0)
|
|
251
|
+
record.parentPid = parentPid;
|
|
252
|
+
return record;
|
|
253
|
+
})
|
|
254
|
+
.filter((r) => Boolean(r))
|
|
255
|
+
.filter((r) => r.pid !== currentPid);
|
|
256
|
+
}
|
|
257
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
258
|
+
const deadline = Date.now() + timeoutMs;
|
|
259
|
+
while (Date.now() < deadline) {
|
|
260
|
+
if (!isPidAlive(pid)) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
await sleep(Math.min(PEER_BRIDGE_EXIT_POLL_MS, deadline - Date.now()));
|
|
264
|
+
}
|
|
265
|
+
return !isPidAlive(pid);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Synchronously kill a process and its entire descendant tree.
|
|
269
|
+
* On Windows uses `taskkill /T /F /PID` to recursively kill all descendants.
|
|
270
|
+
* On other platforms uses `process.kill(pid)` directly.
|
|
271
|
+
*/
|
|
272
|
+
export function killProcessTreeSync(pid) {
|
|
273
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (process.platform === "win32") {
|
|
277
|
+
try {
|
|
278
|
+
spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], {
|
|
279
|
+
windowsHide: true,
|
|
280
|
+
timeout: 5_000,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
try {
|
|
285
|
+
process.kill(pid);
|
|
286
|
+
}
|
|
287
|
+
catch { /* best effort */ }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
try {
|
|
292
|
+
process.kill(pid);
|
|
293
|
+
}
|
|
294
|
+
catch { /* best effort */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export async function reapPeerBridgeProcesses(params = {}) {
|
|
298
|
+
const currentPid = params.currentPid ?? process.pid;
|
|
299
|
+
const peers = listPeerBridgeProcesses(currentPid);
|
|
300
|
+
const terminated = [];
|
|
301
|
+
for (const peer of peers) {
|
|
302
|
+
try {
|
|
303
|
+
params.logger?.(`peer_bridge_reap_attempt: pid=${peer.pid}${peer.name ? ` name=${peer.name}` : ""} command=${peer.commandLine}`);
|
|
304
|
+
killProcessTreeSync(peer.pid);
|
|
305
|
+
if (await waitForProcessExit(peer.pid, PEER_BRIDGE_EXIT_TIMEOUT_MS)) {
|
|
306
|
+
terminated.push(peer.pid);
|
|
307
|
+
params.logger?.(`peer_bridge_reaped: pid=${peer.pid}`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
params.logger?.(`peer_bridge_reap_timeout: pid=${peer.pid}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
315
|
+
params.logger?.(`peer_bridge_reap_failed: pid=${peer.pid} error=${message}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return terminated;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Kill orphaned managed OpenCode processes (`serve` and `attach`) whose parent
|
|
322
|
+
* bridge/companion has already exited. These accumulate when a bridge crashes
|
|
323
|
+
* or is killed without cleaning up its child OpenCode processes (common on
|
|
324
|
+
* Windows where child processes are not automatically killed when the parent dies).
|
|
325
|
+
*/
|
|
326
|
+
export async function reapOrphanedOpencodeProcesses(params = {}) {
|
|
327
|
+
const currentPid = params.currentPid ?? process.pid;
|
|
328
|
+
const orphans = listOrphanedOpencodeProcesses(currentPid);
|
|
329
|
+
const terminated = [];
|
|
330
|
+
for (const orphan of orphans) {
|
|
331
|
+
try {
|
|
332
|
+
params.logger?.(`orphan_opencode_reap_attempt: pid=${orphan.pid}${orphan.name ? ` name=${orphan.name}` : ""} parentPid=${orphan.parentPid} command=${orphan.commandLine}`);
|
|
333
|
+
killProcessTreeSync(orphan.pid);
|
|
334
|
+
if (await waitForProcessExit(orphan.pid, PEER_BRIDGE_EXIT_TIMEOUT_MS)) {
|
|
335
|
+
terminated.push(orphan.pid);
|
|
336
|
+
params.logger?.(`orphan_opencode_reaped: pid=${orphan.pid}`);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
params.logger?.(`orphan_opencode_reap_timeout: pid=${orphan.pid}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
344
|
+
params.logger?.(`orphan_opencode_reap_failed: pid=${orphan.pid} error=${message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return terminated;
|
|
348
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
|
|
3
|
+
import { buildInstanceId } from "./bridge-utils.js";
|
|
4
|
+
const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
|
|
5
|
+
const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
|
|
6
|
+
export function resolveRestorableSharedSessionId(persisted, options) {
|
|
7
|
+
if (!persisted ||
|
|
8
|
+
persisted.cwd !== options.cwd ||
|
|
9
|
+
persisted.adapter !== options.adapter) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
return persisted.sharedSessionId ?? persisted.sharedThreadId;
|
|
13
|
+
}
|
|
14
|
+
function cloneState(state) {
|
|
15
|
+
return JSON.parse(JSON.stringify(state));
|
|
16
|
+
}
|
|
17
|
+
function isPidAlive(pid) {
|
|
18
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function sleepSync(ms) {
|
|
30
|
+
if (ms <= 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Rare startup-only reclaim path; blocking briefly here keeps the lock flow simple.
|
|
34
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
35
|
+
}
|
|
36
|
+
function waitForProcessExitSync(pid, timeoutMs, isProcessAlive = isPidAlive) {
|
|
37
|
+
const deadline = Date.now() + timeoutMs;
|
|
38
|
+
while (Date.now() < deadline) {
|
|
39
|
+
if (!isProcessAlive(pid)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
sleepSync(Math.min(ORPHAN_LOCK_RECLAIM_POLL_MS, deadline - Date.now()));
|
|
43
|
+
}
|
|
44
|
+
return !isProcessAlive(pid);
|
|
45
|
+
}
|
|
46
|
+
export function normalizeBridgeLockPayload(value) {
|
|
47
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const record = value;
|
|
51
|
+
if (typeof record.pid !== "number" ||
|
|
52
|
+
typeof record.instanceId !== "string" ||
|
|
53
|
+
typeof record.adapter !== "string" ||
|
|
54
|
+
typeof record.command !== "string" ||
|
|
55
|
+
typeof record.cwd !== "string" ||
|
|
56
|
+
typeof record.startedAt !== "string") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const adapter = record.adapter === "codex" ||
|
|
60
|
+
record.adapter === "claude" ||
|
|
61
|
+
record.adapter === "opencode" ||
|
|
62
|
+
record.adapter === "shell"
|
|
63
|
+
? record.adapter
|
|
64
|
+
: null;
|
|
65
|
+
if (!adapter) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const hasExplicitLifecycle = record.lifecycle === "persistent" || record.lifecycle === "companion_bound";
|
|
69
|
+
return {
|
|
70
|
+
pid: record.pid,
|
|
71
|
+
parentPid: typeof record.parentPid === "number" ? record.parentPid : 0,
|
|
72
|
+
instanceId: record.instanceId,
|
|
73
|
+
adapter,
|
|
74
|
+
command: record.command,
|
|
75
|
+
cwd: record.cwd,
|
|
76
|
+
startedAt: record.startedAt,
|
|
77
|
+
lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
|
|
78
|
+
legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function readBridgeLockFile() {
|
|
82
|
+
try {
|
|
83
|
+
if (!fs.existsSync(BRIDGE_LOCK_FILE)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return normalizeBridgeLockPayload(JSON.parse(fs.readFileSync(BRIDGE_LOCK_FILE, "utf-8")));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function shouldAutoReclaimBridgeLock(lock, isProcessAlive = isPidAlive) {
|
|
93
|
+
if (lock.lifecycle === "companion_bound" ||
|
|
94
|
+
(lock.legacyLifecycleFallback === true && lock.adapter === "codex")) {
|
|
95
|
+
return lock.parentPid > 1 && !isProcessAlive(lock.parentPid);
|
|
96
|
+
}
|
|
97
|
+
// For persistent lifecycle (e.g. opencode), reclaim the lock if the
|
|
98
|
+
// lock-holding process is no longer alive. This prevents stale locks
|
|
99
|
+
// from permanently blocking subsequent bridge starts when the process
|
|
100
|
+
// was force-killed (SIGKILL, Task Manager, OOM, etc.).
|
|
101
|
+
return !isProcessAlive(lock.pid);
|
|
102
|
+
}
|
|
103
|
+
export function evaluateBridgeRuntimeOwnership(params) {
|
|
104
|
+
const isProcessAlive = params.isProcessAlive ?? isPidAlive;
|
|
105
|
+
if (params.workspaceStateInstanceId &&
|
|
106
|
+
params.workspaceStateInstanceId !== params.currentInstanceId) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: "superseded",
|
|
110
|
+
activeInstanceId: params.workspaceStateInstanceId,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (params.lock &&
|
|
114
|
+
params.lock.pid === params.currentPid &&
|
|
115
|
+
params.lock.instanceId === params.currentInstanceId) {
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
rehydratedLock: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (params.lock &&
|
|
122
|
+
params.lock.instanceId !== params.currentInstanceId &&
|
|
123
|
+
isProcessAlive(params.lock.pid)) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: "lock_conflict",
|
|
127
|
+
activeInstanceId: params.lock.instanceId,
|
|
128
|
+
activePid: params.lock.pid,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
rehydratedLock: true,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function buildLockConflictError(lock) {
|
|
137
|
+
return new Error(`Another bridge instance is already running (pid=${lock.pid}, instanceId=${lock.instanceId}, adapter=${lock.adapter}, cwd=${lock.cwd}, startedAt=${lock.startedAt}, lifecycle=${lock.lifecycle}). Stop it before starting a new bridge.`);
|
|
138
|
+
}
|
|
139
|
+
function tryTerminateOrphanedBridge(lock) {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(lock.pid);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
if (isPidAlive(lock.pid)) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return waitForProcessExitSync(lock.pid, ORPHAN_LOCK_RECLAIM_TIMEOUT_MS);
|
|
149
|
+
}
|
|
150
|
+
export class BridgeStateStore {
|
|
151
|
+
state;
|
|
152
|
+
lockPayload;
|
|
153
|
+
bridgeStartedAtMs;
|
|
154
|
+
instanceId;
|
|
155
|
+
stateFilePath;
|
|
156
|
+
constructor(options) {
|
|
157
|
+
ensureChannelDataDir();
|
|
158
|
+
this.stateFilePath = ensureWorkspaceChannelDir(options.cwd).stateFile;
|
|
159
|
+
this.bridgeStartedAtMs = Date.now();
|
|
160
|
+
this.instanceId = buildInstanceId();
|
|
161
|
+
this.lockPayload = {
|
|
162
|
+
pid: process.pid,
|
|
163
|
+
parentPid: process.ppid,
|
|
164
|
+
instanceId: this.instanceId,
|
|
165
|
+
adapter: options.adapter,
|
|
166
|
+
command: options.command,
|
|
167
|
+
cwd: options.cwd,
|
|
168
|
+
startedAt: new Date(this.bridgeStartedAtMs).toISOString(),
|
|
169
|
+
lifecycle: options.lifecycle,
|
|
170
|
+
};
|
|
171
|
+
this.acquireLock();
|
|
172
|
+
const persisted = this.readStateFile();
|
|
173
|
+
const persistedSharedSessionId = resolveRestorableSharedSessionId(persisted, options);
|
|
174
|
+
const persistedResumeConversationId = options.adapter === "claude" &&
|
|
175
|
+
persisted?.cwd === options.cwd &&
|
|
176
|
+
typeof persisted.resumeConversationId === "string"
|
|
177
|
+
? persisted.resumeConversationId
|
|
178
|
+
: undefined;
|
|
179
|
+
const persistedTranscriptPath = options.adapter === "claude" &&
|
|
180
|
+
persisted?.cwd === options.cwd &&
|
|
181
|
+
typeof persisted.transcriptPath === "string"
|
|
182
|
+
? persisted.transcriptPath
|
|
183
|
+
: undefined;
|
|
184
|
+
this.state = {
|
|
185
|
+
instanceId: this.instanceId,
|
|
186
|
+
adapter: options.adapter,
|
|
187
|
+
command: options.command,
|
|
188
|
+
cwd: options.cwd,
|
|
189
|
+
profile: options.profile,
|
|
190
|
+
authorizedUserId: options.authorizedUserId,
|
|
191
|
+
bridgeStartedAtMs: this.bridgeStartedAtMs,
|
|
192
|
+
ignoredBacklogCount: 0,
|
|
193
|
+
sharedSessionId: persistedSharedSessionId,
|
|
194
|
+
sharedThreadId: options.adapter === "codex" ? persistedSharedSessionId : undefined,
|
|
195
|
+
resumeConversationId: persistedResumeConversationId,
|
|
196
|
+
transcriptPath: persistedTranscriptPath,
|
|
197
|
+
lastActivityAt: persisted?.lastActivityAt,
|
|
198
|
+
pendingConfirmation: null,
|
|
199
|
+
pendingUserInput: null,
|
|
200
|
+
};
|
|
201
|
+
this.save();
|
|
202
|
+
if (persisted?.pendingConfirmation) {
|
|
203
|
+
this.appendLog("Cleared stale pending confirmation from previous bridge session.");
|
|
204
|
+
}
|
|
205
|
+
if (persisted?.pendingUserInput) {
|
|
206
|
+
this.appendLog("Cleared stale pending user input request from previous bridge session.");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
getState() {
|
|
210
|
+
return cloneState(this.state);
|
|
211
|
+
}
|
|
212
|
+
touchActivity(timestamp = new Date().toISOString()) {
|
|
213
|
+
this.state.lastActivityAt = timestamp;
|
|
214
|
+
this.save();
|
|
215
|
+
}
|
|
216
|
+
setPendingConfirmation(pending) {
|
|
217
|
+
this.state.pendingConfirmation = pending;
|
|
218
|
+
this.save();
|
|
219
|
+
}
|
|
220
|
+
clearPendingConfirmation() {
|
|
221
|
+
if (!this.state.pendingConfirmation) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.state.pendingConfirmation = null;
|
|
225
|
+
this.save();
|
|
226
|
+
}
|
|
227
|
+
setPendingUserInput(pending) {
|
|
228
|
+
this.state.pendingUserInput = pending;
|
|
229
|
+
this.save();
|
|
230
|
+
}
|
|
231
|
+
clearPendingUserInput() {
|
|
232
|
+
if (!this.state.pendingUserInput) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.state.pendingUserInput = null;
|
|
236
|
+
this.save();
|
|
237
|
+
}
|
|
238
|
+
incrementIgnoredBacklog(count = 1) {
|
|
239
|
+
this.state.ignoredBacklogCount += count;
|
|
240
|
+
this.save();
|
|
241
|
+
}
|
|
242
|
+
setSharedSessionId(sessionId) {
|
|
243
|
+
this.state.sharedSessionId = sessionId;
|
|
244
|
+
this.state.sharedThreadId = this.state.adapter === "codex" ? sessionId : undefined;
|
|
245
|
+
this.save();
|
|
246
|
+
}
|
|
247
|
+
setSharedThreadId(threadId) {
|
|
248
|
+
this.setSharedSessionId(threadId);
|
|
249
|
+
}
|
|
250
|
+
clearSharedSessionId() {
|
|
251
|
+
if (!this.state.sharedSessionId && !this.state.sharedThreadId) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.state.sharedSessionId = undefined;
|
|
255
|
+
this.state.sharedThreadId = undefined;
|
|
256
|
+
this.save();
|
|
257
|
+
}
|
|
258
|
+
clearSharedThreadId() {
|
|
259
|
+
this.clearSharedSessionId();
|
|
260
|
+
}
|
|
261
|
+
setClaudeResumeState(resumeConversationId, transcriptPath) {
|
|
262
|
+
if (this.state.adapter !== "claude") {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.state.resumeConversationId = resumeConversationId || undefined;
|
|
266
|
+
this.state.transcriptPath = transcriptPath || undefined;
|
|
267
|
+
this.save();
|
|
268
|
+
}
|
|
269
|
+
clearClaudeResumeState() {
|
|
270
|
+
if (this.state.adapter !== "claude" ||
|
|
271
|
+
(!this.state.resumeConversationId && !this.state.transcriptPath)) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.state.resumeConversationId = undefined;
|
|
275
|
+
this.state.transcriptPath = undefined;
|
|
276
|
+
this.save();
|
|
277
|
+
}
|
|
278
|
+
appendLog(message) {
|
|
279
|
+
ensureChannelDataDir();
|
|
280
|
+
fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] ${message}\n`, "utf-8");
|
|
281
|
+
}
|
|
282
|
+
releaseLock() {
|
|
283
|
+
try {
|
|
284
|
+
const currentLock = readBridgeLockFile();
|
|
285
|
+
if (currentLock?.pid === process.pid) {
|
|
286
|
+
fs.rmSync(BRIDGE_LOCK_FILE, { force: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Best effort cleanup.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
save() {
|
|
294
|
+
ensureChannelDataDir();
|
|
295
|
+
fs.writeFileSync(this.stateFilePath, JSON.stringify(this.state, null, 2), "utf-8");
|
|
296
|
+
}
|
|
297
|
+
acquireLock() {
|
|
298
|
+
const existing = readBridgeLockFile();
|
|
299
|
+
if (existing &&
|
|
300
|
+
existing.pid !== process.pid &&
|
|
301
|
+
isPidAlive(existing.pid)) {
|
|
302
|
+
if (shouldAutoReclaimBridgeLock(existing)) {
|
|
303
|
+
this.appendLog(`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
304
|
+
if (tryTerminateOrphanedBridge(existing)) {
|
|
305
|
+
this.appendLog(`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
this.appendLog(`lock_reclaim_failed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
309
|
+
throw buildLockConflictError(existing);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
this.appendLog(`lock_conflict: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
314
|
+
throw buildLockConflictError(existing);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
|
|
318
|
+
}
|
|
319
|
+
verifyRuntimeOwnership() {
|
|
320
|
+
const workspaceState = this.readWorkspaceStateFile();
|
|
321
|
+
const ownership = evaluateBridgeRuntimeOwnership({
|
|
322
|
+
currentInstanceId: this.instanceId,
|
|
323
|
+
currentPid: process.pid,
|
|
324
|
+
workspaceStateInstanceId: typeof workspaceState?.instanceId === "string"
|
|
325
|
+
? workspaceState.instanceId
|
|
326
|
+
: undefined,
|
|
327
|
+
lock: readBridgeLockFile(),
|
|
328
|
+
});
|
|
329
|
+
if (!ownership.ok) {
|
|
330
|
+
return ownership;
|
|
331
|
+
}
|
|
332
|
+
if (!workspaceState?.instanceId) {
|
|
333
|
+
this.save();
|
|
334
|
+
}
|
|
335
|
+
if (ownership.rehydratedLock) {
|
|
336
|
+
fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
|
|
337
|
+
}
|
|
338
|
+
return ownership;
|
|
339
|
+
}
|
|
340
|
+
readStateFile() {
|
|
341
|
+
try {
|
|
342
|
+
if (!fs.existsSync(this.stateFilePath)) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
return JSON.parse(fs.readFileSync(this.stateFilePath, "utf-8"));
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
readWorkspaceStateFile() {
|
|
352
|
+
try {
|
|
353
|
+
if (!fs.existsSync(this.stateFilePath)) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
return JSON.parse(fs.readFileSync(this.stateFilePath, "utf-8"));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|