@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.
- package/README.md +110 -140
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +448 -11
- package/dist/bridge/bridge-adapters.codex.js +262 -11
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +497 -49
- package/dist/bridge/bridge-adapters.pi.js +44 -8
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-controller.js +3 -0
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-session-resume.js +151 -0
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +63 -229
- package/dist/bridge/claude-hooks.js +1 -0
- package/dist/bridge/wechat-bridge.js +93 -140
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +309 -36
- package/dist/companion/codex-visible-client-link.js +122 -0
- package/dist/companion/local-companion-link.js +33 -2
- package/dist/companion/local-companion-start.js +44 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
- package/dist/companion/pi-tui-bridge-extension.js +42 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +168 -56
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +5 -12
- package/scripts/ensure-node-pty-permissions.mjs +73 -0
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/bin/wechat-claude-start.mjs +0 -5
- package/bin/wechat-codex-start.mjs +0 -5
- package/bin/wechat-opencode-start.mjs +0 -5
- package/bin/wechat-pi-start.mjs +0 -5
- 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}
|
|
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
|
-
|
|
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
|
-
"
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
26
|
+
"Internal companion usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
|
|
27
27
|
"",
|
|
28
|
-
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|