cli-wechat-bridge 1.1.1 → 1.1.3
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 +60 -43
- package/bin/_run-entry.mjs +24 -0
- package/bin/wechat-bridge-pi.mjs +5 -0
- package/bin/wechat-pi-start.mjs +5 -0
- package/bin/wechat-pi.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +2 -0
- package/dist/bridge/bridge-adapters.claude.js +18 -0
- package/dist/bridge/bridge-adapters.codex.js +83 -2
- package/dist/bridge/bridge-adapters.core.js +36 -2
- package/dist/bridge/bridge-adapters.js +5 -0
- package/dist/bridge/bridge-adapters.opencode.js +278 -32
- package/dist/bridge/bridge-adapters.pi.js +697 -0
- package/dist/bridge/bridge-adapters.shared.js +44 -46
- package/dist/bridge/bridge-final-reply.js +8 -4
- package/dist/bridge/bridge-process-reaper.js +58 -3
- package/dist/bridge/bridge-state.js +15 -6
- package/dist/bridge/bridge-utils.js +129 -30
- package/dist/bridge/claude-hook.js +7 -0
- package/dist/bridge/claude-hooks.js +10 -2
- package/dist/bridge/wechat-bridge.js +20 -8
- package/dist/commands/check-update.js +15 -4
- package/dist/companion/local-companion-link.js +6 -1
- package/dist/companion/local-companion-start.js +29 -8
- package/dist/companion/local-companion.js +3 -3
- package/dist/companion/pi-tui-bridge-extension.js +256 -0
- package/dist/daemon/wechat-daemon.js +126 -25
- package/dist/i18n/messages-en.js +12 -2
- package/dist/i18n/messages-zh.js +12 -2
- package/dist/utils/doctor.js +85 -2
- package/dist/utils/version-checker.js +77 -44
- package/dist/wechat/channel-config.js +28 -0
- package/dist/wechat/setup.js +2 -0
- package/dist/wechat/wechat-transport.js +49 -13
- package/package.json +120 -100
- package/scripts/ensure-node-pty-permissions.mjs +73 -0
|
@@ -46,7 +46,7 @@ export function formatUserFacingBridgeFatalError(message) {
|
|
|
46
46
|
return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
|
|
47
47
|
}
|
|
48
48
|
export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
|
|
49
|
-
if (adapter !== "opencode") {
|
|
49
|
+
if (adapter !== "opencode" && adapter !== "pi") {
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
52
|
switch (eventType) {
|
|
@@ -55,7 +55,7 @@ export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {
|
|
|
55
55
|
case "thread_switched":
|
|
56
56
|
return false;
|
|
57
57
|
case "notice":
|
|
58
|
-
return /^OpenCode local draft:\s*/i.test(options.text ?? "");
|
|
58
|
+
return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
|
|
59
59
|
case "mirrored_user_input":
|
|
60
60
|
return true;
|
|
61
61
|
default:
|
|
@@ -159,7 +159,7 @@ export function parseCliArgs(argv) {
|
|
|
159
159
|
const next = argv[i + 1];
|
|
160
160
|
switch (arg) {
|
|
161
161
|
case "--adapter":
|
|
162
|
-
if (!next || !["codex", "claude", "opencode", "shell"].includes(next)) {
|
|
162
|
+
if (!next || !["codex", "claude", "opencode", "pi", "shell"].includes(next)) {
|
|
163
163
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
164
164
|
}
|
|
165
165
|
adapter = next;
|
|
@@ -212,7 +212,7 @@ export function parseCliArgs(argv) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
if (!adapter) {
|
|
215
|
-
throw new Error("Missing required --adapter <codex|claude|opencode|shell>");
|
|
215
|
+
throw new Error("Missing required --adapter <codex|claude|opencode|pi|shell>");
|
|
216
216
|
}
|
|
217
217
|
const defaultCommand = resolveDefaultAdapterCommand(adapter);
|
|
218
218
|
return {
|
|
@@ -226,12 +226,13 @@ export function parseCliArgs(argv) {
|
|
|
226
226
|
}
|
|
227
227
|
function printUsageAndExit() {
|
|
228
228
|
process.stdout.write([
|
|
229
|
-
"Usage: wechat-bridge --adapter <codex|claude|opencode|shell> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
|
|
229
|
+
"Usage: wechat-bridge --adapter <codex|claude|opencode|pi|shell> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
|
|
230
230
|
"",
|
|
231
231
|
"Examples:",
|
|
232
232
|
" wechat-bridge-codex",
|
|
233
233
|
" wechat-bridge-claude --cwd ~/work/my-project",
|
|
234
234
|
" wechat-bridge-opencode --cwd ~/work/my-project",
|
|
235
|
+
" wechat-bridge-pi --cwd ~/work/my-project",
|
|
235
236
|
" wechat-bridge-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
|
|
236
237
|
" wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
|
|
237
238
|
" wechat-bridge-codex --lifecycle companion_bound",
|
|
@@ -266,7 +267,9 @@ async function main() {
|
|
|
266
267
|
}
|
|
267
268
|
const transport = new WeChatTransport({ log, logError });
|
|
268
269
|
// 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
|
|
269
|
-
|
|
270
|
+
// unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
|
|
271
|
+
// 否则会与强制退出 teardown 竞态。
|
|
272
|
+
const updateCheckTimer = setTimeout(async () => {
|
|
270
273
|
try {
|
|
271
274
|
const versionInfo = await checkForUpdate();
|
|
272
275
|
if (versionInfo?.hasUpdate) {
|
|
@@ -277,6 +280,7 @@ async function main() {
|
|
|
277
280
|
// 静默失败,不影响正常使用
|
|
278
281
|
}
|
|
279
282
|
}, 3000); // 延迟3秒,确保不影响启动
|
|
283
|
+
updateCheckTimer.unref?.();
|
|
280
284
|
const stateStore = new BridgeStateStore({
|
|
281
285
|
...options,
|
|
282
286
|
authorizedUserId: credentials.userId,
|
|
@@ -338,6 +342,7 @@ async function main() {
|
|
|
338
342
|
let lastOutputAt = 0;
|
|
339
343
|
let lastHeartbeatAt = 0;
|
|
340
344
|
let consecutivePollFailures = 0;
|
|
345
|
+
let backlogNoticeSent = false;
|
|
341
346
|
const queueWechatTextAction = (action) => {
|
|
342
347
|
const run = textSendChain.then(action);
|
|
343
348
|
textSendChain = run.then(() => undefined, () => undefined);
|
|
@@ -661,6 +666,13 @@ async function main() {
|
|
|
661
666
|
if (pollResult.ignoredBacklogCount > 0) {
|
|
662
667
|
stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
|
|
663
668
|
stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
|
|
669
|
+
if (!backlogNoticeSent) {
|
|
670
|
+
backlogNoticeSent = true;
|
|
671
|
+
await queueWechatMessage(stateStore.getState().authorizedUserId, t("bridge.backlogIgnored", {
|
|
672
|
+
count: pollResult.ignoredBacklogCount,
|
|
673
|
+
graceSeconds: Math.round(MESSAGE_START_GRACE_MS / 1000),
|
|
674
|
+
}), "notice");
|
|
675
|
+
}
|
|
664
676
|
}
|
|
665
677
|
for (const message of pollResult.messages) {
|
|
666
678
|
if (!ensureRuntimeOwnership()) {
|
|
@@ -1111,9 +1123,9 @@ async function handleInboundMessage(params) {
|
|
|
1111
1123
|
return null;
|
|
1112
1124
|
}
|
|
1113
1125
|
if (adapterState.status === "busy") {
|
|
1114
|
-
if ((options.adapter === "codex" || options.adapter === "opencode") &&
|
|
1126
|
+
if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
|
|
1115
1127
|
adapterState.activeTurnOrigin === "local") {
|
|
1116
|
-
await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
|
|
1128
|
+
await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
|
|
1117
1129
|
return null;
|
|
1118
1130
|
}
|
|
1119
1131
|
await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
|
|
@@ -7,12 +7,23 @@ async function main() {
|
|
|
7
7
|
console.log(`Checking for updates...`);
|
|
8
8
|
const versionInfo = await checkForUpdate(true); // 强制检查
|
|
9
9
|
if (!versionInfo) {
|
|
10
|
-
console.
|
|
11
|
-
|
|
10
|
+
console.error(`ERROR: Unable to check for updates.`);
|
|
11
|
+
console.error(`Could not read the latest version from the npm registry or GitHub.`);
|
|
12
|
+
console.error(`Possible causes:`);
|
|
13
|
+
console.error(` - No network connection`);
|
|
14
|
+
console.error(` - A proxy is required but not configured (set HTTP_PROXY/HTTPS_PROXY and NODE_USE_ENV_PROXY=1)`);
|
|
15
|
+
console.error(` - The npm registry or GitHub API are temporarily unavailable`);
|
|
16
|
+
console.error(`\nCheck the latest release manually:`);
|
|
17
|
+
console.error(` https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases`);
|
|
18
|
+
// 不使用 process.exit():强制退出会打断 fetch 底层 handle 的关闭流程,
|
|
19
|
+
// 在 Windows + Node 24 上触发 libuv 的 UV_HANDLE_CLOSING 断言崩溃。
|
|
20
|
+
// 设置 exitCode 后让事件循环自然退出。
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return;
|
|
12
23
|
}
|
|
13
24
|
if (!versionInfo.hasUpdate) {
|
|
14
25
|
console.log(`OK: Already up to date (v${versionInfo.latest})`);
|
|
15
|
-
|
|
26
|
+
return;
|
|
16
27
|
}
|
|
17
28
|
console.log(`[New Version Available] v${versionInfo.latest}`);
|
|
18
29
|
console.log(`Current version: v${versionInfo.current}\n`);
|
|
@@ -26,5 +37,5 @@ async function main() {
|
|
|
26
37
|
}
|
|
27
38
|
main().catch((error) => {
|
|
28
39
|
console.error(`Error checking for updates: ${error.message}`);
|
|
29
|
-
process.
|
|
40
|
+
process.exitCode = 1;
|
|
30
41
|
});
|
|
@@ -7,7 +7,11 @@ function normalizeEndpoint(value) {
|
|
|
7
7
|
return null;
|
|
8
8
|
}
|
|
9
9
|
const record = value;
|
|
10
|
-
const kind = record.kind === "codex" ||
|
|
10
|
+
const kind = record.kind === "codex" ||
|
|
11
|
+
record.kind === "claude" ||
|
|
12
|
+
record.kind === "opencode" ||
|
|
13
|
+
record.kind === "pi" ||
|
|
14
|
+
record.kind === "shell"
|
|
11
15
|
? record.kind
|
|
12
16
|
: "codex";
|
|
13
17
|
const sharedSessionId = typeof record.sharedSessionId === "string"
|
|
@@ -118,6 +122,7 @@ export function clearLocalCompanionEndpoint(cwd, instanceId, options = {}) {
|
|
|
118
122
|
getWorkspaceAdapterEndpointFile(cwd, "codex"),
|
|
119
123
|
getWorkspaceAdapterEndpointFile(cwd, "claude"),
|
|
120
124
|
getWorkspaceAdapterEndpointFile(cwd, "opencode"),
|
|
125
|
+
getWorkspaceAdapterEndpointFile(cwd, "pi"),
|
|
121
126
|
getWorkspaceAdapterEndpointFile(cwd, "shell"),
|
|
122
127
|
];
|
|
123
128
|
for (const filePath of files) {
|
|
@@ -38,7 +38,7 @@ export function formatRestartUnhealthyMessage(cwd) {
|
|
|
38
38
|
return `Detected unhealthy companion state for ${cwd}. Restarting bridge...`;
|
|
39
39
|
}
|
|
40
40
|
export function defaultSessionStartMode(adapter) {
|
|
41
|
-
return adapter === "
|
|
41
|
+
return adapter === "codex" ? "restore" : "new";
|
|
42
42
|
}
|
|
43
43
|
export function decideLaunchAction(input) {
|
|
44
44
|
if (input.lockShouldAutoReclaim) {
|
|
@@ -59,7 +59,9 @@ export function decideLaunchAction(input) {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
if (input.sessionStartMode === "new" &&
|
|
62
|
-
(input.requestedAdapter === "claude" ||
|
|
62
|
+
(input.requestedAdapter === "claude" ||
|
|
63
|
+
input.requestedAdapter === "opencode" ||
|
|
64
|
+
input.requestedAdapter === "pi")) {
|
|
63
65
|
return {
|
|
64
66
|
kind: "start_bridge",
|
|
65
67
|
message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
|
|
@@ -95,6 +97,7 @@ export function parseCliArgs(argv) {
|
|
|
95
97
|
let cwd = process.cwd();
|
|
96
98
|
let profile;
|
|
97
99
|
let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
|
|
100
|
+
let sessionStartMode;
|
|
98
101
|
const cliArgs = [];
|
|
99
102
|
for (let i = 0; i < argv.length; i += 1) {
|
|
100
103
|
const arg = argv[i];
|
|
@@ -107,10 +110,12 @@ export function parseCliArgs(argv) {
|
|
|
107
110
|
"Usage: wechat-codex-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...codex args]",
|
|
108
111
|
" wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
|
|
109
112
|
" wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
|
|
110
|
-
"
|
|
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]",
|
|
111
115
|
"",
|
|
112
116
|
"Starts a bridge for the current directory, waits for the local endpoint, then opens the visible companion or panel.",
|
|
113
|
-
"Claude and
|
|
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.",
|
|
114
119
|
"All adapters are companion-bound: closing the companion/panel also stops the bridge.",
|
|
115
120
|
"Unknown arguments are forwarded to the visible CLI client.",
|
|
116
121
|
"",
|
|
@@ -118,7 +123,7 @@ export function parseCliArgs(argv) {
|
|
|
118
123
|
process.exit(0);
|
|
119
124
|
}
|
|
120
125
|
if (arg === "--adapter") {
|
|
121
|
-
if (!next || !["codex", "claude", "opencode"].includes(next)) {
|
|
126
|
+
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
122
127
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
123
128
|
}
|
|
124
129
|
adapter = next;
|
|
@@ -153,6 +158,14 @@ export function parseCliArgs(argv) {
|
|
|
153
158
|
i += 1;
|
|
154
159
|
continue;
|
|
155
160
|
}
|
|
161
|
+
if (arg === "--session-start-mode") {
|
|
162
|
+
if (!next || !["restore", "new"].includes(next)) {
|
|
163
|
+
throw new Error(`Invalid session start mode: ${next ?? "(missing)"}`);
|
|
164
|
+
}
|
|
165
|
+
sessionStartMode = next;
|
|
166
|
+
i += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
156
169
|
cliArgs.push(arg);
|
|
157
170
|
}
|
|
158
171
|
return {
|
|
@@ -160,7 +173,7 @@ export function parseCliArgs(argv) {
|
|
|
160
173
|
cwd,
|
|
161
174
|
profile,
|
|
162
175
|
timeoutMs,
|
|
163
|
-
sessionStartMode: defaultSessionStartMode(adapter),
|
|
176
|
+
sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
|
|
164
177
|
cliArgs,
|
|
165
178
|
};
|
|
166
179
|
}
|
|
@@ -203,7 +216,10 @@ async function stopExistingBridge(lock, requestedAdapter) {
|
|
|
203
216
|
if (!(await waitForProcessExit(pid, 10_000))) {
|
|
204
217
|
throw new Error(`Timed out waiting for existing bridge pid=${pid} to exit.`);
|
|
205
218
|
}
|
|
206
|
-
if (lock.adapter === "codex" ||
|
|
219
|
+
if (lock.adapter === "codex" ||
|
|
220
|
+
lock.adapter === "claude" ||
|
|
221
|
+
lock.adapter === "opencode" ||
|
|
222
|
+
lock.adapter === "pi") {
|
|
207
223
|
clearLocalCompanionEndpoint(cwd, undefined, { adapter: lock.adapter });
|
|
208
224
|
}
|
|
209
225
|
else {
|
|
@@ -283,6 +299,11 @@ function startBridgeInBackground(options) {
|
|
|
283
299
|
stdio: "ignore",
|
|
284
300
|
windowsHide: true,
|
|
285
301
|
});
|
|
302
|
+
// A detached background spawn must still handle its own 'error' event
|
|
303
|
+
// (e.g. ENOENT), otherwise it surfaces as an unhandled exception.
|
|
304
|
+
child.on("error", () => {
|
|
305
|
+
/* best effort: background bridge failed to detach */
|
|
306
|
+
});
|
|
286
307
|
child.unref();
|
|
287
308
|
}
|
|
288
309
|
async function waitForEndpoint(cwd, adapter, timeoutMs) {
|
|
@@ -378,7 +399,7 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
378
399
|
cliArgs: options.cliArgs,
|
|
379
400
|
openVisible: true,
|
|
380
401
|
sessionStartMode: options.sessionStartMode,
|
|
381
|
-
reuseExistingVisible:
|
|
402
|
+
reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
|
|
382
403
|
});
|
|
383
404
|
if (!response.ok) {
|
|
384
405
|
throw new Error(response.error);
|
|
@@ -23,7 +23,7 @@ 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> [--cwd <path>] [...cli args]",
|
|
26
|
+
"Usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
|
|
27
27
|
"",
|
|
28
28
|
'Starts the visible local companion and connects it to the matching running bridge for the current directory.',
|
|
29
29
|
"Unknown arguments are forwarded to the visible CLI client.",
|
|
@@ -32,7 +32,7 @@ function parseCliArgs(argv) {
|
|
|
32
32
|
process.exit(0);
|
|
33
33
|
}
|
|
34
34
|
if (arg === "--adapter") {
|
|
35
|
-
if (!next || !["codex", "claude", "opencode"].includes(next)) {
|
|
35
|
+
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
36
36
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
37
37
|
}
|
|
38
38
|
adapter = next;
|
|
@@ -58,7 +58,7 @@ function parseCliArgs(argv) {
|
|
|
58
58
|
cliArgs.push(arg);
|
|
59
59
|
}
|
|
60
60
|
if (!adapter) {
|
|
61
|
-
throw new Error("Missing required --adapter <codex|claude|opencode>");
|
|
61
|
+
throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
|
|
62
62
|
}
|
|
63
63
|
return { adapter, cwd, sessionStartMode, cliArgs };
|
|
64
64
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
const BRIDGE_HOST = "127.0.0.1";
|
|
3
|
+
const INTERNAL_NEW_COMMAND = "__cli_bridge_new";
|
|
4
|
+
const INTERNAL_SWITCH_COMMAND = "__cli_bridge_switch";
|
|
5
|
+
const MAX_BUFFER_SIZE = 8 * 1024 * 1024;
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function extractAssistantText(message) {
|
|
10
|
+
if (!isRecord(message) || message.role !== "assistant") {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
if (typeof message.content === "string") {
|
|
14
|
+
return message.content.trim();
|
|
15
|
+
}
|
|
16
|
+
if (!Array.isArray(message.content)) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
return message.content
|
|
20
|
+
.filter((block) => isRecord(block))
|
|
21
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
22
|
+
.map((block) => block.text)
|
|
23
|
+
.join("\n")
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
function parseControlArgs(args) {
|
|
27
|
+
const trimmed = args.trim();
|
|
28
|
+
if (!trimmed) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const separatorIndex = trimmed.indexOf(" ");
|
|
32
|
+
if (separatorIndex < 0) {
|
|
33
|
+
return { id: trimmed };
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
id: trimmed.slice(0, separatorIndex),
|
|
37
|
+
value: trimmed.slice(separatorIndex + 1),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export default function piTuiBridgeExtension(pi) {
|
|
41
|
+
const bridgePort = Number(process.env.CLI_BRIDGE_PI_TUI_PORT ?? "");
|
|
42
|
+
const bridgeToken = process.env.CLI_BRIDGE_PI_TUI_TOKEN ?? "";
|
|
43
|
+
if (!Number.isInteger(bridgePort) || bridgePort <= 0 || !bridgeToken) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const socket = net.connect({ host: BRIDGE_HOST, port: bridgePort });
|
|
47
|
+
let buffer = "";
|
|
48
|
+
let connected = false;
|
|
49
|
+
const queuedFrames = [];
|
|
50
|
+
const queuedCommands = [];
|
|
51
|
+
let latestContext = null;
|
|
52
|
+
const writeFrame = (frame) => {
|
|
53
|
+
if (socket.destroyed) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!connected) {
|
|
57
|
+
queuedFrames.push(frame);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
socket.write(`${JSON.stringify(frame)}\n`);
|
|
61
|
+
};
|
|
62
|
+
const writeSessionState = (context, reason) => {
|
|
63
|
+
writeFrame({
|
|
64
|
+
type: "session_state",
|
|
65
|
+
sessionId: context.sessionManager.getSessionId(),
|
|
66
|
+
sessionFile: context.sessionManager.getSessionFile(),
|
|
67
|
+
reason,
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
pi.registerCommand(INTERNAL_NEW_COMMAND, {
|
|
71
|
+
description: "Internal CLI WeChat Bridge session command",
|
|
72
|
+
handler: async (args, context) => {
|
|
73
|
+
const parsed = parseControlArgs(args);
|
|
74
|
+
if (!parsed) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
let sessionState = {};
|
|
79
|
+
const result = await context.newSession({
|
|
80
|
+
withSession: async (nextContext) => {
|
|
81
|
+
writeSessionState(nextContext, "new");
|
|
82
|
+
sessionState = {
|
|
83
|
+
sessionId: nextContext.sessionManager.getSessionId(),
|
|
84
|
+
sessionFile: nextContext.sessionManager.getSessionFile(),
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
writeFrame({
|
|
89
|
+
type: "response",
|
|
90
|
+
id: parsed.id,
|
|
91
|
+
success: true,
|
|
92
|
+
data: { ...result, ...sessionState },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
writeFrame({
|
|
97
|
+
type: "response",
|
|
98
|
+
id: parsed.id,
|
|
99
|
+
success: false,
|
|
100
|
+
error: error instanceof Error ? error.message : String(error),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
pi.registerCommand(INTERNAL_SWITCH_COMMAND, {
|
|
106
|
+
description: "Internal CLI WeChat Bridge session command",
|
|
107
|
+
handler: async (args, context) => {
|
|
108
|
+
const parsed = parseControlArgs(args);
|
|
109
|
+
if (!parsed?.value) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
let sessionState = {};
|
|
114
|
+
const result = await context.switchSession(parsed.value, {
|
|
115
|
+
withSession: async (nextContext) => {
|
|
116
|
+
writeSessionState(nextContext, "resume");
|
|
117
|
+
sessionState = {
|
|
118
|
+
sessionId: nextContext.sessionManager.getSessionId(),
|
|
119
|
+
sessionFile: nextContext.sessionManager.getSessionFile(),
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
writeFrame({
|
|
124
|
+
type: "response",
|
|
125
|
+
id: parsed.id,
|
|
126
|
+
success: true,
|
|
127
|
+
data: { ...result, ...sessionState },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
writeFrame({
|
|
132
|
+
type: "response",
|
|
133
|
+
id: parsed.id,
|
|
134
|
+
success: false,
|
|
135
|
+
error: error instanceof Error ? error.message : String(error),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const executeCommand = (command) => {
|
|
141
|
+
try {
|
|
142
|
+
if (command.type === "prompt" && typeof command.text === "string") {
|
|
143
|
+
if (!latestContext?.isIdle()) {
|
|
144
|
+
throw new Error("Pi TUI is already processing a turn.");
|
|
145
|
+
}
|
|
146
|
+
pi.sendUserMessage(command.text);
|
|
147
|
+
writeFrame({ type: "response", id: command.id, success: true });
|
|
148
|
+
}
|
|
149
|
+
else if (command.type === "abort") {
|
|
150
|
+
latestContext?.abort();
|
|
151
|
+
writeFrame({ type: "response", id: command.id, success: true });
|
|
152
|
+
}
|
|
153
|
+
else if (command.type === "new_session" && typeof command.id === "string") {
|
|
154
|
+
pi.sendUserMessage(`/${INTERNAL_NEW_COMMAND} ${command.id}`, {
|
|
155
|
+
expandPromptTemplates: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
else if (command.type === "switch_session" &&
|
|
159
|
+
typeof command.id === "string" &&
|
|
160
|
+
typeof command.sessionPath === "string") {
|
|
161
|
+
pi.sendUserMessage(`/${INTERNAL_SWITCH_COMMAND} ${command.id} ${command.sessionPath}`, { expandPromptTemplates: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
writeFrame({
|
|
166
|
+
type: "response",
|
|
167
|
+
id: command.id,
|
|
168
|
+
success: false,
|
|
169
|
+
error: error instanceof Error ? error.message : String(error),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const dispatchCommand = (command) => {
|
|
174
|
+
if (!latestContext) {
|
|
175
|
+
queuedCommands.push(command);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
executeCommand(command);
|
|
179
|
+
};
|
|
180
|
+
socket.setEncoding("utf8");
|
|
181
|
+
socket.on("connect", () => {
|
|
182
|
+
socket.setNoDelay(true);
|
|
183
|
+
socket.write(`${JSON.stringify({ type: "hello", token: bridgeToken })}\n`);
|
|
184
|
+
connected = true;
|
|
185
|
+
for (const frame of queuedFrames.splice(0)) {
|
|
186
|
+
writeFrame(frame);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
socket.on("data", (chunk) => {
|
|
190
|
+
buffer += chunk;
|
|
191
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
192
|
+
buffer = "";
|
|
193
|
+
socket.destroy();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
while (true) {
|
|
197
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
198
|
+
if (newlineIndex < 0) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
202
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
203
|
+
if (!line.trim()) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(line);
|
|
208
|
+
if (isRecord(parsed)) {
|
|
209
|
+
dispatchCommand(parsed);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Ignore malformed bridge commands and keep the native TUI alive.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
socket.on("error", () => {
|
|
218
|
+
// The visible TUI remains usable if the bridge side disappears.
|
|
219
|
+
});
|
|
220
|
+
pi.on("session_start", (event, context) => {
|
|
221
|
+
latestContext = context;
|
|
222
|
+
writeSessionState(context, typeof event.reason === "string" ? event.reason : "startup");
|
|
223
|
+
for (const command of queuedCommands.splice(0)) {
|
|
224
|
+
executeCommand(command);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
pi.on("input", (event, context) => {
|
|
228
|
+
latestContext = context;
|
|
229
|
+
if (event.source === "interactive" && typeof event.text === "string") {
|
|
230
|
+
writeFrame({ type: "local_input", text: event.text });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
pi.on("message_end", (event, context) => {
|
|
234
|
+
latestContext = context;
|
|
235
|
+
const text = extractAssistantText(event.message);
|
|
236
|
+
if (!text) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const message = isRecord(event.message) ? event.message : {};
|
|
240
|
+
writeFrame({
|
|
241
|
+
type: "assistant_message",
|
|
242
|
+
text,
|
|
243
|
+
stopReason: message.stopReason,
|
|
244
|
+
errorMessage: message.errorMessage,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
pi.on("agent_start", (_event, context) => {
|
|
248
|
+
latestContext = context;
|
|
249
|
+
writeFrame({ type: "agent_start" });
|
|
250
|
+
});
|
|
251
|
+
pi.on("agent_settled", (_event, context) => {
|
|
252
|
+
latestContext = context;
|
|
253
|
+
writeSessionState(context, "settled");
|
|
254
|
+
writeFrame({ type: "agent_settled" });
|
|
255
|
+
});
|
|
256
|
+
}
|