@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,417 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export type BridgeProcessRecord = {
4
+ pid: number;
5
+ parentPid?: number;
6
+ name?: string;
7
+ commandLine: string;
8
+ };
9
+
10
+ const PEER_BRIDGE_EXIT_TIMEOUT_MS = 4_000;
11
+ const PEER_BRIDGE_EXIT_POLL_MS = 100;
12
+
13
+ function isPidAlive(pid: number): boolean {
14
+ if (!Number.isInteger(pid) || pid <= 0) {
15
+ return false;
16
+ }
17
+
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function sleep(ms: number): Promise<void> {
27
+ if (ms <= 0) {
28
+ return Promise.resolve();
29
+ }
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ export function isWechatBridgeCommandLine(commandLine: string): boolean {
38
+ return (
39
+ /wechat-bridge\.(?:ts|js|mjs)/i.test(commandLine) &&
40
+ /(?:^|\s)--adapter(?:\s|=)/i.test(commandLine)
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Detects `opencode serve` command lines spawned by the bridge adapter.
46
+ * Matches patterns like:
47
+ * opencode serve --port 12345 --hostname 127.0.0.1
48
+ * opencode.exe serve --port 12345
49
+ * Also matches wrapper invocations (opencode.cmd / opencode.bat) on Windows.
50
+ */
51
+ export function isOpencodeServeCommandLine(commandLine: string): boolean {
52
+ return /\bopencode(?:\.exe|\.cmd|\.bat)?\b.*\bserve\b/i.test(commandLine);
53
+ }
54
+
55
+ /**
56
+ * Detects `opencode attach` command lines spawned by the local OpenCode companion.
57
+ * Matches patterns like:
58
+ * opencode attach http://127.0.0.1:12345
59
+ * opencode.exe attach http://127.0.0.1:12345 --session ses_123
60
+ */
61
+ export function isOpencodeAttachCommandLine(commandLine: string): boolean {
62
+ return /\bopencode(?:\.exe|\.cmd|\.bat)?\b.*\battach\b/i.test(commandLine);
63
+ }
64
+
65
+ function normalizeBridgeProcessRecord(value: unknown): BridgeProcessRecord | null {
66
+ if (!isRecord(value)) {
67
+ return null;
68
+ }
69
+
70
+ const pid =
71
+ typeof value.ProcessId === "number"
72
+ ? value.ProcessId
73
+ : typeof value.pid === "number"
74
+ ? value.pid
75
+ : Number.NaN;
76
+ const commandLine =
77
+ typeof value.CommandLine === "string"
78
+ ? value.CommandLine
79
+ : typeof value.commandLine === "string"
80
+ ? value.commandLine
81
+ : "";
82
+ if (!Number.isInteger(pid) || pid <= 0 || !commandLine) {
83
+ return null;
84
+ }
85
+
86
+ const record: BridgeProcessRecord = {
87
+ pid,
88
+ commandLine,
89
+ };
90
+ const parentPid =
91
+ typeof value.ParentProcessId === "number"
92
+ ? value.ParentProcessId
93
+ : typeof value.parentPid === "number"
94
+ ? value.parentPid
95
+ : undefined;
96
+ if (typeof parentPid === "number" && Number.isInteger(parentPid) && parentPid > 0) {
97
+ record.parentPid = parentPid;
98
+ }
99
+ const name =
100
+ typeof value.Name === "string"
101
+ ? value.Name
102
+ : typeof value.name === "string"
103
+ ? value.name
104
+ : undefined;
105
+ if (name) {
106
+ record.name = name;
107
+ }
108
+
109
+ return record;
110
+ }
111
+
112
+ export function parseWindowsBridgeProcessProbeOutput(
113
+ stdout: string,
114
+ currentPid = process.pid,
115
+ ): BridgeProcessRecord[] {
116
+ const trimmed = stdout.trim();
117
+ if (!trimmed) {
118
+ return [];
119
+ }
120
+
121
+ let parsed: unknown;
122
+ try {
123
+ parsed = JSON.parse(trimmed);
124
+ } catch {
125
+ return [];
126
+ }
127
+
128
+ const values = Array.isArray(parsed) ? parsed : [parsed];
129
+ return values
130
+ .map(normalizeBridgeProcessRecord)
131
+ .filter((record): record is BridgeProcessRecord => Boolean(record))
132
+ .filter(
133
+ (record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine),
134
+ );
135
+ }
136
+
137
+ export function parsePosixBridgeProcessProbeOutput(
138
+ stdout: string,
139
+ currentPid = process.pid,
140
+ ): BridgeProcessRecord[] {
141
+ return stdout
142
+ .split(/\r?\n/)
143
+ .map((line) => line.trim())
144
+ .filter(Boolean)
145
+ .map((line) => {
146
+ const match = /^(\d+)\s+(.*)$/.exec(line);
147
+ if (!match) {
148
+ return null;
149
+ }
150
+ const pid = Number(match[1]);
151
+ const commandLine = match[2] ?? "";
152
+ if (!Number.isInteger(pid) || pid <= 0 || !commandLine) {
153
+ return null;
154
+ }
155
+ return {
156
+ pid,
157
+ commandLine,
158
+ } satisfies BridgeProcessRecord;
159
+ })
160
+ .filter((record): record is BridgeProcessRecord => Boolean(record))
161
+ .filter(
162
+ (record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine),
163
+ );
164
+ }
165
+
166
+ function listWindowsBridgeProcesses(currentPid = process.pid): BridgeProcessRecord[] {
167
+ const probe = spawnSync(
168
+ "powershell.exe",
169
+ [
170
+ "-NoLogo",
171
+ "-NoProfile",
172
+ "-NonInteractive",
173
+ "-ExecutionPolicy",
174
+ "Bypass",
175
+ "-Command",
176
+ [
177
+ "$ErrorActionPreference='Stop'",
178
+ "Get-CimInstance Win32_Process",
179
+ "| Where-Object { $_.CommandLine }",
180
+ "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
181
+ "| ConvertTo-Json -Compress",
182
+ ].join(" "),
183
+ ],
184
+ {
185
+ encoding: "utf8",
186
+ windowsHide: true,
187
+ timeout: 8_000,
188
+ },
189
+ );
190
+
191
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
192
+ return [];
193
+ }
194
+
195
+ return parseWindowsBridgeProcessProbeOutput(probe.stdout, currentPid);
196
+ }
197
+
198
+ function listPosixBridgeProcesses(currentPid = process.pid): BridgeProcessRecord[] {
199
+ const probe = spawnSync(
200
+ "ps",
201
+ ["-ax", "-o", "pid=", "-o", "command="],
202
+ {
203
+ encoding: "utf8",
204
+ timeout: 8_000,
205
+ },
206
+ );
207
+
208
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
209
+ return [];
210
+ }
211
+
212
+ return parsePosixBridgeProcessProbeOutput(probe.stdout, currentPid);
213
+ }
214
+
215
+ export function listPeerBridgeProcesses(currentPid = process.pid): BridgeProcessRecord[] {
216
+ return process.platform === "win32"
217
+ ? listWindowsBridgeProcesses(currentPid)
218
+ : listPosixBridgeProcesses(currentPid);
219
+ }
220
+
221
+ /**
222
+ * List all managed OpenCode child processes (`serve` and `attach`) whose parent
223
+ * PID is no longer alive. These are orphaned processes left behind when a bridge
224
+ * or local companion crashed or was killed without cleaning up its child
225
+ * OpenCode processes.
226
+ */
227
+ export function listOrphanedOpencodeProcesses(
228
+ currentPid = process.pid,
229
+ ): BridgeProcessRecord[] {
230
+ const all = listAllProcessesRaw(currentPid);
231
+ return all.filter((record) => {
232
+ if (
233
+ !isOpencodeServeCommandLine(record.commandLine) &&
234
+ !isOpencodeAttachCommandLine(record.commandLine)
235
+ ) {
236
+ return false;
237
+ }
238
+ if (!record.parentPid) {
239
+ return false;
240
+ }
241
+ if (record.parentPid === currentPid) {
242
+ return false;
243
+ }
244
+ return !isPidAlive(record.parentPid);
245
+ });
246
+ }
247
+
248
+ function listAllProcessesRaw(currentPid = process.pid): BridgeProcessRecord[] {
249
+ if (process.platform === "win32") {
250
+ const probe = spawnSync(
251
+ "powershell.exe",
252
+ [
253
+ "-NoLogo",
254
+ "-NoProfile",
255
+ "-NonInteractive",
256
+ "-ExecutionPolicy",
257
+ "Bypass",
258
+ "-Command",
259
+ [
260
+ "$ErrorActionPreference='Stop'",
261
+ "Get-CimInstance Win32_Process",
262
+ "| Where-Object { $_.CommandLine }",
263
+ "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
264
+ "| ConvertTo-Json -Compress",
265
+ ].join(" "),
266
+ ],
267
+ {
268
+ encoding: "utf8",
269
+ windowsHide: true,
270
+ timeout: 8_000,
271
+ },
272
+ );
273
+
274
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
275
+ return [];
276
+ }
277
+
278
+ const trimmed = probe.stdout.trim();
279
+ if (!trimmed) return [];
280
+ try {
281
+ const parsed = JSON.parse(trimmed);
282
+ const values = Array.isArray(parsed) ? parsed : [parsed];
283
+ return values
284
+ .map(normalizeBridgeProcessRecord)
285
+ .filter((r): r is BridgeProcessRecord => Boolean(r))
286
+ .filter((r) => r.pid !== currentPid);
287
+ } catch {
288
+ return [];
289
+ }
290
+ }
291
+
292
+ const probe = spawnSync("ps", ["-ax", "-o", "pid=", "-o", "ppid=", "-o", "command="], {
293
+ encoding: "utf8",
294
+ timeout: 8_000,
295
+ });
296
+
297
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
298
+ return [];
299
+ }
300
+
301
+ return probe.stdout
302
+ .split(/\r?\n/)
303
+ .map((line) => line.trim())
304
+ .filter(Boolean)
305
+ .map((line) => {
306
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line);
307
+ if (!match) return null;
308
+ const pid = Number(match[1]);
309
+ const parentPid = Number(match[2]);
310
+ const commandLine = match[3] ?? "";
311
+ if (!Number.isInteger(pid) || pid <= 0 || !commandLine) return null;
312
+ const record: BridgeProcessRecord = { pid, commandLine };
313
+ if (Number.isInteger(parentPid) && parentPid > 0) record.parentPid = parentPid;
314
+ return record;
315
+ })
316
+ .filter((r): r is BridgeProcessRecord => Boolean(r))
317
+ .filter((r) => r.pid !== currentPid);
318
+ }
319
+
320
+ async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
321
+ const deadline = Date.now() + timeoutMs;
322
+ while (Date.now() < deadline) {
323
+ if (!isPidAlive(pid)) {
324
+ return true;
325
+ }
326
+ await sleep(Math.min(PEER_BRIDGE_EXIT_POLL_MS, deadline - Date.now()));
327
+ }
328
+ return !isPidAlive(pid);
329
+ }
330
+
331
+ /**
332
+ * Synchronously kill a process and its entire descendant tree.
333
+ * On Windows uses `taskkill /T /F /PID` to recursively kill all descendants.
334
+ * On other platforms uses `process.kill(pid)` directly.
335
+ */
336
+ export function killProcessTreeSync(pid: number): void {
337
+ if (!Number.isInteger(pid) || pid <= 0) {
338
+ return;
339
+ }
340
+
341
+ if (process.platform === "win32") {
342
+ try {
343
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], {
344
+ windowsHide: true,
345
+ timeout: 5_000,
346
+ });
347
+ } catch {
348
+ try { process.kill(pid); } catch { /* best effort */ }
349
+ }
350
+ } else {
351
+ try { process.kill(pid); } catch { /* best effort */ }
352
+ }
353
+ }
354
+
355
+ export async function reapPeerBridgeProcesses(params: {
356
+ currentPid?: number;
357
+ logger?: (message: string) => void;
358
+ } = {}): Promise<number[]> {
359
+ const currentPid = params.currentPid ?? process.pid;
360
+ const peers = listPeerBridgeProcesses(currentPid);
361
+ const terminated: number[] = [];
362
+
363
+ for (const peer of peers) {
364
+ try {
365
+ params.logger?.(
366
+ `peer_bridge_reap_attempt: pid=${peer.pid}${peer.name ? ` name=${peer.name}` : ""} command=${peer.commandLine}`,
367
+ );
368
+ killProcessTreeSync(peer.pid);
369
+ if (await waitForProcessExit(peer.pid, PEER_BRIDGE_EXIT_TIMEOUT_MS)) {
370
+ terminated.push(peer.pid);
371
+ params.logger?.(`peer_bridge_reaped: pid=${peer.pid}`);
372
+ } else {
373
+ params.logger?.(`peer_bridge_reap_timeout: pid=${peer.pid}`);
374
+ }
375
+ } catch (error) {
376
+ const message = error instanceof Error ? error.message : String(error);
377
+ params.logger?.(`peer_bridge_reap_failed: pid=${peer.pid} error=${message}`);
378
+ }
379
+ }
380
+
381
+ return terminated;
382
+ }
383
+
384
+ /**
385
+ * Kill orphaned managed OpenCode processes (`serve` and `attach`) whose parent
386
+ * bridge/companion has already exited. These accumulate when a bridge crashes
387
+ * or is killed without cleaning up its child OpenCode processes (common on
388
+ * Windows where child processes are not automatically killed when the parent dies).
389
+ */
390
+ export async function reapOrphanedOpencodeProcesses(params: {
391
+ currentPid?: number;
392
+ logger?: (message: string) => void;
393
+ } = {}): Promise<number[]> {
394
+ const currentPid = params.currentPid ?? process.pid;
395
+ const orphans = listOrphanedOpencodeProcesses(currentPid);
396
+ const terminated: number[] = [];
397
+
398
+ for (const orphan of orphans) {
399
+ try {
400
+ params.logger?.(
401
+ `orphan_opencode_reap_attempt: pid=${orphan.pid}${orphan.name ? ` name=${orphan.name}` : ""} parentPid=${orphan.parentPid} command=${orphan.commandLine}`,
402
+ );
403
+ killProcessTreeSync(orphan.pid);
404
+ if (await waitForProcessExit(orphan.pid, PEER_BRIDGE_EXIT_TIMEOUT_MS)) {
405
+ terminated.push(orphan.pid);
406
+ params.logger?.(`orphan_opencode_reaped: pid=${orphan.pid}`);
407
+ } else {
408
+ params.logger?.(`orphan_opencode_reap_timeout: pid=${orphan.pid}`);
409
+ }
410
+ } catch (error) {
411
+ const message = error instanceof Error ? error.message : String(error);
412
+ params.logger?.(`orphan_opencode_reap_failed: pid=${orphan.pid} error=${message}`);
413
+ }
414
+ }
415
+
416
+ return terminated;
417
+ }