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.
Files changed (54) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +637 -0
  3. package/bin/_run-entry.mjs +35 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-daemon.mjs +5 -0
  15. package/bin/wechat-opencode-start.mjs +5 -0
  16. package/bin/wechat-opencode.mjs +5 -0
  17. package/bin/wechat-setup.mjs +5 -0
  18. package/dist/bridge/bridge-adapter-common.js +95 -0
  19. package/dist/bridge/bridge-adapters.claude.js +829 -0
  20. package/dist/bridge/bridge-adapters.codex.js +2228 -0
  21. package/dist/bridge/bridge-adapters.core.js +717 -0
  22. package/dist/bridge/bridge-adapters.js +26 -0
  23. package/dist/bridge/bridge-adapters.opencode.js +2129 -0
  24. package/dist/bridge/bridge-adapters.shared.js +1005 -0
  25. package/dist/bridge/bridge-adapters.shell.js +363 -0
  26. package/dist/bridge/bridge-controller.js +48 -0
  27. package/dist/bridge/bridge-final-reply.js +46 -0
  28. package/dist/bridge/bridge-process-reaper.js +348 -0
  29. package/dist/bridge/bridge-state.js +362 -0
  30. package/dist/bridge/bridge-types.js +1 -0
  31. package/dist/bridge/bridge-utils.js +1240 -0
  32. package/dist/bridge/claude-hook.js +82 -0
  33. package/dist/bridge/claude-hooks.js +267 -0
  34. package/dist/bridge/wechat-bridge.js +1026 -0
  35. package/dist/commands/check-update.js +30 -0
  36. package/dist/companion/codex-panel-link.js +72 -0
  37. package/dist/companion/codex-panel.js +179 -0
  38. package/dist/companion/codex-remote-client.js +124 -0
  39. package/dist/companion/local-companion-link.js +240 -0
  40. package/dist/companion/local-companion-start.js +420 -0
  41. package/dist/companion/local-companion.js +424 -0
  42. package/dist/daemon/daemon-link.js +175 -0
  43. package/dist/daemon/wechat-daemon.js +1202 -0
  44. package/dist/media/media-types.js +1 -0
  45. package/dist/runtime/create-runtime-host.js +12 -0
  46. package/dist/runtime/legacy-adapter-runtime.js +46 -0
  47. package/dist/runtime/runtime-types.js +5 -0
  48. package/dist/utils/version-checker.js +161 -0
  49. package/dist/wechat/channel-config.js +196 -0
  50. package/dist/wechat/setup.js +283 -0
  51. package/dist/wechat/standalone-bot.js +355 -0
  52. package/dist/wechat/wechat-channel.js +492 -0
  53. package/dist/wechat/wechat-transport.js +1213 -0
  54. 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
+ }