@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.
Files changed (49) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +531 -0
  3. package/bin/_run-entry.mjs +41 -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-opencode-start.mjs +5 -0
  15. package/bin/wechat-opencode.mjs +5 -0
  16. package/package.json +86 -0
  17. package/src/bridge/bridge-adapters.claude.ts +1047 -0
  18. package/src/bridge/bridge-adapters.codex.ts +2733 -0
  19. package/src/bridge/bridge-adapters.core.ts +877 -0
  20. package/src/bridge/bridge-adapters.opencode.ts +2390 -0
  21. package/src/bridge/bridge-adapters.shared.ts +1647 -0
  22. package/src/bridge/bridge-adapters.shell.ts +456 -0
  23. package/src/bridge/bridge-adapters.ts +30 -0
  24. package/src/bridge/bridge-controller.ts +58 -0
  25. package/src/bridge/bridge-final-reply.ts +56 -0
  26. package/src/bridge/bridge-process-reaper.ts +417 -0
  27. package/src/bridge/bridge-state.ts +520 -0
  28. package/src/bridge/bridge-types.ts +218 -0
  29. package/src/bridge/bridge-utils.ts +1524 -0
  30. package/src/bridge/claude-hook.ts +92 -0
  31. package/src/bridge/claude-hooks.ts +376 -0
  32. package/src/bridge/wechat-bridge.ts +1305 -0
  33. package/src/commands/check-update.ts +46 -0
  34. package/src/companion/codex-panel-link.ts +151 -0
  35. package/src/companion/codex-panel.ts +225 -0
  36. package/src/companion/codex-remote-client.ts +178 -0
  37. package/src/companion/local-companion-link.ts +338 -0
  38. package/src/companion/local-companion-start.ts +520 -0
  39. package/src/companion/local-companion.ts +532 -0
  40. package/src/media/media-types.ts +11 -0
  41. package/src/runtime/create-runtime-host.ts +16 -0
  42. package/src/runtime/legacy-adapter-runtime.ts +55 -0
  43. package/src/runtime/runtime-types.ts +51 -0
  44. package/src/utils/version-checker.ts +197 -0
  45. package/src/wechat/channel-config.ts +141 -0
  46. package/src/wechat/setup.ts +232 -0
  47. package/src/wechat/standalone-bot.ts +483 -0
  48. package/src/wechat/wechat-channel.ts +608 -0
  49. 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
+ }