cli-wechat-bridge 1.1.2 → 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.
@@ -0,0 +1,697 @@
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
+ this.emit({
640
+ type: "fatal_error",
641
+ message: "The native Pi TUI bridge extension disconnected and did not reconnect.",
642
+ timestamp: nowIso(),
643
+ });
644
+ }
645
+ }, PI_TUI_RECONNECT_TIMEOUT_MS);
646
+ }
647
+ clearReconnectTimer() {
648
+ if (this.reconnectTimer) {
649
+ clearTimeout(this.reconnectTimer);
650
+ this.reconnectTimer = null;
651
+ }
652
+ }
653
+ scheduleConnectWarning() {
654
+ this.clearConnectWarningTimer();
655
+ this.connectWarningTimer = setTimeout(() => {
656
+ this.connectWarningTimer = null;
657
+ if (!this.extensionReady && !this.disposing && this.child) {
658
+ this.setStatus("starting", "Native Pi TUI is still loading; waiting for the bridge extension to connect.");
659
+ }
660
+ }, PI_TUI_CONNECT_WARNING_MS);
661
+ this.connectWarningTimer.unref?.();
662
+ }
663
+ clearConnectWarningTimer() {
664
+ if (this.connectWarningTimer) {
665
+ clearTimeout(this.connectWarningTimer);
666
+ this.connectWarningTimer = null;
667
+ }
668
+ }
669
+ handleProtocolError(error) {
670
+ if (!this.disposing) {
671
+ this.emit({ type: "fatal_error", message: error.message, timestamp: nowIso() });
672
+ }
673
+ }
674
+ handleProcessExit(code, signal) {
675
+ this.clearConnectWarningTimer();
676
+ this.rejectPendingRequests(new Error(`Pi TUI exited (code=${code ?? "none"}, signal=${signal ?? "none"}).`));
677
+ this.child = null;
678
+ this.state.pid = undefined;
679
+ this.state.status = "stopped";
680
+ this.extensionReady = false;
681
+ this.clearActiveTurn();
682
+ if (!this.disposing) {
683
+ this.emit({
684
+ type: "status",
685
+ status: "stopped",
686
+ timestamp: nowIso(),
687
+ });
688
+ }
689
+ }
690
+ setStatus(status, message) {
691
+ this.state.status = status;
692
+ this.emit({ type: "status", status, message, timestamp: nowIso() });
693
+ }
694
+ emit(event) {
695
+ this.eventSink(event);
696
+ }
697
+ }