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
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { assertNoReservedExtraCliArgs, buildCliEnvironment, describeUnknownError, isRecord, resolveSpawnTarget, } from "./bridge-adapters.shared.js";
|
|
9
|
+
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
10
|
+
import { normalizeOutput, nowIso } from "./bridge-utils.js";
|
|
11
|
+
const PI_TUI_CONNECT_WARNING_MS = 30_000;
|
|
12
|
+
const PI_TUI_REQUEST_TIMEOUT_MS = 30_000;
|
|
13
|
+
const PI_TUI_RECONNECT_TIMEOUT_MS = 10_000;
|
|
14
|
+
const PI_TUI_MAX_BUFFER_SIZE = 8 * 1024 * 1024;
|
|
15
|
+
function expandHomePath(value) {
|
|
16
|
+
if (value === "~") {
|
|
17
|
+
return os.homedir();
|
|
18
|
+
}
|
|
19
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
20
|
+
return path.join(os.homedir(), value.slice(2));
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function buildPiSessionDirectory(cwd, env = process.env) {
|
|
25
|
+
const customSessionDir = env.PI_CODING_AGENT_SESSION_DIR;
|
|
26
|
+
if (customSessionDir) {
|
|
27
|
+
return path.resolve(expandHomePath(customSessionDir));
|
|
28
|
+
}
|
|
29
|
+
const agentDir = env.PI_CODING_AGENT_DIR
|
|
30
|
+
? path.resolve(expandHomePath(env.PI_CODING_AGENT_DIR))
|
|
31
|
+
: path.join(os.homedir(), ".pi", "agent");
|
|
32
|
+
const resolvedCwd = path.resolve(cwd);
|
|
33
|
+
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
34
|
+
return path.join(agentDir, "sessions", safePath);
|
|
35
|
+
}
|
|
36
|
+
function extractMessageText(message) {
|
|
37
|
+
if (!isRecord(message)) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
const content = message.content;
|
|
41
|
+
if (typeof content === "string") {
|
|
42
|
+
return normalizeOutput(content).trim();
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(content)) {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
return normalizeOutput(content
|
|
48
|
+
.filter((block) => isRecord(block))
|
|
49
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
50
|
+
.map((block) => block.text)
|
|
51
|
+
.join("\n")).trim();
|
|
52
|
+
}
|
|
53
|
+
function summarizePiSessionFile(filePath, cwd) {
|
|
54
|
+
try {
|
|
55
|
+
const entries = fs
|
|
56
|
+
.readFileSync(filePath, "utf8")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((line) => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.filter((entry) => isRecord(entry));
|
|
68
|
+
const header = entries.find((entry) => entry.type === "session");
|
|
69
|
+
if (!header || typeof header.id !== "string") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (typeof header.cwd === "string" && path.resolve(header.cwd) !== path.resolve(cwd)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
let sessionName = "";
|
|
76
|
+
let firstUserMessage = "";
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.type === "session_info" && typeof entry.name === "string") {
|
|
79
|
+
sessionName = normalizeOutput(entry.name).trim();
|
|
80
|
+
}
|
|
81
|
+
if (firstUserMessage || entry.type !== "message" || !isRecord(entry.message)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (entry.message.role === "user") {
|
|
85
|
+
firstUserMessage = extractMessageText(entry.message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
sessionId: header.id,
|
|
90
|
+
title: sessionName || firstUserMessage || header.id,
|
|
91
|
+
lastUpdatedAt: fs.statSync(filePath).mtime.toISOString(),
|
|
92
|
+
source: "pi",
|
|
93
|
+
filePath,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function listPiResumeSessions(cwd, limit = 10, env = process.env) {
|
|
101
|
+
const sessionDir = buildPiSessionDirectory(cwd, env);
|
|
102
|
+
if (!fs.existsSync(sessionDir)) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return fs
|
|
106
|
+
.readdirSync(sessionDir, { withFileTypes: true })
|
|
107
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
108
|
+
.map((entry) => path.join(sessionDir, entry.name))
|
|
109
|
+
.map((filePath) => summarizePiSessionFile(filePath, cwd))
|
|
110
|
+
.filter((entry) => entry !== null)
|
|
111
|
+
.sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
|
|
112
|
+
.slice(0, Math.max(1, limit))
|
|
113
|
+
.map(({ filePath: _filePath, ...candidate }) => candidate);
|
|
114
|
+
}
|
|
115
|
+
function findPiSessionFile(cwd, sessionId, env = process.env) {
|
|
116
|
+
const sessionDir = buildPiSessionDirectory(cwd, env);
|
|
117
|
+
if (!fs.existsSync(sessionDir)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of fs.readdirSync(sessionDir, { withFileTypes: true })) {
|
|
121
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const filePath = path.join(sessionDir, entry.name);
|
|
125
|
+
if (summarizePiSessionFile(filePath, cwd)?.sessionId === sessionId) {
|
|
126
|
+
return filePath;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
export function buildPiTuiCliArgs(options) {
|
|
132
|
+
const extraCliArgs = options.extraCliArgs ?? [];
|
|
133
|
+
assertNoReservedExtraCliArgs(extraCliArgs, [
|
|
134
|
+
"--mode",
|
|
135
|
+
"--print",
|
|
136
|
+
"-p",
|
|
137
|
+
"--approve",
|
|
138
|
+
"-a",
|
|
139
|
+
"--no-approve",
|
|
140
|
+
"-na",
|
|
141
|
+
"--continue",
|
|
142
|
+
"-c",
|
|
143
|
+
"--resume",
|
|
144
|
+
"-r",
|
|
145
|
+
"--session",
|
|
146
|
+
"--session-id",
|
|
147
|
+
"--session-dir",
|
|
148
|
+
"--no-session",
|
|
149
|
+
], "Pi TUI runtime");
|
|
150
|
+
const args = ["--approve", "--extension", options.extensionPath];
|
|
151
|
+
if (options.sessionStartMode !== "new") {
|
|
152
|
+
if (options.initialSessionId) {
|
|
153
|
+
args.push("--session-id", options.initialSessionId);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
args.push("--continue");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...args, ...extraCliArgs];
|
|
160
|
+
}
|
|
161
|
+
export function attachPiTuiJsonlReader(stream, onFrame, onError) {
|
|
162
|
+
let buffer = "";
|
|
163
|
+
const onData = (chunk) => {
|
|
164
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
165
|
+
if (buffer.length > PI_TUI_MAX_BUFFER_SIZE) {
|
|
166
|
+
buffer = "";
|
|
167
|
+
onError(new Error("Pi TUI bridge buffer exceeded 8MB without a complete LF frame."));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
while (true) {
|
|
171
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
172
|
+
if (newlineIndex < 0) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
176
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
177
|
+
if (!line.trim()) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(line);
|
|
182
|
+
if (isRecord(parsed)) {
|
|
183
|
+
onFrame(parsed);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
onError(new Error(`Invalid Pi TUI bridge frame: ${describeUnknownError(error)}`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
stream.on("data", onData);
|
|
192
|
+
return () => stream.removeListener("data", onData);
|
|
193
|
+
}
|
|
194
|
+
function getPiBridgeExtensionPath() {
|
|
195
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
196
|
+
return path.resolve(path.dirname(modulePath), "..", "companion", `pi-tui-bridge-extension${path.extname(modulePath)}`);
|
|
197
|
+
}
|
|
198
|
+
export class PiTuiAdapter {
|
|
199
|
+
options;
|
|
200
|
+
deps;
|
|
201
|
+
state;
|
|
202
|
+
eventSink = () => undefined;
|
|
203
|
+
child = null;
|
|
204
|
+
server = null;
|
|
205
|
+
socket = null;
|
|
206
|
+
socketReaders = new Map();
|
|
207
|
+
retiringSockets = new Set();
|
|
208
|
+
connectWarningTimer = null;
|
|
209
|
+
reconnectTimer = null;
|
|
210
|
+
requestCounter = 0;
|
|
211
|
+
pendingRequests = new Map();
|
|
212
|
+
extensionReady = false;
|
|
213
|
+
disposing = false;
|
|
214
|
+
authenticated = new WeakSet();
|
|
215
|
+
currentAssistantText = "";
|
|
216
|
+
currentTurnError = "";
|
|
217
|
+
finalizingTurn = false;
|
|
218
|
+
pendingSessionSwitch = null;
|
|
219
|
+
token = crypto.randomBytes(32).toString("hex");
|
|
220
|
+
constructor(options, deps = {}) {
|
|
221
|
+
this.options = options;
|
|
222
|
+
this.deps = {
|
|
223
|
+
spawnProcess: deps.spawnProcess ?? spawn,
|
|
224
|
+
killProcessTree: deps.killProcessTree ?? killProcessTreeSync,
|
|
225
|
+
createServer: deps.createServer ?? net.createServer,
|
|
226
|
+
};
|
|
227
|
+
this.state = {
|
|
228
|
+
kind: "pi",
|
|
229
|
+
status: "stopped",
|
|
230
|
+
cwd: options.cwd,
|
|
231
|
+
command: options.command,
|
|
232
|
+
profile: options.profile,
|
|
233
|
+
sharedSessionId: options.sessionStartMode === "new" ? undefined : options.initialSharedSessionId,
|
|
234
|
+
activeRuntimeSessionId: options.sessionStartMode === "new" ? undefined : options.initialSharedSessionId,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
setEventSink(sink) {
|
|
238
|
+
this.eventSink = sink;
|
|
239
|
+
}
|
|
240
|
+
async start() {
|
|
241
|
+
if (this.child) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.disposing = false;
|
|
245
|
+
this.extensionReady = false;
|
|
246
|
+
this.setStatus("starting", "Starting the native Pi TUI...");
|
|
247
|
+
const port = await this.startServer();
|
|
248
|
+
const extensionPath = getPiBridgeExtensionPath();
|
|
249
|
+
if (!fs.existsSync(extensionPath)) {
|
|
250
|
+
await this.dispose();
|
|
251
|
+
throw new Error(`Pi TUI bridge extension is missing: ${extensionPath}`);
|
|
252
|
+
}
|
|
253
|
+
const cliArgs = buildPiTuiCliArgs({
|
|
254
|
+
extensionPath,
|
|
255
|
+
sessionStartMode: this.options.sessionStartMode,
|
|
256
|
+
initialSessionId: this.options.initialSharedSessionId,
|
|
257
|
+
extraCliArgs: this.options.extraCliArgs,
|
|
258
|
+
});
|
|
259
|
+
const target = resolveSpawnTarget(this.options.command, "pi", {
|
|
260
|
+
forwardArgs: cliArgs,
|
|
261
|
+
});
|
|
262
|
+
const env = {
|
|
263
|
+
...buildCliEnvironment("pi"),
|
|
264
|
+
CLI_BRIDGE_PI_TUI_PORT: String(port),
|
|
265
|
+
CLI_BRIDGE_PI_TUI_TOKEN: this.token,
|
|
266
|
+
};
|
|
267
|
+
const child = this.deps.spawnProcess(target.file, target.args, {
|
|
268
|
+
cwd: this.options.cwd,
|
|
269
|
+
env,
|
|
270
|
+
stdio: "inherit",
|
|
271
|
+
windowsHide: false,
|
|
272
|
+
shell: false,
|
|
273
|
+
});
|
|
274
|
+
this.child = child;
|
|
275
|
+
this.state.pid = child.pid;
|
|
276
|
+
this.state.startedAt = nowIso();
|
|
277
|
+
child.once("error", (error) => {
|
|
278
|
+
this.clearConnectWarningTimer();
|
|
279
|
+
this.rejectPendingRequests(error);
|
|
280
|
+
if (!this.disposing) {
|
|
281
|
+
this.emit({
|
|
282
|
+
type: "fatal_error",
|
|
283
|
+
message: `Failed to start Pi TUI: ${describeUnknownError(error)}`,
|
|
284
|
+
timestamp: nowIso(),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
child.once("exit", (code, signal) => this.handleProcessExit(code, signal));
|
|
289
|
+
this.scheduleConnectWarning();
|
|
290
|
+
}
|
|
291
|
+
async sendInput(text) {
|
|
292
|
+
this.assertConnected();
|
|
293
|
+
const prompt = text.trim();
|
|
294
|
+
if (!prompt) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (this.state.status === "busy") {
|
|
298
|
+
throw new Error("Pi is still working. Wait for the current reply or use /stop.");
|
|
299
|
+
}
|
|
300
|
+
this.beginTurn("wechat");
|
|
301
|
+
try {
|
|
302
|
+
await this.sendCommand("prompt", { text: prompt });
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
this.clearActiveTurn();
|
|
306
|
+
this.setStatus("idle");
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async listResumeSessions(limit = 10) {
|
|
311
|
+
return listPiResumeSessions(this.options.cwd, limit);
|
|
312
|
+
}
|
|
313
|
+
async resumeSession(sessionId) {
|
|
314
|
+
this.assertConnected();
|
|
315
|
+
if (this.state.status === "busy") {
|
|
316
|
+
throw new Error("Pi is still working. Stop the current turn before switching sessions.");
|
|
317
|
+
}
|
|
318
|
+
const sessionPath = findPiSessionFile(this.options.cwd, sessionId);
|
|
319
|
+
if (!sessionPath) {
|
|
320
|
+
throw new Error(`Pi session not found for this workspace: ${sessionId}`);
|
|
321
|
+
}
|
|
322
|
+
this.pendingSessionSwitch = { source: "wechat", reason: "wechat_resume" };
|
|
323
|
+
try {
|
|
324
|
+
const response = await this.sendCommand("switch_session", { sessionPath });
|
|
325
|
+
this.applySessionState(response.data, "wechat", "wechat_resume", true);
|
|
326
|
+
if (isRecord(response.data) && response.data.cancelled === true) {
|
|
327
|
+
throw new Error("Pi session switch was cancelled by an extension.");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
this.pendingSessionSwitch = null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async createSession() {
|
|
335
|
+
this.assertConnected();
|
|
336
|
+
if (this.state.status === "busy") {
|
|
337
|
+
throw new Error("Pi is still working. Stop the current turn before creating a session.");
|
|
338
|
+
}
|
|
339
|
+
this.pendingSessionSwitch = { source: "wechat", reason: "wechat_resume" };
|
|
340
|
+
try {
|
|
341
|
+
const response = await this.sendCommand("new_session");
|
|
342
|
+
this.applySessionState(response.data, "wechat", "wechat_resume", true);
|
|
343
|
+
if (isRecord(response.data) && response.data.cancelled === true) {
|
|
344
|
+
throw new Error("Pi session creation was cancelled by an extension.");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
this.pendingSessionSwitch = null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async interrupt() {
|
|
352
|
+
if (!this.socket || this.state.status !== "busy") {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
await this.sendCommand("abort");
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
async reset() {
|
|
359
|
+
await this.createSession();
|
|
360
|
+
}
|
|
361
|
+
async resolveApproval(_action) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
async resolveAllApprovals(_action) {
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
|
367
|
+
async submitUserInput(_answers) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
async dispose() {
|
|
371
|
+
this.disposing = true;
|
|
372
|
+
this.clearConnectWarningTimer();
|
|
373
|
+
this.clearReconnectTimer();
|
|
374
|
+
this.rejectPendingRequests(new Error("Pi TUI adapter is shutting down."));
|
|
375
|
+
for (const [socket, detach] of this.socketReaders) {
|
|
376
|
+
detach();
|
|
377
|
+
socket.destroy();
|
|
378
|
+
}
|
|
379
|
+
this.socketReaders.clear();
|
|
380
|
+
this.retiringSockets.clear();
|
|
381
|
+
this.socket = null;
|
|
382
|
+
if (this.server) {
|
|
383
|
+
const server = this.server;
|
|
384
|
+
this.server = null;
|
|
385
|
+
if (server.listening) {
|
|
386
|
+
await new Promise((resolve) => {
|
|
387
|
+
try {
|
|
388
|
+
server.close(() => resolve());
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
resolve();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const child = this.child;
|
|
397
|
+
this.child = null;
|
|
398
|
+
if (child?.pid) {
|
|
399
|
+
try {
|
|
400
|
+
this.deps.killProcessTree(child.pid);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// Best effort.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
this.state.status = "stopped";
|
|
407
|
+
this.state.pid = undefined;
|
|
408
|
+
this.extensionReady = false;
|
|
409
|
+
this.clearActiveTurn();
|
|
410
|
+
}
|
|
411
|
+
getState() {
|
|
412
|
+
return JSON.parse(JSON.stringify(this.state));
|
|
413
|
+
}
|
|
414
|
+
async startServer() {
|
|
415
|
+
const server = this.deps.createServer((socket) => this.acceptSocket(socket));
|
|
416
|
+
this.server = server;
|
|
417
|
+
return await new Promise((resolve, reject) => {
|
|
418
|
+
server.once("error", reject);
|
|
419
|
+
server.listen(0, "127.0.0.1", () => {
|
|
420
|
+
server.removeListener("error", reject);
|
|
421
|
+
const address = server.address();
|
|
422
|
+
if (!address || typeof address === "string") {
|
|
423
|
+
reject(new Error("Pi TUI bridge server did not expose a TCP port."));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
resolve(address.port);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
acceptSocket(socket) {
|
|
431
|
+
socket.setEncoding("utf8");
|
|
432
|
+
socket.setNoDelay(true);
|
|
433
|
+
const detach = attachPiTuiJsonlReader(socket, (frame) => {
|
|
434
|
+
if (!this.authenticated.has(socket)) {
|
|
435
|
+
if (frame.type !== "hello" || frame.token !== this.token) {
|
|
436
|
+
socket.destroy();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.authenticated.add(socket);
|
|
440
|
+
this.clearReconnectTimer();
|
|
441
|
+
const previousSocket = this.socket;
|
|
442
|
+
this.socket = socket;
|
|
443
|
+
this.extensionReady = true;
|
|
444
|
+
this.clearConnectWarningTimer();
|
|
445
|
+
if (this.state.status === "starting") {
|
|
446
|
+
this.setStatus("idle", "Native Pi TUI connected with full local permissions.");
|
|
447
|
+
}
|
|
448
|
+
if (previousSocket && previousSocket !== socket) {
|
|
449
|
+
if (this.hasPendingSessionCommand()) {
|
|
450
|
+
this.retiringSockets.add(previousSocket);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
previousSocket.destroy();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.handleFrame(frame, socket);
|
|
459
|
+
}, (error) => this.handleProtocolError(error));
|
|
460
|
+
socket.once("close", () => {
|
|
461
|
+
detach();
|
|
462
|
+
this.socketReaders.delete(socket);
|
|
463
|
+
this.retiringSockets.delete(socket);
|
|
464
|
+
if (this.socket === socket) {
|
|
465
|
+
this.socket = null;
|
|
466
|
+
this.scheduleReconnectFailure();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
socket.once("error", () => {
|
|
470
|
+
// Close handling owns reconnect behavior.
|
|
471
|
+
});
|
|
472
|
+
this.socketReaders.set(socket, detach);
|
|
473
|
+
}
|
|
474
|
+
handleFrame(frame, sourceSocket) {
|
|
475
|
+
if (frame.type === "response") {
|
|
476
|
+
this.handleResponse(frame);
|
|
477
|
+
if (this.retiringSockets.has(sourceSocket) && !this.hasPendingSessionCommand()) {
|
|
478
|
+
sourceSocket.destroy();
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
switch (frame.type) {
|
|
483
|
+
case "session_state": {
|
|
484
|
+
const pending = this.pendingSessionSwitch;
|
|
485
|
+
this.applySessionState(frame, pending?.source ?? "local", pending?.reason ?? "local_follow", Boolean(this.state.sharedSessionId));
|
|
486
|
+
this.setStatus(this.state.status);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case "local_input":
|
|
490
|
+
if (typeof frame.text === "string" && frame.text.trim()) {
|
|
491
|
+
this.handleLocalInput(frame.text.trim());
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
case "agent_start":
|
|
495
|
+
if (!this.state.activeTurnOrigin) {
|
|
496
|
+
this.beginTurn("local");
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
case "assistant_message":
|
|
500
|
+
if (typeof frame.text === "string") {
|
|
501
|
+
this.currentAssistantText = normalizeOutput(frame.text).trim();
|
|
502
|
+
}
|
|
503
|
+
if (frame.stopReason === "error") {
|
|
504
|
+
this.currentTurnError =
|
|
505
|
+
typeof frame.errorMessage === "string"
|
|
506
|
+
? frame.errorMessage
|
|
507
|
+
: "Pi stopped because the model returned an error.";
|
|
508
|
+
}
|
|
509
|
+
break;
|
|
510
|
+
case "agent_settled":
|
|
511
|
+
void this.finalizeTurn();
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
handleLocalInput(text) {
|
|
516
|
+
this.emit({
|
|
517
|
+
type: "mirrored_user_input",
|
|
518
|
+
text,
|
|
519
|
+
origin: "local",
|
|
520
|
+
timestamp: nowIso(),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
beginTurn(origin) {
|
|
524
|
+
this.currentAssistantText = "";
|
|
525
|
+
this.currentTurnError = "";
|
|
526
|
+
this.state.activeTurnId = `${Date.now()}-${++this.requestCounter}`;
|
|
527
|
+
this.state.activeTurnOrigin = origin;
|
|
528
|
+
this.state.lastInputAt = nowIso();
|
|
529
|
+
this.setStatus("busy", origin === "wechat" ? "Pi TUI is handling a WeChat request." : "Pi TUI is working.");
|
|
530
|
+
}
|
|
531
|
+
async finalizeTurn() {
|
|
532
|
+
if (this.finalizingTurn || !this.state.activeTurnOrigin) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.finalizingTurn = true;
|
|
536
|
+
const origin = this.state.activeTurnOrigin;
|
|
537
|
+
try {
|
|
538
|
+
if (this.currentTurnError) {
|
|
539
|
+
if (origin === "wechat") {
|
|
540
|
+
this.emit({ type: "task_failed", message: this.currentTurnError, timestamp: nowIso() });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else if (origin === "wechat" && this.currentAssistantText) {
|
|
544
|
+
this.emit({ type: "final_reply", text: this.currentAssistantText, timestamp: nowIso() });
|
|
545
|
+
}
|
|
546
|
+
this.state.lastOutputAt = nowIso();
|
|
547
|
+
this.clearActiveTurn();
|
|
548
|
+
this.setStatus("idle");
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
this.finalizingTurn = false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
applySessionState(value, source, reason, emitSwitch) {
|
|
555
|
+
if (!isRecord(value) || typeof value.sessionId !== "string") {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const previousSessionId = this.state.sharedSessionId;
|
|
559
|
+
this.state.sharedSessionId = value.sessionId;
|
|
560
|
+
this.state.activeRuntimeSessionId = value.sessionId;
|
|
561
|
+
this.state.transcriptPath =
|
|
562
|
+
typeof value.sessionFile === "string" ? value.sessionFile : undefined;
|
|
563
|
+
if (emitSwitch && value.sessionId !== previousSessionId) {
|
|
564
|
+
this.emit({
|
|
565
|
+
type: "session_switched",
|
|
566
|
+
sessionId: value.sessionId,
|
|
567
|
+
source,
|
|
568
|
+
reason,
|
|
569
|
+
timestamp: nowIso(),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
sendCommand(command, payload = {}) {
|
|
574
|
+
this.assertConnected();
|
|
575
|
+
const socket = this.socket;
|
|
576
|
+
if (!socket || socket.destroyed) {
|
|
577
|
+
return Promise.reject(new Error("Native Pi TUI is not connected to the WeChat bridge."));
|
|
578
|
+
}
|
|
579
|
+
const id = `pi-tui-${++this.requestCounter}`;
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const timer = setTimeout(() => {
|
|
582
|
+
this.pendingRequests.delete(id);
|
|
583
|
+
reject(new Error(`Timed out waiting for Pi TUI command: ${command}`));
|
|
584
|
+
}, PI_TUI_REQUEST_TIMEOUT_MS);
|
|
585
|
+
this.pendingRequests.set(id, { command, resolve, reject, timer });
|
|
586
|
+
try {
|
|
587
|
+
socket.write(`${JSON.stringify({ id, type: command, ...payload })}\n`);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
this.pendingRequests.delete(id);
|
|
592
|
+
reject(error);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
handleResponse(frame) {
|
|
597
|
+
const id = typeof frame.id === "string" ? frame.id : "";
|
|
598
|
+
const pending = this.pendingRequests.get(id);
|
|
599
|
+
if (!pending) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.pendingRequests.delete(id);
|
|
603
|
+
clearTimeout(pending.timer);
|
|
604
|
+
if (frame.success === false) {
|
|
605
|
+
pending.reject(new Error(typeof frame.error === "string"
|
|
606
|
+
? frame.error
|
|
607
|
+
: `Pi TUI command failed: ${pending.command}`));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
pending.resolve(frame);
|
|
611
|
+
}
|
|
612
|
+
hasPendingSessionCommand() {
|
|
613
|
+
return [...this.pendingRequests.values()].some((pending) => pending.command === "new_session" || pending.command === "switch_session");
|
|
614
|
+
}
|
|
615
|
+
assertConnected() {
|
|
616
|
+
if (!this.child || !this.socket || this.socket.destroyed) {
|
|
617
|
+
throw new Error("Native Pi TUI is not connected to the WeChat bridge.");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
clearActiveTurn() {
|
|
621
|
+
this.state.activeTurnId = undefined;
|
|
622
|
+
this.state.activeTurnOrigin = undefined;
|
|
623
|
+
}
|
|
624
|
+
rejectPendingRequests(error) {
|
|
625
|
+
for (const pending of this.pendingRequests.values()) {
|
|
626
|
+
clearTimeout(pending.timer);
|
|
627
|
+
pending.reject(error);
|
|
628
|
+
}
|
|
629
|
+
this.pendingRequests.clear();
|
|
630
|
+
}
|
|
631
|
+
scheduleReconnectFailure() {
|
|
632
|
+
this.clearReconnectTimer();
|
|
633
|
+
if (this.disposing || !this.child) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
this.reconnectTimer = setTimeout(() => {
|
|
637
|
+
this.reconnectTimer = null;
|
|
638
|
+
if (!this.socket && !this.disposing) {
|
|
639
|
+
// Reset the turn state alongside the fatal error: leaving status at
|
|
640
|
+
// "busy" would keep rejecting every message with "still working"
|
|
641
|
+
// while /stop stays unavailable (no socket) and /reset is the only
|
|
642
|
+
// way out.
|
|
643
|
+
this.clearActiveTurn();
|
|
644
|
+
this.setStatus("idle", "The Pi TUI extension disconnected; the bridge is waiting for a reconnect.");
|
|
645
|
+
this.emit({
|
|
646
|
+
type: "fatal_error",
|
|
647
|
+
message: "The native Pi TUI bridge extension disconnected and did not reconnect.",
|
|
648
|
+
timestamp: nowIso(),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}, PI_TUI_RECONNECT_TIMEOUT_MS);
|
|
652
|
+
}
|
|
653
|
+
clearReconnectTimer() {
|
|
654
|
+
if (this.reconnectTimer) {
|
|
655
|
+
clearTimeout(this.reconnectTimer);
|
|
656
|
+
this.reconnectTimer = null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
scheduleConnectWarning() {
|
|
660
|
+
this.clearConnectWarningTimer();
|
|
661
|
+
this.connectWarningTimer = setTimeout(() => {
|
|
662
|
+
this.connectWarningTimer = null;
|
|
663
|
+
if (!this.extensionReady && !this.disposing && this.child) {
|
|
664
|
+
this.setStatus("starting", "Native Pi TUI is still loading; waiting for the bridge extension to connect.");
|
|
665
|
+
}
|
|
666
|
+
}, PI_TUI_CONNECT_WARNING_MS);
|
|
667
|
+
this.connectWarningTimer.unref?.();
|
|
668
|
+
}
|
|
669
|
+
clearConnectWarningTimer() {
|
|
670
|
+
if (this.connectWarningTimer) {
|
|
671
|
+
clearTimeout(this.connectWarningTimer);
|
|
672
|
+
this.connectWarningTimer = null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
handleProtocolError(error) {
|
|
676
|
+
if (!this.disposing) {
|
|
677
|
+
this.emit({ type: "fatal_error", message: error.message, timestamp: nowIso() });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
handleProcessExit(code, signal) {
|
|
681
|
+
this.clearConnectWarningTimer();
|
|
682
|
+
this.rejectPendingRequests(new Error(`Pi TUI exited (code=${code ?? "none"}, signal=${signal ?? "none"}).`));
|
|
683
|
+
this.child = null;
|
|
684
|
+
this.state.pid = undefined;
|
|
685
|
+
this.state.status = "stopped";
|
|
686
|
+
this.extensionReady = false;
|
|
687
|
+
this.clearActiveTurn();
|
|
688
|
+
if (!this.disposing) {
|
|
689
|
+
// Report unexpected exits as fatal errors, matching the other adapters:
|
|
690
|
+
// a bare "stopped" status is only logged by the bridge, so WeChat users
|
|
691
|
+
// would get no signal that the TUI is gone until their next message
|
|
692
|
+
// fails with a cryptic not-connected error.
|
|
693
|
+
this.emit({
|
|
694
|
+
type: "fatal_error",
|
|
695
|
+
message: `Pi TUI exited unexpectedly (code=${code ?? "none"}, signal=${signal ?? "none"}). Restart the bridge or use /reset to recover.`,
|
|
696
|
+
timestamp: nowIso(),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
setStatus(status, message) {
|
|
701
|
+
this.state.status = status;
|
|
702
|
+
this.emit({ type: "status", status, message, timestamp: nowIso() });
|
|
703
|
+
}
|
|
704
|
+
emit(event) {
|
|
705
|
+
this.eventSink(event);
|
|
706
|
+
}
|
|
707
|
+
}
|