@unlinearity/cli-wechat-bridge 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bin/_run-entry.mjs +2 -8
  2. package/bin/wechat-bridge-claude.mjs +2 -2
  3. package/bin/wechat-bridge-codex.mjs +2 -2
  4. package/bin/wechat-bridge-opencode.mjs +2 -2
  5. package/bin/wechat-bridge-shell.mjs +2 -2
  6. package/bin/wechat-bridge.mjs +2 -2
  7. package/bin/wechat-check-update.mjs +2 -2
  8. package/bin/wechat-claude-start.mjs +2 -2
  9. package/bin/wechat-claude.mjs +2 -2
  10. package/bin/wechat-codex-start.mjs +2 -2
  11. package/bin/wechat-codex.mjs +2 -2
  12. package/bin/wechat-opencode-start.mjs +2 -2
  13. package/bin/wechat-opencode.mjs +2 -2
  14. package/bin/wechat-setup.mjs +5 -0
  15. package/dist/bridge/bridge-adapters.claude.js +829 -0
  16. package/dist/bridge/bridge-adapters.codex.js +2206 -0
  17. package/dist/bridge/bridge-adapters.core.js +668 -0
  18. package/dist/bridge/bridge-adapters.js +26 -0
  19. package/dist/bridge/bridge-adapters.opencode.js +1777 -0
  20. package/dist/bridge/bridge-adapters.shared.js +1098 -0
  21. package/dist/bridge/bridge-adapters.shell.js +363 -0
  22. package/dist/bridge/bridge-controller.js +44 -0
  23. package/dist/bridge/bridge-final-reply.js +31 -0
  24. package/dist/bridge/bridge-process-reaper.js +348 -0
  25. package/dist/bridge/bridge-state.js +356 -0
  26. package/dist/bridge/bridge-types.js +1 -0
  27. package/dist/bridge/bridge-utils.js +1150 -0
  28. package/dist/bridge/claude-hook.js +82 -0
  29. package/dist/bridge/claude-hooks.js +267 -0
  30. package/dist/bridge/wechat-bridge.js +904 -0
  31. package/dist/commands/check-update.js +30 -0
  32. package/dist/companion/codex-panel-link.js +72 -0
  33. package/dist/companion/codex-panel.js +179 -0
  34. package/dist/companion/codex-remote-client.js +117 -0
  35. package/dist/companion/local-companion-link.js +210 -0
  36. package/dist/companion/local-companion-start.js +372 -0
  37. package/dist/companion/local-companion.js +411 -0
  38. package/dist/media/media-types.js +1 -0
  39. package/dist/runtime/create-runtime-host.js +12 -0
  40. package/dist/runtime/legacy-adapter-runtime.js +40 -0
  41. package/dist/runtime/runtime-types.js +5 -0
  42. package/dist/utils/version-checker.js +161 -0
  43. package/dist/wechat/channel-config.js +103 -0
  44. package/dist/wechat/setup.js +283 -0
  45. package/dist/wechat/standalone-bot.js +356 -0
  46. package/dist/wechat/wechat-channel.js +481 -0
  47. package/dist/wechat/wechat-transport.js +887 -0
  48. package/package.json +6 -2
  49. package/src/bridge/bridge-adapters.claude.ts +0 -1047
  50. package/src/bridge/bridge-adapters.codex.ts +0 -2733
  51. package/src/bridge/bridge-adapters.core.ts +0 -877
  52. package/src/bridge/bridge-adapters.opencode.ts +0 -2390
  53. package/src/bridge/bridge-adapters.shared.ts +0 -1647
  54. package/src/bridge/bridge-adapters.shell.ts +0 -456
  55. package/src/bridge/bridge-adapters.ts +0 -30
  56. package/src/bridge/bridge-controller.ts +0 -58
  57. package/src/bridge/bridge-final-reply.ts +0 -56
  58. package/src/bridge/bridge-process-reaper.ts +0 -417
  59. package/src/bridge/bridge-state.ts +0 -520
  60. package/src/bridge/bridge-types.ts +0 -218
  61. package/src/bridge/bridge-utils.ts +0 -1524
  62. package/src/bridge/claude-hook.ts +0 -92
  63. package/src/bridge/claude-hooks.ts +0 -376
  64. package/src/bridge/wechat-bridge.ts +0 -1305
  65. package/src/commands/check-update.ts +0 -46
  66. package/src/companion/codex-panel-link.ts +0 -151
  67. package/src/companion/codex-panel.ts +0 -225
  68. package/src/companion/codex-remote-client.ts +0 -178
  69. package/src/companion/local-companion-link.ts +0 -338
  70. package/src/companion/local-companion-start.ts +0 -520
  71. package/src/companion/local-companion.ts +0 -532
  72. package/src/media/media-types.ts +0 -11
  73. package/src/runtime/create-runtime-host.ts +0 -16
  74. package/src/runtime/legacy-adapter-runtime.ts +0 -55
  75. package/src/runtime/runtime-types.ts +0 -51
  76. package/src/utils/version-checker.ts +0 -197
  77. package/src/wechat/channel-config.ts +0 -141
  78. package/src/wechat/setup.ts +0 -232
  79. package/src/wechat/standalone-bot.ts +0 -483
  80. package/src/wechat/wechat-channel.ts +0 -608
  81. package/src/wechat/wechat-transport.ts +0 -1290
