@unlinearity/cli-wechat-bridge 1.1.3 → 1.1.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 (51) hide show
  1. package/README.md +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +5 -12
  40. package/scripts/ensure-node-pty-permissions.mjs +73 -0
  41. package/bin/wechat-bridge-claude.mjs +0 -5
  42. package/bin/wechat-bridge-codex.mjs +0 -5
  43. package/bin/wechat-bridge-opencode.mjs +0 -5
  44. package/bin/wechat-bridge-pi.mjs +0 -5
  45. package/bin/wechat-bridge-shell.mjs +0 -5
  46. package/bin/wechat-bridge.mjs +0 -5
  47. package/bin/wechat-claude-start.mjs +0 -5
  48. package/bin/wechat-codex-start.mjs +0 -5
  49. package/bin/wechat-opencode-start.mjs +0 -5
  50. package/bin/wechat-pi-start.mjs +0 -5
  51. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -16,7 +16,7 @@ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".
16
16
  const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
17
17
  const DEFAULT_ADAPTER = "codex";
18
18
  function log(adapter, message) {
19
- process.stderr.write(`[wechat-${adapter}-start] ${message}\n`);
19
+ process.stderr.write(`[wechat-${adapter}] ${message}\n`);
20
20
  }
21
21
  export function normalizeComparablePath(cwd) {
22
22
  const normalized = path.resolve(cwd);
@@ -61,7 +61,8 @@ export function decideLaunchAction(input) {
61
61
  if (input.sessionStartMode === "new" &&
62
62
  (input.requestedAdapter === "claude" ||
63
63
  input.requestedAdapter === "opencode" ||
64
- input.requestedAdapter === "pi")) {
64
+ input.requestedAdapter === "pi" ||
65
+ input.requestedAdapter === "codex")) {
65
66
  return {
66
67
  kind: "start_bridge",
67
68
  message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
@@ -106,22 +107,35 @@ export function parseCliArgs(argv) {
106
107
  }
107
108
  const next = argv[i + 1];
108
109
  if (arg === "--help" || arg === "-h") {
110
+ const helpAdapter = adapter;
111
+ const commandName = `wechat-${helpAdapter}`;
112
+ const adapterLabel = {
113
+ codex: "Codex",
114
+ claude: "Claude Code",
115
+ opencode: "OpenCode",
116
+ pi: "Pi",
117
+ }[helpAdapter];
109
118
  process.stdout.write([
110
- "Usage: wechat-codex-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...codex args]",
111
- " wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
112
- " wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
113
- " wechat-pi-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...pi args]",
114
- " local-companion-start [--adapter <codex|claude|opencode|pi>] [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...cli args]",
119
+ `Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
115
120
  "",
116
- "Starts a bridge for the current directory, waits for the local endpoint, then opens the visible companion or panel.",
117
- "Claude, OpenCode, and Pi launchers start a fresh CLI session by default.",
118
- "Use --session-start-mode restore to explicitly restore the previous session.",
119
- "All adapters are companion-bound: closing the companion/panel also stops the bridge.",
121
+ "Ensures the WeChat runtime for the current directory, then opens or reuses the visible CLI.",
122
+ "If a same-workspace daemon is running, the command delegates to that daemon.",
123
+ helpAdapter === "codex"
124
+ ? "Codex restores the current session by default."
125
+ : `${adapterLabel} starts a fresh session by default.`,
126
+ helpAdapter === "codex"
127
+ ? "Use --session-start-mode new to explicitly start a fresh session."
128
+ : "Use --session-start-mode restore to explicitly restore the previous session.",
129
+ "Use --doctor to check the selected adapter and workspace without starting it.",
130
+ "Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
120
131
  "Unknown arguments are forwarded to the visible CLI client.",
121
132
  "",
122
133
  ].join("\n"));
123
134
  process.exit(0);
124
135
  }
136
+ if (arg === "--doctor") {
137
+ continue;
138
+ }
125
139
  if (arg === "--adapter") {
126
140
  if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
127
141
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
@@ -375,6 +389,12 @@ async function ensureBridgeReady(options) {
375
389
  await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
376
390
  return { shouldOpenVisibleClient: true };
377
391
  }
392
+ // The daemon's ensure_slot can legitimately take a while: opening a visible
393
+ // client waits up to 15s for it to connect, and codex waits another 15s for
394
+ // the visible thread — all serialized per adapter. The default 2s IPC timeout
395
+ // would expire on nearly every cold start, so delegation uses a generous
396
+ // deadline and falls back to a local bridge when the daemon cannot deliver.
397
+ const ENSURE_SLOT_DELEGATION_TIMEOUT_MS = 45_000;
378
398
  export async function tryDelegateToDaemon(options, deps = {}) {
379
399
  const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
380
400
  const isEndpointAlive = deps.isEndpointAlive ?? isDaemonEndpointAlive;
@@ -400,9 +420,14 @@ export async function tryDelegateToDaemon(options, deps = {}) {
400
420
  openVisible: true,
401
421
  sessionStartMode: options.sessionStartMode,
402
422
  reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
403
- });
423
+ }, { timeoutMs: ENSURE_SLOT_DELEGATION_TIMEOUT_MS });
404
424
  if (!response.ok) {
405
- throw new Error(response.error);
425
+ // A daemon that cannot answer in time (or at all) must not abort the
426
+ // launch: fall back to a local companion-bound bridge so the user still
427
+ // gets a working terminal instead of an error for work the daemon may
428
+ // still complete.
429
+ log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
430
+ return false;
406
431
  }
407
432
  log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
408
433
  return true;
@@ -414,6 +439,7 @@ export async function runVisibleClient(options, runners = {}) {
414
439
  return await (runners.codexRemoteClient ?? runCodexRemoteClient)({
415
440
  cwd: options.cwd,
416
441
  cliArgs: options.cliArgs,
442
+ sessionStartMode: options.sessionStartMode,
417
443
  });
418
444
  }
419
445
  return await (runners.localCompanion ?? runLocalCompanion)({
@@ -431,6 +457,11 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
431
457
  });
432
458
  }
433
459
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
460
+ if (argv.includes("--doctor")) {
461
+ const { runDoctorCheck } = await import("../utils/doctor.js");
462
+ await runDoctorCheck(argv, { mode: "bridge" });
463
+ return 0;
464
+ }
434
465
  const options = parseCliArgs(argv);
435
466
  await ensureCompanionStartWechatCredentials(options.adapter);
436
467
  if (await tryDelegateToDaemon(options)) {
@@ -23,9 +23,9 @@ function parseCliArgs(argv) {
23
23
  const next = argv[i + 1];
24
24
  if (arg === "--help" || arg === "-h") {
25
25
  process.stdout.write([
26
- "Usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
26
+ "Internal companion usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
27
27
  "",
28
- 'Starts the visible local companion and connects it to the matching running bridge for the current directory.',
28
+ "Connects a visible local CLI to the matching bridge runtime for the current directory.",
29
29
  "Unknown arguments are forwarded to the visible CLI client.",
30
30
  "",
31
31
  ].join("\n"));
@@ -73,7 +73,7 @@ function readMatchingEndpoint(options) {
73
73
  adapter: options.adapter,
74
74
  });
75
75
  if (!endpoint || endpoint.kind !== options.adapter) {
76
- throw new Error(`No active ${options.adapter} bridge endpoint was found for ${options.cwd}. Start "wechat-bridge-${options.adapter}" in that directory first.`);
76
+ throw new Error(`No active ${options.adapter} runtime was found for ${options.cwd}. Run "wechat-${options.adapter}" in that directory to recreate it.`);
77
77
  }
78
78
  return endpoint;
79
79
  }
@@ -241,6 +241,9 @@ export async function runLocalCompanion(options) {
241
241
  case "resolve_approval":
242
242
  sendResponse(socket, message.id, true, await adapter.resolveApproval(message.payload.action));
243
243
  break;
244
+ case "submit_user_input":
245
+ sendResponse(socket, message.id, true, await adapter.submitUserInput(message.payload.answers));
246
+ break;
244
247
  case "dispose":
245
248
  sendResponse(socket, message.id, true);
246
249
  await closeCompanion(0, "bridge_dispose");
@@ -0,0 +1,73 @@
1
+ import net from "node:net";
2
+ const ROUTE_POLL_INTERVAL_MS = 100;
3
+ const MAX_BUFFER_SIZE = 1024 * 1024;
4
+ function extractSessionId(route) {
5
+ if (route.name === "session" && typeof route.params?.sessionID === "string") {
6
+ return route.params.sessionID;
7
+ }
8
+ if (route.type === "session" && typeof route.sessionID === "string") {
9
+ return route.sessionID;
10
+ }
11
+ return null;
12
+ }
13
+ export default {
14
+ id: "cli-wechat-bridge.session-route",
15
+ tui: async (api, options) => {
16
+ const port = Number(options?.port);
17
+ const token = typeof options?.token === "string" ? options.token : "";
18
+ if (!Number.isInteger(port) || port <= 0 || !token) {
19
+ return;
20
+ }
21
+ const socket = net.connect({ host: "127.0.0.1", port });
22
+ let connected = false;
23
+ let lastSessionId;
24
+ let queuedFrame = null;
25
+ const writeFrame = (frame) => {
26
+ if (socket.destroyed) {
27
+ return;
28
+ }
29
+ if (!connected) {
30
+ queuedFrame = frame;
31
+ return;
32
+ }
33
+ socket.write(`${JSON.stringify(frame)}\n`);
34
+ };
35
+ const publishRoute = () => {
36
+ const sessionId = extractSessionId(api.route.current);
37
+ if (sessionId === lastSessionId) {
38
+ return;
39
+ }
40
+ lastSessionId = sessionId;
41
+ writeFrame({
42
+ type: "route_state",
43
+ sessionId,
44
+ observedAt: new Date().toISOString(),
45
+ });
46
+ };
47
+ socket.setEncoding("utf8");
48
+ socket.on("connect", () => {
49
+ socket.setNoDelay(true);
50
+ socket.write(`${JSON.stringify({ type: "hello", token })}\n`);
51
+ connected = true;
52
+ publishRoute();
53
+ if (queuedFrame) {
54
+ socket.write(`${JSON.stringify(queuedFrame)}\n`);
55
+ queuedFrame = null;
56
+ }
57
+ });
58
+ socket.on("data", (chunk) => {
59
+ if (chunk.length > MAX_BUFFER_SIZE) {
60
+ socket.destroy();
61
+ }
62
+ });
63
+ socket.on("error", () => {
64
+ // Keep the native TUI usable if the bridge-side observer disappears.
65
+ });
66
+ const timer = setInterval(publishRoute, ROUTE_POLL_INTERVAL_MS);
67
+ timer.unref?.();
68
+ api.lifecycle.onDispose(() => {
69
+ clearInterval(timer);
70
+ socket.destroy();
71
+ });
72
+ },
73
+ };
@@ -1,4 +1,6 @@
1
+ import fs from "node:fs";
1
2
  import net from "node:net";
3
+ import path from "node:path";
2
4
  const BRIDGE_HOST = "127.0.0.1";
3
5
  const INTERNAL_NEW_COMMAND = "__cli_bridge_new";
4
6
  const INTERNAL_SWITCH_COMMAND = "__cli_bridge_switch";
@@ -110,8 +112,36 @@ export default function piTuiBridgeExtension(pi) {
110
112
  return;
111
113
  }
112
114
  try {
115
+ if (!context.isIdle()) {
116
+ throw new Error("Pi TUI is already processing a turn.");
117
+ }
118
+ let request;
119
+ try {
120
+ request = JSON.parse(parsed.value);
121
+ }
122
+ catch {
123
+ throw new Error("The Pi session switch request is malformed.");
124
+ }
125
+ if (typeof request.sessionPath !== "string" ||
126
+ typeof request.sessionId !== "string" ||
127
+ typeof request.cwd !== "string" ||
128
+ !fs.existsSync(request.sessionPath)) {
129
+ throw new Error("The requested Pi session is no longer available.");
130
+ }
131
+ const firstLine = fs
132
+ .readFileSync(request.sessionPath, "utf8")
133
+ .split(/\r?\n/)
134
+ .find((line) => line.trim());
135
+ const header = firstLine ? JSON.parse(firstLine) : null;
136
+ if (!isRecord(header) ||
137
+ header.type !== "session" ||
138
+ header.id !== request.sessionId ||
139
+ typeof header.cwd !== "string" ||
140
+ path.resolve(header.cwd) !== path.resolve(request.cwd)) {
141
+ throw new Error("The requested Pi session no longer matches this workspace.");
142
+ }
113
143
  let sessionState = {};
114
- const result = await context.switchSession(parsed.value, {
144
+ const result = await context.switchSession(request.sessionPath, {
115
145
  withSession: async (nextContext) => {
116
146
  writeSessionState(nextContext, "resume");
117
147
  sessionState = {
@@ -157,8 +187,17 @@ export default function piTuiBridgeExtension(pi) {
157
187
  }
158
188
  else if (command.type === "switch_session" &&
159
189
  typeof command.id === "string" &&
160
- typeof command.sessionPath === "string") {
161
- pi.sendUserMessage(`/${INTERNAL_SWITCH_COMMAND} ${command.id} ${command.sessionPath}`, { expandPromptTemplates: true });
190
+ typeof command.sessionPath === "string" &&
191
+ typeof command.sessionId === "string" &&
192
+ typeof command.cwd === "string") {
193
+ if (!latestContext?.isIdle()) {
194
+ throw new Error("Pi TUI is already processing a turn.");
195
+ }
196
+ pi.sendUserMessage(`/${INTERNAL_SWITCH_COMMAND} ${command.id} ${JSON.stringify({
197
+ sessionPath: command.sessionPath,
198
+ sessionId: command.sessionId,
199
+ cwd: command.cwd,
200
+ })}`, { expandPromptTemplates: true });
162
201
  }
163
202
  }
164
203
  catch (error) {
@@ -65,8 +65,12 @@ export function isPidAlive(pid) {
65
65
  process.kill(pid, 0);
66
66
  return true;
67
67
  }
68
- catch {
69
- return false;
68
+ catch (error) {
69
+ // EPERM means the OS found the pid but denied access (e.g. a Windows
70
+ // process running under another privilege level). The process exists, so
71
+ // it must be treated as alive — returning false here would let callers
72
+ // delete a live daemon's endpoint file and break its singleton.
73
+ return error instanceof Error && error.code === "EPERM";
70
74
  }
71
75
  }
72
76
  export function sendDaemonResponse(socket, id, response) {
@@ -170,6 +174,11 @@ export async function sendDaemonRequest(endpoint, payload, options = {}) {
170
174
  socket.once("error", () => {
171
175
  finish({ ok: false, error: "Daemon endpoint is not reachable." });
172
176
  });
177
+ socket.once("close", () => {
178
+ // The daemon may die or be taken over mid-request; without this the
179
+ // caller waits out the full timeout for a socket that is already gone.
180
+ finish({ ok: false, error: "Daemon connection closed before a response." });
181
+ });
173
182
  });
174
183
  }
175
184
  export async function isDaemonEndpointAlive(endpoint, options = {}) {
@@ -177,5 +186,18 @@ export async function isDaemonEndpointAlive(endpoint, options = {}) {
177
186
  return false;
178
187
  }
179
188
  const response = await sendDaemonRequest(endpoint, { command: "status" }, options);
180
- return response.ok;
189
+ if (response.ok) {
190
+ return true;
191
+ }
192
+ // A momentarily busy daemon (e.g. a synchronous session-directory scan
193
+ // blocking its event loop) can miss the short status deadline. Retry once
194
+ // before declaring it dead: a false negative makes callers delete a live
195
+ // daemon's endpoint file and break its singleton.
196
+ const timedOut = (response.error ?? "").includes("Timed out");
197
+ if (!timedOut) {
198
+ return false;
199
+ }
200
+ await new Promise((resolve) => setTimeout(resolve, 250));
201
+ const retry = await sendDaemonRequest(endpoint, { command: "status" }, options);
202
+ return retry.ok;
181
203
  }