cli-wechat-bridge 1.1.2 → 1.1.4
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 +129 -150
- package/bin/_run-entry.mjs +12 -0
- package/bin/wechat-claude-start.mjs +7 -2
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex-start.mjs +7 -2
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode-start.mjs +7 -2
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi-start.mjs +10 -0
- package/bin/wechat-pi.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +2 -0
- package/dist/bridge/bridge-adapters.claude.js +21 -1
- package/dist/bridge/bridge-adapters.codex.js +85 -6
- package/dist/bridge/bridge-adapters.core.js +112 -6
- package/dist/bridge/bridge-adapters.js +5 -3
- package/dist/bridge/bridge-adapters.opencode.js +390 -52
- package/dist/bridge/bridge-adapters.pi.js +707 -0
- package/dist/bridge/bridge-adapters.shared.js +22 -102
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-state.js +42 -6
- package/dist/bridge/bridge-utils.js +94 -237
- package/dist/bridge/wechat-bridge.js +77 -131
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +20 -6
- package/dist/companion/local-companion-link.js +13 -3
- package/dist/companion/local-companion-start.js +64 -16
- package/dist/companion/local-companion.js +8 -5
- package/dist/companion/pi-tui-bridge-extension.js +256 -0
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +264 -77
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +4 -5
- package/dist/i18n/messages-zh.js +4 -5
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +27 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -9
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +113 -102
- 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-shell.mjs +0 -5
- package/bin/wechat-bridge.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);
|
|
@@ -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,10 @@ 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" ||
|
|
65
|
+
input.requestedAdapter === "codex")) {
|
|
63
66
|
return {
|
|
64
67
|
kind: "start_bridge",
|
|
65
68
|
message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
|
|
@@ -95,6 +98,7 @@ export function parseCliArgs(argv) {
|
|
|
95
98
|
let cwd = process.cwd();
|
|
96
99
|
let profile;
|
|
97
100
|
let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
|
|
101
|
+
let sessionStartMode;
|
|
98
102
|
const cliArgs = [];
|
|
99
103
|
for (let i = 0; i < argv.length; i += 1) {
|
|
100
104
|
const arg = argv[i];
|
|
@@ -103,22 +107,38 @@ export function parseCliArgs(argv) {
|
|
|
103
107
|
}
|
|
104
108
|
const next = argv[i + 1];
|
|
105
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];
|
|
106
118
|
process.stdout.write([
|
|
107
|
-
|
|
108
|
-
" wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
|
|
109
|
-
" wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
|
|
110
|
-
" local-companion-start [--adapter <codex|claude|opencode>] [--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]`,
|
|
111
120
|
"",
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
|
|
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.",
|
|
115
131
|
"Unknown arguments are forwarded to the visible CLI client.",
|
|
132
|
+
`Deprecated alias: ${commandName}-start.`,
|
|
116
133
|
"",
|
|
117
134
|
].join("\n"));
|
|
118
135
|
process.exit(0);
|
|
119
136
|
}
|
|
137
|
+
if (arg === "--doctor") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
120
140
|
if (arg === "--adapter") {
|
|
121
|
-
if (!next || !["codex", "claude", "opencode"].includes(next)) {
|
|
141
|
+
if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
|
|
122
142
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
123
143
|
}
|
|
124
144
|
adapter = next;
|
|
@@ -153,6 +173,14 @@ export function parseCliArgs(argv) {
|
|
|
153
173
|
i += 1;
|
|
154
174
|
continue;
|
|
155
175
|
}
|
|
176
|
+
if (arg === "--session-start-mode") {
|
|
177
|
+
if (!next || !["restore", "new"].includes(next)) {
|
|
178
|
+
throw new Error(`Invalid session start mode: ${next ?? "(missing)"}`);
|
|
179
|
+
}
|
|
180
|
+
sessionStartMode = next;
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
156
184
|
cliArgs.push(arg);
|
|
157
185
|
}
|
|
158
186
|
return {
|
|
@@ -160,7 +188,7 @@ export function parseCliArgs(argv) {
|
|
|
160
188
|
cwd,
|
|
161
189
|
profile,
|
|
162
190
|
timeoutMs,
|
|
163
|
-
sessionStartMode: defaultSessionStartMode(adapter),
|
|
191
|
+
sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
|
|
164
192
|
cliArgs,
|
|
165
193
|
};
|
|
166
194
|
}
|
|
@@ -203,7 +231,10 @@ async function stopExistingBridge(lock, requestedAdapter) {
|
|
|
203
231
|
if (!(await waitForProcessExit(pid, 10_000))) {
|
|
204
232
|
throw new Error(`Timed out waiting for existing bridge pid=${pid} to exit.`);
|
|
205
233
|
}
|
|
206
|
-
if (lock.adapter === "codex" ||
|
|
234
|
+
if (lock.adapter === "codex" ||
|
|
235
|
+
lock.adapter === "claude" ||
|
|
236
|
+
lock.adapter === "opencode" ||
|
|
237
|
+
lock.adapter === "pi") {
|
|
207
238
|
clearLocalCompanionEndpoint(cwd, undefined, { adapter: lock.adapter });
|
|
208
239
|
}
|
|
209
240
|
else {
|
|
@@ -359,6 +390,12 @@ async function ensureBridgeReady(options) {
|
|
|
359
390
|
await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
|
|
360
391
|
return { shouldOpenVisibleClient: true };
|
|
361
392
|
}
|
|
393
|
+
// The daemon's ensure_slot can legitimately take a while: opening a visible
|
|
394
|
+
// client waits up to 15s for it to connect, and codex waits another 15s for
|
|
395
|
+
// the visible thread — all serialized per adapter. The default 2s IPC timeout
|
|
396
|
+
// would expire on nearly every cold start, so delegation uses a generous
|
|
397
|
+
// deadline and falls back to a local bridge when the daemon cannot deliver.
|
|
398
|
+
const ENSURE_SLOT_DELEGATION_TIMEOUT_MS = 45_000;
|
|
362
399
|
export async function tryDelegateToDaemon(options, deps = {}) {
|
|
363
400
|
const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
|
|
364
401
|
const isEndpointAlive = deps.isEndpointAlive ?? isDaemonEndpointAlive;
|
|
@@ -383,10 +420,15 @@ export async function tryDelegateToDaemon(options, deps = {}) {
|
|
|
383
420
|
cliArgs: options.cliArgs,
|
|
384
421
|
openVisible: true,
|
|
385
422
|
sessionStartMode: options.sessionStartMode,
|
|
386
|
-
reuseExistingVisible:
|
|
387
|
-
});
|
|
423
|
+
reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
|
|
424
|
+
}, { timeoutMs: ENSURE_SLOT_DELEGATION_TIMEOUT_MS });
|
|
388
425
|
if (!response.ok) {
|
|
389
|
-
|
|
426
|
+
// A daemon that cannot answer in time (or at all) must not abort the
|
|
427
|
+
// launch: fall back to a local companion-bound bridge so the user still
|
|
428
|
+
// gets a working terminal instead of an error for work the daemon may
|
|
429
|
+
// still complete.
|
|
430
|
+
log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
|
|
431
|
+
return false;
|
|
390
432
|
}
|
|
391
433
|
log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
|
|
392
434
|
return true;
|
|
@@ -398,6 +440,7 @@ export async function runVisibleClient(options, runners = {}) {
|
|
|
398
440
|
return await (runners.codexRemoteClient ?? runCodexRemoteClient)({
|
|
399
441
|
cwd: options.cwd,
|
|
400
442
|
cliArgs: options.cliArgs,
|
|
443
|
+
sessionStartMode: options.sessionStartMode,
|
|
401
444
|
});
|
|
402
445
|
}
|
|
403
446
|
return await (runners.localCompanion ?? runLocalCompanion)({
|
|
@@ -415,6 +458,11 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
|
|
|
415
458
|
});
|
|
416
459
|
}
|
|
417
460
|
export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
|
|
461
|
+
if (argv.includes("--doctor")) {
|
|
462
|
+
const { runDoctorCheck } = await import("../utils/doctor.js");
|
|
463
|
+
await runDoctorCheck(argv, { mode: "bridge" });
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
418
466
|
const options = parseCliArgs(argv);
|
|
419
467
|
await ensureCompanionStartWechatCredentials(options.adapter);
|
|
420
468
|
if (await tryDelegateToDaemon(options)) {
|
|
@@ -23,16 +23,16 @@ 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"));
|
|
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
|
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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
|
}
|