@@ -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,356 @@
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
+ function cloneState(state) {
7
+ return JSON.parse(JSON.stringify(state));
8
+ }
9
+ function isPidAlive(pid) {
10
+ if (!Number.isInteger(pid) || pid <= 0) {
11
+ return false;
12
+ }
13
+ try {
14
+ process.kill(pid, 0);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function sleepSync(ms) {
22
+ if (ms <= 0) {
23
+ return;
24
+ }
25
+ // Rare startup-only reclaim path; blocking briefly here keeps the lock flow simple.
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+ function waitForProcessExitSync(pid, timeoutMs, isProcessAlive = isPidAlive) {
29
+ const deadline = Date.now() + timeoutMs;
30
+ while (Date.now() < deadline) {
31
+ if (!isProcessAlive(pid)) {
32
+ return true;
33
+ }
34
+ sleepSync(Math.min(ORPHAN_LOCK_RECLAIM_POLL_MS, deadline - Date.now()));
35
+ }
36
+ return !isProcessAlive(pid);
37
+ }
38
+ export function normalizeBridgeLockPayload(value) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
40
+ return null;
41
+ }
42
+ const record = value;
43
+ if (typeof record.pid !== "number" ||
44
+ typeof record.instanceId !== "string" ||
45
+ typeof record.adapter !== "string" ||
46
+ typeof record.command !== "string" ||
47
+ typeof record.cwd !== "string" ||
48
+ typeof record.startedAt !== "string") {
49
+ return null;
50
+ }
51
+ const adapter = record.adapter === "codex" ||
52
+ record.adapter === "claude" ||
53
+ record.adapter === "opencode" ||
54
+ record.adapter === "shell"
55
+ ? record.adapter
56
+ : null;
57
+ if (!adapter) {
58
+ return null;
59
+ }
60
+ const hasExplicitLifecycle = record.lifecycle === "persistent" || record.lifecycle === "companion_bound";
61
+ return {
62
+ pid: record.pid,
63
+ parentPid: typeof record.parentPid === "number" ? record.parentPid : 0,
64
+ instanceId: record.instanceId,
65
+ adapter,
66
+ command: record.command,
67
+ cwd: record.cwd,
68
+ startedAt: record.startedAt,
69
+ lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
70
+ legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
71
+ };
72
+ }
73
+ export function readBridgeLockFile() {
74
+ try {
75
+ if (!fs.existsSync(BRIDGE_LOCK_FILE)) {
76
+ return null;
77
+ }
78
+ return normalizeBridgeLockPayload(JSON.parse(fs.readFileSync(BRIDGE_LOCK_FILE, "utf-8")));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ export function shouldAutoReclaimBridgeLock(lock, isProcessAlive = isPidAlive) {
85
+ if (lock.lifecycle === "companion_bound" ||
86
+ (lock.legacyLifecycleFallback === true && lock.adapter === "codex")) {
87
+ return lock.parentPid > 1 && !isProcessAlive(lock.parentPid);
88
+ }
89
+ // For persistent lifecycle (e.g. opencode), reclaim the lock if the
90
+ // lock-holding process is no longer alive. This prevents stale locks
91
+ // from permanently blocking subsequent bridge starts when the process
92
+ // was force-killed (SIGKILL, Task Manager, OOM, etc.).
93
+ return !isProcessAlive(lock.pid);
94
+ }
95
+ export function evaluateBridgeRuntimeOwnership(params) {
96
+ const isProcessAlive = params.isProcessAlive ?? isPidAlive;
97
+ if (params.workspaceStateInstanceId &&
98
+ params.workspaceStateInstanceId !== params.currentInstanceId) {
99
+ return {
100
+ ok: false,
101
+ reason: "superseded",
102
+ activeInstanceId: params.workspaceStateInstanceId,
103
+ };
104
+ }
105
+ if (params.lock &&
106
+ params.lock.pid === params.currentPid &&
107
+ params.lock.instanceId === params.currentInstanceId) {
108
+ return {
109
+ ok: true,
110
+ rehydratedLock: false,
111
+ };
112
+ }
113
+ if (params.lock &&
114
+ params.lock.instanceId !== params.currentInstanceId &&
115
+ isProcessAlive(params.lock.pid)) {
116
+ return {
117
+ ok: false,
118
+ reason: "lock_conflict",
119
+ activeInstanceId: params.lock.instanceId,
120
+ activePid: params.lock.pid,
121
+ };
122
+ }
123
+ return {
124
+ ok: true,
125
+ rehydratedLock: true,
126
+ };
127
+ }
128
+ function buildLockConflictError(lock) {
129
+ 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.`);
130
+ }
131
+ function tryTerminateOrphanedBridge(lock) {
132
+ try {
133
+ process.kill(lock.pid);
134
+ }
135
+ catch {
136
+ if (isPidAlive(lock.pid)) {
137
+ return false;
138
+ }
139
+ }
140
+ return waitForProcessExitSync(lock.pid, ORPHAN_LOCK_RECLAIM_TIMEOUT_MS);
141
+ }
142
+ export class BridgeStateStore {
143
+ state;
144
+ lockPayload;
145
+ bridgeStartedAtMs;
146
+ instanceId;
147
+ stateFilePath;
148
+ constructor(options) {
149
+ ensureChannelDataDir();
150
+ this.stateFilePath = ensureWorkspaceChannelDir(options.cwd).stateFile;
151
+ this.bridgeStartedAtMs = Date.now();
152
+ this.instanceId = buildInstanceId();
153
+ this.lockPayload = {
154
+ pid: process.pid,
155
+ parentPid: process.ppid,
156
+ instanceId: this.instanceId,
157
+ adapter: options.adapter,
158
+ command: options.command,
159
+ cwd: options.cwd,
160
+ startedAt: new Date(this.bridgeStartedAtMs).toISOString(),
161
+ lifecycle: options.lifecycle,
162
+ };
163
+ this.acquireLock();
164
+ const persisted = this.readStateFile();
165
+ const persistedSharedSessionId = persisted?.cwd === options.cwd
166
+ ? persisted.sharedSessionId ?? persisted.sharedThreadId
167
+ : undefined;
168
+ const persistedResumeConversationId = options.adapter === "claude" &&
169
+ persisted?.cwd === options.cwd &&
170
+ typeof persisted.resumeConversationId === "string"
171
+ ? persisted.resumeConversationId
172
+ : undefined;
173
+ const persistedTranscriptPath = options.adapter === "claude" &&
174
+ persisted?.cwd === options.cwd &&
175
+ typeof persisted.transcriptPath === "string"
176
+ ? persisted.transcriptPath
177
+ : undefined;
178
+ this.state = {
179
+ instanceId: this.instanceId,
180
+ adapter: options.adapter,
181
+ command: options.command,
182
+ cwd: options.cwd,
183
+ profile: options.profile,
184
+ authorizedUserId: options.authorizedUserId,
185
+ bridgeStartedAtMs: this.bridgeStartedAtMs,
186
+ ignoredBacklogCount: 0,
187
+ sharedSessionId: persistedSharedSessionId,
188
+ sharedThreadId: options.adapter === "codex" ? persistedSharedSessionId : undefined,
189
+ resumeConversationId: persistedResumeConversationId,
190
+ transcriptPath: persistedTranscriptPath,
191
+ lastActivityAt: persisted?.lastActivityAt,
192
+ pendingConfirmation: null,
193
+ pendingUserInput: null,
194
+ };
195
+ this.save();
196
+ if (persisted?.pendingConfirmation) {
197
+ this.appendLog("Cleared stale pending confirmation from previous bridge session.");
198
+ }
199
+ if (persisted?.pendingUserInput) {
200
+ this.appendLog("Cleared stale pending user input request from previous bridge session.");
201
+ }
202
+ }
203
+ getState() {
204
+ return cloneState(this.state);
205
+ }
206
+ touchActivity(timestamp = new Date().toISOString()) {
207
+ this.state.lastActivityAt = timestamp;
208
+ this.save();
209
+ }
210
+ setPendingConfirmation(pending) {
211
+ this.state.pendingConfirmation = pending;
212
+ this.save();
213
+ }
214
+ clearPendingConfirmation() {
215
+ if (!this.state.pendingConfirmation) {
216
+ return;
217
+ }
218
+ this.state.pendingConfirmation = null;
219
+ this.save();
220
+ }
221
+ setPendingUserInput(pending) {
222
+ this.state.pendingUserInput = pending;
223
+ this.save();
224
+ }
225
+ clearPendingUserInput() {
226
+ if (!this.state.pendingUserInput) {
227
+ return;
228
+ }
229
+ this.state.pendingUserInput = null;
230
+ this.save();
231
+ }
232
+ incrementIgnoredBacklog(count = 1) {
233
+ this.state.ignoredBacklogCount += count;
234
+ this.save();
235
+ }
236
+ setSharedSessionId(sessionId) {
237
+ this.state.sharedSessionId = sessionId;
238
+ this.state.sharedThreadId = this.state.adapter === "codex" ? sessionId : undefined;
239
+ this.save();
240
+ }
241
+ setSharedThreadId(threadId) {
242
+ this.setSharedSessionId(threadId);
243
+ }
244
+ clearSharedSessionId() {
245
+ if (!this.state.sharedSessionId && !this.state.sharedThreadId) {
246
+ return;
247
+ }
248
+ this.state.sharedSessionId = undefined;
249
+ this.state.sharedThreadId = undefined;
250
+ this.save();
251
+ }
252
+ clearSharedThreadId() {
253
+ this.clearSharedSessionId();
254
+ }
255
+ setClaudeResumeState(resumeConversationId, transcriptPath) {
256
+ if (this.state.adapter !== "claude") {
257
+ return;
258
+ }
259
+ this.state.resumeConversationId = resumeConversationId || undefined;
260
+ this.state.transcriptPath = transcriptPath || undefined;
261
+ this.save();
262
+ }
263
+ clearClaudeResumeState() {
264
+ if (this.state.adapter !== "claude" ||
265
+ (!this.state.resumeConversationId && !this.state.transcriptPath)) {
266
+ return;
267
+ }
268
+ this.state.resumeConversationId = undefined;
269
+ this.state.transcriptPath = undefined;
270
+ this.save();
271
+ }
272
+ appendLog(message) {
273
+ ensureChannelDataDir();
274
+ fs.appendFileSync(BRIDGE_LOG_FILE, `[${new Date().toISOString()}] ${message}\n`, "utf-8");
275
+ }
276
+ releaseLock() {
277
+ try {
278
+ const currentLock = readBridgeLockFile();
279
+ if (currentLock?.pid === process.pid) {
280
+ fs.rmSync(BRIDGE_LOCK_FILE, { force: true });
281
+ }
282
+ }
283
+ catch {
284
+ // Best effort cleanup.
285
+ }
286
+ }
287
+ save() {
288
+ ensureChannelDataDir();
289
+ fs.writeFileSync(this.stateFilePath, JSON.stringify(this.state, null, 2), "utf-8");
290
+ }
291
+ acquireLock() {
292
+ const existing = readBridgeLockFile();
293
+ if (existing &&
294
+ existing.pid !== process.pid &&
295
+ isPidAlive(existing.pid)) {
296
+ if (shouldAutoReclaimBridgeLock(existing)) {
297
+ this.appendLog(`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
298
+ if (tryTerminateOrphanedBridge(existing)) {
299
+ this.appendLog(`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
300
+ }
301
+ else {
302
+ this.appendLog(`lock_reclaim_failed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
303
+ throw buildLockConflictError(existing);
304
+ }
305
+ }
306
+ else {
307
+ this.appendLog(`lock_conflict: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
308
+ throw buildLockConflictError(existing);
309
+ }
310
+ }
311
+ fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
312
+ }
313
+ verifyRuntimeOwnership() {
314
+ const workspaceState = this.readWorkspaceStateFile();
315
+ const ownership = evaluateBridgeRuntimeOwnership({
316
+ currentInstanceId: this.instanceId,
317
+ currentPid: process.pid,
318
+ workspaceStateInstanceId: typeof workspaceState?.instanceId === "string"
319
+ ? workspaceState.instanceId
320
+ : undefined,
321
+ lock: readBridgeLockFile(),
322
+ });
323
+ if (!ownership.ok) {
324
+ return ownership;
325
+ }
326
+ if (!workspaceState?.instanceId) {
327
+ this.save();
328
+ }
329
+ if (ownership.rehydratedLock) {
330
+ fs.writeFileSync(BRIDGE_LOCK_FILE, JSON.stringify(this.lockPayload, null, 2), "utf-8");
331
+ }
332
+ return ownership;
333
+ }
334
+ readStateFile() {
335
+ try {
336
+ if (!fs.existsSync(this.stateFilePath)) {
337
+ return null;
338
+ }
339
+ return JSON.parse(fs.readFileSync(this.stateFilePath, "utf-8"));
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ }
345
+ readWorkspaceStateFile() {
346
+ try {
347
+ if (!fs.existsSync(this.stateFilePath)) {
348
+ return null;
349
+ }
350
+ return JSON.parse(fs.readFileSync(this.stateFilePath, "utf-8"));
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ }
@@ -0,0 +1 @@
1
+ export {};