@unlinearity/cli-wechat-bridge 1.0.0
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 +531 -0
- package/bin/_run-entry.mjs +41 -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-opencode-start.mjs +5 -0
- package/bin/wechat-opencode.mjs +5 -0
- package/package.json +86 -0
- package/src/bridge/bridge-adapters.claude.ts +1047 -0
- package/src/bridge/bridge-adapters.codex.ts +2733 -0
- package/src/bridge/bridge-adapters.core.ts +877 -0
- package/src/bridge/bridge-adapters.opencode.ts +2390 -0
- package/src/bridge/bridge-adapters.shared.ts +1647 -0
- package/src/bridge/bridge-adapters.shell.ts +456 -0
- package/src/bridge/bridge-adapters.ts +30 -0
- package/src/bridge/bridge-controller.ts +58 -0
- package/src/bridge/bridge-final-reply.ts +56 -0
- package/src/bridge/bridge-process-reaper.ts +417 -0
- package/src/bridge/bridge-state.ts +520 -0
- package/src/bridge/bridge-types.ts +218 -0
- package/src/bridge/bridge-utils.ts +1524 -0
- package/src/bridge/claude-hook.ts +92 -0
- package/src/bridge/claude-hooks.ts +376 -0
- package/src/bridge/wechat-bridge.ts +1305 -0
- package/src/commands/check-update.ts +46 -0
- package/src/companion/codex-panel-link.ts +151 -0
- package/src/companion/codex-panel.ts +225 -0
- package/src/companion/codex-remote-client.ts +178 -0
- package/src/companion/local-companion-link.ts +338 -0
- package/src/companion/local-companion-start.ts +520 -0
- package/src/companion/local-companion.ts +532 -0
- package/src/media/media-types.ts +11 -0
- package/src/runtime/create-runtime-host.ts +16 -0
- package/src/runtime/legacy-adapter-runtime.ts +55 -0
- package/src/runtime/runtime-types.ts +51 -0
- package/src/utils/version-checker.ts +197 -0
- package/src/wechat/channel-config.ts +141 -0
- package/src/wechat/setup.ts +232 -0
- package/src/wechat/standalone-bot.ts +483 -0
- package/src/wechat/wechat-channel.ts +608 -0
- package/src/wechat/wechat-transport.ts +1290 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BRIDGE_LOCK_FILE,
|
|
5
|
+
BRIDGE_LOG_FILE,
|
|
6
|
+
ensureWorkspaceChannelDir,
|
|
7
|
+
ensureChannelDataDir,
|
|
8
|
+
} from "../wechat/channel-config.ts";
|
|
9
|
+
import type {
|
|
10
|
+
BridgeAdapterKind,
|
|
11
|
+
BridgeLifecycleMode,
|
|
12
|
+
BridgeState,
|
|
13
|
+
PendingApproval,
|
|
14
|
+
PendingUserInputRequest,
|
|
15
|
+
} from "./bridge-types.ts";
|
|
16
|
+
import { buildInstanceId } from "./bridge-utils.ts";
|
|
17
|
+
|
|
18
|
+
type BridgeStateOptions = {
|
|
19
|
+
adapter: BridgeAdapterKind;
|
|
20
|
+
command: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
profile?: string;
|
|
23
|
+
lifecycle: BridgeLifecycleMode;
|
|
24
|
+
authorizedUserId: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type BridgeLockPayload = {
|
|
28
|
+
pid: number;
|
|
29
|
+
parentPid: number;
|
|
30
|
+
instanceId: string;
|
|
31
|
+
adapter: BridgeAdapterKind;
|
|
32
|
+
command: string;
|
|
33
|
+
cwd: string;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
lifecycle: BridgeLifecycleMode;
|
|
36
|
+
legacyLifecycleFallback?: true;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type BridgeRuntimeOwnership =
|
|
40
|
+
| {
|
|
41
|
+
ok: true;
|
|
42
|
+
rehydratedLock: boolean;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
ok: false;
|
|
46
|
+
reason: "superseded";
|
|
47
|
+
activeInstanceId: string;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: "lock_conflict";
|
|
52
|
+
activeInstanceId: string;
|
|
53
|
+
activePid: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
|
|
57
|
+
const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
|
|
58
|
+
|
|
59
|
+
function cloneState(state: BridgeState): BridgeState {
|
|
60
|
+
return JSON.parse(JSON.stringify(state)) as BridgeState;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPidAlive(pid: number): boolean {
|
|
64
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sleepSync(ms: number): void {
|
|
77
|
+
if (ms <= 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Rare startup-only reclaim path; blocking briefly here keeps the lock flow simple.
|
|
82
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function waitForProcessExitSync(
|
|
86
|
+
pid: number,
|
|
87
|
+
timeoutMs: number,
|
|
88
|
+
isProcessAlive: (pid: number) => boolean = isPidAlive,
|
|
89
|
+
): boolean {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
if (!isProcessAlive(pid)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
sleepSync(Math.min(ORPHAN_LOCK_RECLAIM_POLL_MS, deadline - Date.now()));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return !isProcessAlive(pid);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeBridgeLockPayload(value: unknown): BridgeLockPayload | null {
|
|
103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const record = value as Record<string, unknown>;
|
|
108
|
+
if (
|
|
109
|
+
typeof record.pid !== "number" ||
|
|
110
|
+
typeof record.instanceId !== "string" ||
|
|
111
|
+
typeof record.adapter !== "string" ||
|
|
112
|
+
typeof record.command !== "string" ||
|
|
113
|
+
typeof record.cwd !== "string" ||
|
|
114
|
+
typeof record.startedAt !== "string"
|
|
115
|
+
) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const adapter =
|
|
120
|
+
record.adapter === "codex" ||
|
|
121
|
+
record.adapter === "claude" ||
|
|
122
|
+
record.adapter === "opencode" ||
|
|
123
|
+
record.adapter === "shell"
|
|
124
|
+
? record.adapter
|
|
125
|
+
: null;
|
|
126
|
+
if (!adapter) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hasExplicitLifecycle =
|
|
131
|
+
record.lifecycle === "persistent" || record.lifecycle === "companion_bound";
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
pid: record.pid,
|
|
135
|
+
parentPid: typeof record.parentPid === "number" ? record.parentPid : 0,
|
|
136
|
+
instanceId: record.instanceId,
|
|
137
|
+
adapter,
|
|
138
|
+
command: record.command,
|
|
139
|
+
cwd: record.cwd,
|
|
140
|
+
startedAt: record.startedAt,
|
|
141
|
+
lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
|
|
142
|
+
legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function readBridgeLockFile(): BridgeLockPayload | null {
|
|
147
|
+
try {
|
|
148
|
+
if (!fs.existsSync(BRIDGE_LOCK_FILE)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return normalizeBridgeLockPayload(JSON.parse(fs.readFileSync(BRIDGE_LOCK_FILE, "utf-8")));
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function shouldAutoReclaimBridgeLock(
|
|
158
|
+
lock: BridgeLockPayload,
|
|
159
|
+
isProcessAlive: (pid: number) => boolean = isPidAlive,
|
|
160
|
+
): boolean {
|
|
161
|
+
if (
|
|
162
|
+
lock.lifecycle === "companion_bound" ||
|
|
163
|
+
(lock.legacyLifecycleFallback === true && lock.adapter === "codex")
|
|
164
|
+
) {
|
|
165
|
+
return lock.parentPid > 1 && !isProcessAlive(lock.parentPid);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// For persistent lifecycle (e.g. opencode), reclaim the lock if the
|
|
169
|
+
// lock-holding process is no longer alive. This prevents stale locks
|
|
170
|
+
// from permanently blocking subsequent bridge starts when the process
|
|
171
|
+
// was force-killed (SIGKILL, Task Manager, OOM, etc.).
|
|
172
|
+
return !isProcessAlive(lock.pid);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function evaluateBridgeRuntimeOwnership(params: {
|
|
176
|
+
currentInstanceId: string;
|
|
177
|
+
currentPid: number;
|
|
178
|
+
workspaceStateInstanceId?: string;
|
|
179
|
+
lock: BridgeLockPayload | null;
|
|
180
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
181
|
+
}): BridgeRuntimeOwnership {
|
|
182
|
+
const isProcessAlive = params.isProcessAlive ?? isPidAlive;
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
params.workspaceStateInstanceId &&
|
|
186
|
+
params.workspaceStateInstanceId !== params.currentInstanceId
|
|
187
|
+
) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
reason: "superseded",
|
|
191
|
+
activeInstanceId: params.workspaceStateInstanceId,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
params.lock &&
|
|
197
|
+
params.lock.pid === params.currentPid &&
|
|
198
|
+
params.lock.instanceId === params.currentInstanceId
|
|
199
|
+
) {
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
rehydratedLock: false,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
params.lock &&
|
|
208
|
+
params.lock.instanceId !== params.currentInstanceId &&
|
|
209
|
+
isProcessAlive(params.lock.pid)
|
|
210
|
+
) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
reason: "lock_conflict",
|
|
214
|
+
activeInstanceId: params.lock.instanceId,
|
|
215
|
+
activePid: params.lock.pid,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
rehydratedLock: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildLockConflictError(lock: BridgeLockPayload): Error {
|
|
226
|
+
return new Error(
|
|
227
|
+
`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.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function tryTerminateOrphanedBridge(lock: BridgeLockPayload): boolean {
|
|
232
|
+
try {
|
|
233
|
+
process.kill(lock.pid);
|
|
234
|
+
} catch {
|
|
235
|
+
if (isPidAlive(lock.pid)) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return waitForProcessExitSync(lock.pid, ORPHAN_LOCK_RECLAIM_TIMEOUT_MS);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export class BridgeStateStore {
|
|
244
|
+
private state: BridgeState;
|
|
245
|
+
private readonly lockPayload: BridgeLockPayload;
|
|
246
|
+
private readonly bridgeStartedAtMs: number;
|
|
247
|
+
private readonly instanceId: string;
|
|
248
|
+
private readonly stateFilePath: string;
|
|
249
|
+
|
|
250
|
+
constructor(options: BridgeStateOptions) {
|
|
251
|
+
ensureChannelDataDir();
|
|
252
|
+
this.stateFilePath = ensureWorkspaceChannelDir(options.cwd).stateFile;
|
|
253
|
+
this.bridgeStartedAtMs = Date.now();
|
|
254
|
+
this.instanceId = buildInstanceId();
|
|
255
|
+
this.lockPayload = {
|
|
256
|
+
pid: process.pid,
|
|
257
|
+
parentPid: process.ppid,
|
|
258
|
+
instanceId: this.instanceId,
|
|
259
|
+
adapter: options.adapter,
|
|
260
|
+
command: options.command,
|
|
261
|
+
cwd: options.cwd,
|
|
262
|
+
startedAt: new Date(this.bridgeStartedAtMs).toISOString(),
|
|
263
|
+
lifecycle: options.lifecycle,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
this.acquireLock();
|
|
267
|
+
|
|
268
|
+
const persisted = this.readStateFile();
|
|
269
|
+
const persistedSharedSessionId =
|
|
270
|
+
persisted?.cwd === options.cwd
|
|
271
|
+
? persisted.sharedSessionId ?? persisted.sharedThreadId
|
|
272
|
+
: undefined;
|
|
273
|
+
const persistedResumeConversationId =
|
|
274
|
+
options.adapter === "claude" &&
|
|
275
|
+
persisted?.cwd === options.cwd &&
|
|
276
|
+
typeof persisted.resumeConversationId === "string"
|
|
277
|
+
? persisted.resumeConversationId
|
|
278
|
+
: undefined;
|
|
279
|
+
const persistedTranscriptPath =
|
|
280
|
+
options.adapter === "claude" &&
|
|
281
|
+
persisted?.cwd === options.cwd &&
|
|
282
|
+
typeof persisted.transcriptPath === "string"
|
|
283
|
+
? persisted.transcriptPath
|
|
284
|
+
: undefined;
|
|
285
|
+
this.state = {
|
|
286
|
+
instanceId: this.instanceId,
|
|
287
|
+
adapter: options.adapter,
|
|
288
|
+
command: options.command,
|
|
289
|
+
cwd: options.cwd,
|
|
290
|
+
profile: options.profile,
|
|
291
|
+
authorizedUserId: options.authorizedUserId,
|
|
292
|
+
bridgeStartedAtMs: this.bridgeStartedAtMs,
|
|
293
|
+
ignoredBacklogCount: 0,
|
|
294
|
+
sharedSessionId: persistedSharedSessionId,
|
|
295
|
+
sharedThreadId:
|
|
296
|
+
options.adapter === "codex" ? persistedSharedSessionId : undefined,
|
|
297
|
+
resumeConversationId: persistedResumeConversationId,
|
|
298
|
+
transcriptPath: persistedTranscriptPath,
|
|
299
|
+
lastActivityAt: persisted?.lastActivityAt,
|
|
300
|
+
pendingConfirmation: null,
|
|
301
|
+
pendingUserInput: null,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
this.save();
|
|
305
|
+
|
|
306
|
+
if (persisted?.pendingConfirmation) {
|
|
307
|
+
this.appendLog("Cleared stale pending confirmation from previous bridge session.");
|
|
308
|
+
}
|
|
309
|
+
if (persisted?.pendingUserInput) {
|
|
310
|
+
this.appendLog("Cleared stale pending user input request from previous bridge session.");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getState(): BridgeState {
|
|
315
|
+
return cloneState(this.state);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
touchActivity(timestamp = new Date().toISOString()): void {
|
|
319
|
+
this.state.lastActivityAt = timestamp;
|
|
320
|
+
this.save();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
setPendingConfirmation(pending: PendingApproval): void {
|
|
324
|
+
this.state.pendingConfirmation = pending;
|
|
325
|
+
this.save();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
clearPendingConfirmation(): void {
|
|
329
|
+
if (!this.state.pendingConfirmation) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.state.pendingConfirmation = null;
|
|
333
|
+
this.save();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setPendingUserInput(pending: PendingUserInputRequest): void {
|
|
337
|
+
this.state.pendingUserInput = pending;
|
|
338
|
+
this.save();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
clearPendingUserInput(): void {
|
|
342
|
+
if (!this.state.pendingUserInput) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.state.pendingUserInput = null;
|
|
346
|
+
this.save();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
incrementIgnoredBacklog(count = 1): void {
|
|
350
|
+
this.state.ignoredBacklogCount += count;
|
|
351
|
+
this.save();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
setSharedSessionId(sessionId: string): void {
|
|
355
|
+
this.state.sharedSessionId = sessionId;
|
|
356
|
+
this.state.sharedThreadId = this.state.adapter === "codex" ? sessionId : undefined;
|
|
357
|
+
this.save();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
setSharedThreadId(threadId: string): void {
|
|
361
|
+
this.setSharedSessionId(threadId);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
clearSharedSessionId(): void {
|
|
365
|
+
if (!this.state.sharedSessionId && !this.state.sharedThreadId) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
this.state.sharedSessionId = undefined;
|
|
369
|
+
this.state.sharedThreadId = undefined;
|
|
370
|
+
this.save();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
clearSharedThreadId(): void {
|
|
374
|
+
this.clearSharedSessionId();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
setClaudeResumeState(resumeConversationId?: string, transcriptPath?: string): void {
|
|
378
|
+
if (this.state.adapter !== "claude") {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this.state.resumeConversationId = resumeConversationId || undefined;
|
|
383
|
+
this.state.transcriptPath = transcriptPath || undefined;
|
|
384
|
+
this.save();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
clearClaudeResumeState(): void {
|
|
388
|
+
if (
|
|
389
|
+
this.state.adapter !== "claude" ||
|
|
390
|
+
(!this.state.resumeConversationId && !this.state.transcriptPath)
|
|
391
|
+
) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.state.resumeConversationId = undefined;
|
|
396
|
+
this.state.transcriptPath = undefined;
|
|
397
|
+
this.save();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
appendLog(message: string): void {
|
|
401
|
+
ensureChannelDataDir();
|
|
402
|
+
fs.appendFileSync(
|
|
403
|
+
BRIDGE_LOG_FILE,
|
|
404
|
+
`[${new Date().toISOString()}] ${message}\n`,
|
|
405
|
+
"utf-8",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
releaseLock(): void {
|
|
410
|
+
try {
|
|
411
|
+
const currentLock = readBridgeLockFile();
|
|
412
|
+
if (currentLock?.pid === process.pid) {
|
|
413
|
+
fs.rmSync(BRIDGE_LOCK_FILE, { force: true });
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// Best effort cleanup.
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private save(): void {
|
|
421
|
+
ensureChannelDataDir();
|
|
422
|
+
fs.writeFileSync(this.stateFilePath, JSON.stringify(this.state, null, 2), "utf-8");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private acquireLock(): void {
|
|
426
|
+
const existing = readBridgeLockFile();
|
|
427
|
+
if (
|
|
428
|
+
existing &&
|
|
429
|
+
existing.pid !== process.pid &&
|
|
430
|
+
isPidAlive(existing.pid)
|
|
431
|
+
) {
|
|
432
|
+
if (shouldAutoReclaimBridgeLock(existing)) {
|
|
433
|
+
this.appendLog(
|
|
434
|
+
`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
if (tryTerminateOrphanedBridge(existing)) {
|
|
438
|
+
this.appendLog(
|
|
439
|
+
`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`,
|
|
440
|
+
);
|
|
441
|
+
} else {
|
|
442
|
+
this.appendLog(
|
|
443
|
+
`lock_reclaim_failed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`,
|
|
444
|
+
);
|
|
445
|
+
throw buildLockConflictError(existing);
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
this.appendLog(
|
|
449
|
+
`lock_conflict: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`,
|
|
450
|
+
);
|
|
451
|
+
throw buildLockConflictError(existing);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
fs.writeFileSync(
|
|
456
|
+
BRIDGE_LOCK_FILE,
|
|
457
|
+
JSON.stringify(this.lockPayload, null, 2),
|
|
458
|
+
"utf-8",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
verifyRuntimeOwnership(): BridgeRuntimeOwnership {
|
|
463
|
+
const workspaceState = this.readWorkspaceStateFile();
|
|
464
|
+
const ownership = evaluateBridgeRuntimeOwnership({
|
|
465
|
+
currentInstanceId: this.instanceId,
|
|
466
|
+
currentPid: process.pid,
|
|
467
|
+
workspaceStateInstanceId:
|
|
468
|
+
typeof workspaceState?.instanceId === "string"
|
|
469
|
+
? workspaceState.instanceId
|
|
470
|
+
: undefined,
|
|
471
|
+
lock: readBridgeLockFile(),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
if (!ownership.ok) {
|
|
475
|
+
return ownership;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!workspaceState?.instanceId) {
|
|
479
|
+
this.save();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (ownership.rehydratedLock) {
|
|
483
|
+
fs.writeFileSync(
|
|
484
|
+
BRIDGE_LOCK_FILE,
|
|
485
|
+
JSON.stringify(this.lockPayload, null, 2),
|
|
486
|
+
"utf-8",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return ownership;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private readStateFile(): Partial<BridgeState> | null {
|
|
494
|
+
try {
|
|
495
|
+
if (!fs.existsSync(this.stateFilePath)) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return JSON.parse(
|
|
500
|
+
fs.readFileSync(this.stateFilePath, "utf-8"),
|
|
501
|
+
) as Partial<BridgeState>;
|
|
502
|
+
} catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private readWorkspaceStateFile(): Partial<BridgeState> | null {
|
|
508
|
+
try {
|
|
509
|
+
if (!fs.existsSync(this.stateFilePath)) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return JSON.parse(
|
|
514
|
+
fs.readFileSync(this.stateFilePath, "utf-8"),
|
|
515
|
+
) as Partial<BridgeState>;
|
|
516
|
+
} catch {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|