@aiti-ai/pi-web-bridge 0.1.0

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,1007 @@
1
+ /**
2
+ * pi-web-bridge — pi TUI extension: the /web command bridges this session to a browser.
3
+ *
4
+ * This is the TUI-side endpoint of the multi-agent frontend link (paired with
5
+ * pi-web-ext/aiti-attach-agent on the pi-web side):
6
+ *
7
+ * /web command
8
+ * 1. Starts a Unix-socket JSONL server (protocol = what aiti-attach-agent speaks).
9
+ * Commands: prompt/steer/follow_up/abort/get_state/get_messages/
10
+ * set_model/set_thinking_level/compact/set_session_name/get_commands
11
+ * get_state additionally carries steering/followUp queued text (mirror);
12
+ * queue echo: queue_update never reaches the extension event channel (upstream pi
13
+ * limitation), so the bridge synthesizes and broadcasts it from a text mirror
14
+ * (enqueue = this bridge's prompt/steer/follow_up while busy; dequeue =
15
+ * message_start(user); see broadcastQueueUpdate)
16
+ * 2. Forwards all agent events (message_* / tool_* / agent_* / turn_* / model_select…)
17
+ * 3. Detached-spawns pi-web (PI_WEB_SOCKET=sockPath); it survives pi exiting
18
+ * (skipped when the user picks "don't spawn" on the last page, or CLI --no-spawn —
19
+ * reuse an already-running instance instead)
20
+ * 4. Tells the user the browser URL to open
21
+ *
22
+ * Session ownership: the pi process remains the sole writer (this extension only reads
23
+ * ctx/pi APIs and never touches files), so there is no session contention.
24
+ * pi exits → socket drops → the pi-web side shows "disconnected".
25
+ *
26
+ * Auto-bridge (env PI_WEB_BRIDGE_AUTO=1, runs on session_start, no /web needed):
27
+ * - CLI full-auto: a plain terminal pi session (no pi-web host marker) auto-attaches
28
+ * the bridge on startup; any pi-web instance (dev/prod) can open the session
29
+ * (All Sessions shows the tui-bridge tag), live view + bidirectional IO.
30
+ * Optional PI_WEB_BRIDGE_BASE points at a running pi-web (used for aiti_* tool
31
+ * routing / URL derivation; the attach itself goes through bridge-registry.json and
32
+ * does not depend on that address).
33
+ * - Subagent auto (§5.4): pi-web host spawn injection + PI_SUBAGENT_CHILD=1, same behavior.
34
+ * - pi-web in-process agents (PI_WEB_CHILD_MARKER): never auto-bridge / never
35
+ * register /web (already inside the web).
36
+ *
37
+ * Install: cp this file to ~/.pi/agent/extensions/pi-web-bridge.ts
38
+ */
39
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
40
+ import { createServer, type Server, type Socket } from "node:net";
41
+ import { readFile, writeFile } from "node:fs/promises";
42
+ import { homedir, platform } from "node:os";
43
+ import { spawn, type ChildProcess } from "node:child_process";
44
+ // Shared bridge registry module (same package ../bridge-registry.ts; deployed along
45
+ // with the directory when installed as a directory extension)
46
+ import { registerBridge, unregisterBridge, bridgeSocketName } from "./bridge-registry";
47
+
48
+ // ── Protocol constants (aligned with aiti-attach-agent) ────────────────────────────────
49
+ const FORWARDED_EVENTS = [
50
+ "message_start", "message_update", "message_end",
51
+ "tool_execution_start", "tool_execution_update", "tool_execution_end",
52
+ "agent_start", "agent_end", "agent_settled",
53
+ "turn_start", "turn_end",
54
+ "model_select", "thinking_level_select",
55
+ "session_info_changed",
56
+ ];
57
+
58
+ // Shared bridge registry module (same package ../bridge-registry.ts): TUI writes, Web
59
+ // reads, single owner of the format
60
+
61
+ // ── Message i18n (the pi extension layer has no i18n mechanism; minimal string table) ──
62
+ // Language: env PI_WEB_BRIDGE_LANG=zh|en explicit > LC_ALL/LANG/LANGUAGE starting with
63
+ // "zh" → Chinese; otherwise English. Fixed at process start, never switches at runtime.
64
+ type BridgeLang = "zh" | "en";
65
+ function detectBridgeLang(): BridgeLang {
66
+ const explicit = (process.env.PI_WEB_BRIDGE_LANG || "").trim().toLowerCase();
67
+ if (explicit === "en" || explicit === "zh") return explicit;
68
+ const lc = process.env.LC_ALL || process.env.LANG || process.env.LANGUAGE || "";
69
+ return /^zh/i.test(lc.trim()) ? "zh" : "en";
70
+ }
71
+ const ZH_M = {
72
+ customArgsTitle: "自定义参数(可留空直接回车;示例:--base https://pi.example.com:8443/dev | --domain pi.example.com --scheme https)",
73
+ modeTitle: "启动方式",
74
+ modeTakeover: "接管(不起桥:pi-web 内部跑 pi SDK 接手本会话,TUI 不再输入)",
75
+ modeAttach: "attach(起 rpc 桥:pi-web 经 attach 连本会话,IO 转发一起工作)",
76
+ finalTitle: "pi-web 服务(最后一步)",
77
+ finalSpawnOpen: "启动并打开浏览器",
78
+ finalSpawnNoOpen: "只启动,不打开浏览器",
79
+ finalNoSpawn: "不启动(连接已在运行的 pi-web)",
80
+ addrInput: "已有 pi-web 地址(留空回车 = 按 --port/env 推导 http://127.0.0.1:<port>)",
81
+ stopped: "pi-web bridge stopped(pi-web 与 rpc 已关闭)",
82
+ notRunning: "pi-web bridge not running",
83
+ runningTitle: (u: string) => `pi-web bridge 已运行 → ${u}`,
84
+ openBrowser: "打开浏览器",
85
+ noOpenBrowser: "不打开浏览器",
86
+ showInfo: "显示连接信息(URL + 执行的参数)",
87
+ stop: "停止(关闭 pi-web 与 rpc)",
88
+ stillRunning: (u: string) => `桥仍运行中:${u}`,
89
+ infoMsg: (u: string, c: string) => `pi-web bridge: ${u}\n执行的参数:${c}`,
90
+ alreadyRunning: (p: string) => `pi-web bridge already running: ${p}`,
91
+ noSession: "no persisted session to bridge (send a message first)",
92
+ attached: (u: string) => `pi-web attached → ${u}(IO 沿桥转发,一起工作)`,
93
+ takeoverDone: (u: string) => `pi-web 接管 → ${u}(本会话已移交浏览器操作;TUI 请退出本会话避免双写)`,
94
+ lastCommandLabel: "\n执行的参数:",
95
+ idleShutdown: (min: string) => `自动桥空闲超时(${min} 分钟无活动),退出本会话——PI_WEB_IDLE_TIMEOUT_MS 可调,0 禁用`,
96
+ };
97
+ const EN_M: typeof ZH_M = {
98
+ customArgsTitle: "Custom args (Enter to skip; e.g. --base https://pi.example.com:8443/dev | --domain pi.example.com --scheme https)",
99
+ modeTitle: "Start mode",
100
+ modeTakeover: "Takeover (no bridge: pi-web runs pi SDK in-process; TUI stops accepting input)",
101
+ modeAttach: "Attach (rpc bridge: pi-web attaches to this session; IO forwarded, work together)",
102
+ finalTitle: "pi-web server (final step)",
103
+ finalSpawnOpen: "Spawn new + open browser",
104
+ finalSpawnNoOpen: "Spawn new, don't open browser",
105
+ finalNoSpawn: "Don't spawn (connect to an already-running pi-web)",
106
+ addrInput: "Existing pi-web URL (empty Enter = derive from --port/env as http://127.0.0.1:<port>)",
107
+ stopped: "pi-web bridge stopped (pi-web & rpc closed)",
108
+ notRunning: "pi-web bridge not running",
109
+ runningTitle: (u: string) => `pi-web bridge already running → ${u}`,
110
+ openBrowser: "Open browser",
111
+ noOpenBrowser: "Don't open browser",
112
+ showInfo: "Show connection info (URL + executed args)",
113
+ stop: "Stop (close pi-web & rpc)",
114
+ stillRunning: (u: string) => `Bridge still running: ${u}`,
115
+ infoMsg: (u: string, c: string) => `pi-web bridge: ${u}\nExecuted as: ${c}`,
116
+ alreadyRunning: (p: string) => `pi-web bridge already running: ${p}`,
117
+ noSession: "no persisted session to bridge (send a message first)",
118
+ attached: (u: string) => `pi-web attached → ${u} (IO forwarded over the bridge, work together)`,
119
+ takeoverDone: (u: string) => `pi-web takeover → ${u} (session handed to the browser; exit this TUI session to avoid double writes)`,
120
+ lastCommandLabel: "\nExecuted as: ",
121
+ idleShutdown: (min: string) => `Auto-bridge idle timeout (${min} min), exiting this session — set PI_WEB_IDLE_TIMEOUT_MS to adjust, 0 to disable`,
122
+ };
123
+ const S = ({ zh: ZH_M, en: EN_M })[detectBridgeLang()];
124
+
125
+ /** Parse /web args: --port 3000 / --no-open / --open / --no-spawn / --spawn */
126
+ function parseWebArgs(raw: string): {
127
+ hasArgs: boolean;
128
+ open?: boolean;
129
+ spawn?: boolean;
130
+ port?: string;
131
+ basePath?: string;
132
+ base?: string;
133
+ scheme?: string;
134
+ domain?: string;
135
+ webport?: string;
136
+ takeover?: boolean;
137
+ } {
138
+ const out: ReturnType<typeof parseWebArgs> = { hasArgs: false };
139
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
140
+ if (tokens.length === 0) return out;
141
+ out.hasArgs = true;
142
+ for (let i = 0; i < tokens.length; i++) {
143
+ const t = tokens[i];
144
+ const next = () => {
145
+ const v = tokens[i + 1];
146
+ if (v && !v.startsWith("--")) { i++; return v; }
147
+ return undefined;
148
+ };
149
+ if (t === "--port") { const v = next(); if (v) out.port = v; }
150
+ else if (t === "--base") { const v = next(); if (v) out.base = v; }
151
+ else if (t === "--basepath" || t === "--url") { const v = next(); if (v) out.basePath = v; }
152
+ else if (t === "--scheme") { const v = next(); if (v) out.scheme = v; }
153
+ else if (t === "--domain" || t === "--host") { const v = next(); if (v) out.domain = v; }
154
+ else if (t === "--webport") { const v = next(); if (v) out.webport = v; }
155
+ else if (t === "--takeover") out.takeover = true;
156
+ else if (t === "--attach") out.takeover = false;
157
+ else if (t === "--no-open") out.open = false;
158
+ else if (t === "--open") out.open = true;
159
+ else if (t === "--no-spawn") out.spawn = false;
160
+ else if (t === "--spawn") out.spawn = true;
161
+ // Unknown tokens are ignored (lenient: never blow up on user input)
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * Resolve the public URL prefix. Priority:
168
+ * 1) --base / --url / a --basepath containing "://" / env PI_WEB_BASE_PATH (whole prefix)
169
+ * 2) --domain (+ --scheme / --webport / --basepath path) combination
170
+ * 3) empty → caller falls back to local http://127.0.0.1:<port>/
171
+ */
172
+ function resolveBridgeBase(flags: { base?: string; url?: string; basePath?: string; scheme?: string; domain?: string; webport?: string }): string {
173
+ const whole = flags.base || flags.url
174
+ || (flags.basePath && flags.basePath.includes("://") ? flags.basePath : undefined)
175
+ || process.env.PI_WEB_BASE_PATH;
176
+ if (whole) return whole.replace(/\/+$/, "");
177
+ const scheme = flags.scheme || "http";
178
+ if (flags.domain) {
179
+ const host = flags.domain.includes(":") ? flags.domain : flags.domain + (flags.webport ? `:${flags.webport}` : "");
180
+ const path = flags.basePath ? "/" + flags.basePath.replace(/^\/+|\/+$/g, "") : "";
181
+ return `${scheme}://${host}${path}`;
182
+ }
183
+ return "";
184
+ }
185
+
186
+ /**
187
+ * Plumb the URL-prefix config into the spawned pi-web process:
188
+ * - PI_WEB_BASE_DOMAIN / PI_WEB_ALLOWED_HOSTS ← public domain (guards against DNS
189
+ * rebinding 403s)
190
+ * - PI_WEB_BASE_PATH ← subpath (effective for next dev / built via PI_WEB_BUILD_BASEPATH)
191
+ * Configuring the URL text alone does not take effect — without this layer the browser
192
+ * reaches pi-web through a reverse proxy and gets rejected.
193
+ */
194
+ function applyBridgeDomainEnv(flags: { base?: string; url?: string; basePath?: string; domain?: string }, env: Record<string, string>): void {
195
+ try {
196
+ const whole = flags.base || flags.url
197
+ || (flags.basePath && flags.basePath.includes("://") ? flags.basePath : undefined);
198
+ const u = whole ? new URL(whole) : flags.domain ? new URL(`http://${flags.domain}`) : null;
199
+ if (u) {
200
+ const host = u.host;
201
+ const hostOnly = host.split(":")[0];
202
+ env.PI_WEB_BASE_DOMAIN = host;
203
+ // The proxied Host header may or may not carry a port — allow both (avoid DNS
204
+ // rebinding false kills)
205
+ env.PI_WEB_ALLOWED_HOSTS = host === hostOnly ? host : `${host},${hostOnly}`;
206
+ if (u.pathname && u.pathname !== "/") env.PI_WEB_BASE_PATH = u.pathname.replace(/\/+$/, "");
207
+ }
208
+ } catch {
209
+ /* Parse failure ignored: URL text still goes through the resolveBridgeBase fallback */
210
+ }
211
+ }
212
+
213
+ function openBrowser(url: string): void {
214
+ if (platform() === "win32") spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
215
+ else if (platform() === "darwin") spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
216
+ else spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
217
+ }
218
+
219
+ export default function (pi: ExtensionAPI) {
220
+ // ── Auto-bridge mode (aiti fork, §5.4 + CLI full-auto) ────────────────────────────────
221
+ // Switch = PI_WEB_BRIDGE_AUTO=1; two kinds of automation depending on process identity:
222
+ // ① HEADLESS_AUTO (subagents): injected by the pi-web host (instrumentation.ts, spawn
223
+ // env passthrough) + PI_SUBAGENT_CHILD=1 (the upstream pi-args marker stamped on
224
+ // every child agent process).
225
+ // Must bypass the host guard — subagent spawn env carries PI_WEB_SOCKET and other
226
+ // host markers.
227
+ // ② CLI_AUTO (CLI full-auto): the user's shell profile exports PI_WEB_BRIDGE_AUTO=1;
228
+ // a plain terminal pi session (including -c/--resume) starts the bridge on boot.
229
+ // The key discriminator = PI_WEB_CHILD_MARKER (stamped by pi-web instrumentation on
230
+ // itself and all child processes): CLI sessions never carry it, pi-web in-process /
231
+ // its children always do.
232
+ // Auto behavior = equivalent to "/web --attach --no-spawn --no-open": no wizard, no
233
+ // browser; start bridge + write bridge-registry → opening the session in pi-web
234
+ // attaches to it (non-exclusive, bidirectional IO).
235
+ const AUTO_ENV = process.env.PI_WEB_BRIDGE_AUTO === "1";
236
+ const SUBAGENT_CHILD = process.env.PI_SUBAGENT_CHILD === "1";
237
+ const IN_PI_WEB_HOST = process.env.PI_WEB_CHILD_MARKER !== undefined;
238
+ const HEADLESS_AUTO = AUTO_ENV && SUBAGENT_CHILD; // ① subagent
239
+ const CLI_AUTO = AUTO_ENV && !SUBAGENT_CHILD && !IN_PI_WEB_HOST; // ② CLI full-auto
240
+ const AUTO_BRIDGE = HEADLESS_AUTO || CLI_AUTO;
241
+ // pi-web in-process agents (plan M / spawn backend) also load this extension from
242
+ // ~/.pi/agent/extensions — but there is no TUI there, and /web would leak into the
243
+ // pi-web AI page command list (verified on dev/prod). Host detection = PI_WEB_CHILD_MARKER
244
+ // or any other PI_WEB_* env. ⚠ PI_WEB_BRIDGE_* and PI_WEB_IDLE_TIMEOUT_MS are this
245
+ // extension's user-config namespace (legitimately exported in the shell) and do NOT
246
+ // count as host markers — otherwise CLI_AUTO would self-exclude (PI_WEB_IDLE_TIMEOUT_MS
247
+ // bit us in production: with it set, the auto bridge never registered).
248
+ const HOST_ENV = Object.keys(process.env).some(
249
+ (k) => k.startsWith("PI_WEB_")
250
+ && k !== "PI_WEB_BRIDGE_AUTO" && k !== "PI_WEB_BRIDGE_BASE"
251
+ && k !== "PI_WEB_BRIDGE_PORT" && k !== "PI_WEB_BRIDGE_LANG"
252
+ && k !== "PI_WEB_IDLE_TIMEOUT_MS",
253
+ );
254
+ if (!HEADLESS_AUTO && (IN_PI_WEB_HOST || HOST_ENV)) {
255
+ console.log("[pi-web-bridge] skipping /web registration (in-process pi-web agent — this extension only registers in TUI sessions)");
256
+ return;
257
+ }
258
+ let server: Server | null = null;
259
+ let child: ChildProcess | null = null;
260
+ let sockPath: string | null = null;
261
+ let webPort: string | null = null;
262
+ let bridgeUrl: string | null = null; // remembered bridge URL (idempotent display; cleared by /web off)
263
+ let registeredFile: string | null = null; // current registry key (follows in-process /new, /resume switches)
264
+ let lastCommand: string | null = null; // remembered "executed args" (next /web <args> skips the wizard)
265
+ let lastTools: Array<{ name: string; description: string; active: boolean; parameters?: Record<string, unknown> }> = []; // tools snapshot from the model request (get_tools data source)
266
+ const clients = new Set<Socket>();
267
+ // Page registry (merged single data stream): each attach connection pushes the set of
268
+ // pageIds served by its own bridge; forwarding routes by args.pageId to the rpc that
269
+ // registered it. Older instances that never push (e.g. production) simply never
270
+ // participate.
271
+ const relayPages = new Map<Socket, Set<string>>();
272
+ let latestCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
273
+ // Reverse-RPC facade (published to globalThis.__aitiTuiRelay when /web is active, so
274
+ // the connector bundle can handshake across modules)
275
+ let installTuiRelay: { active(): boolean; call(tool: string, args: Record<string, unknown>): Promise<unknown> } | null = null;
276
+
277
+ // ── Auto-bridge idle timeout exit (CLI_AUTO only) ─────────────────────────────────────
278
+ // Time control mirrors pi-web inprocess (rpc sessions) idle close: same env
279
+ // PI_WEB_IDLE_TIMEOUT_MS (default 10 min, 0 = disabled, invalid falls back to default
280
+ // with a warning; parse rules aligned with pi-web lib/rpc-manager.ts).
281
+ // Purpose: when a TUI left running by the shell-profile full-auto bridge
282
+ // (PI_WEB_BRIDGE_AUTO=1) sits unused for a long time, don't leave residue
283
+ // (process + bridge socket + the connector's persistent registration retries).
284
+ // Still active at expiry (pi working / a page attached) → re-arm; only truly idle
285
+ // → ctx.shutdown() (Interactive mode already defers to idle when busy).
286
+ // Manual /web (explicit user action) and HEADLESS_AUTO (subagents managed by their
287
+ // parent flow) never auto-exit.
288
+ const IDLE_TIMEOUT_MS = (() => {
289
+ const raw = process.env.PI_WEB_IDLE_TIMEOUT_MS;
290
+ if (raw !== undefined && String(raw).trim() !== "") {
291
+ const parsed = Number(raw);
292
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 2_147_483_647) return parsed;
293
+ console.warn(`[pi-web-bridge] invalid PI_WEB_IDLE_TIMEOUT_MS "${raw}", falling back to 10 minutes`);
294
+ }
295
+ return 10 * 60 * 1000;
296
+ })();
297
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
298
+ function onIdleTimeout(): void {
299
+ idleTimer = null;
300
+ // Soft check at expiry (rpc-manager-style re-arm semantics): a page attached / pi
301
+ // busy → re-arm instead of closing
302
+ if (clients.size > 0) return idleArm();
303
+ if (latestCtx && !latestCtx.isIdle()) return idleArm();
304
+ try { latestCtx?.ui.notify(S.idleShutdown(String(Math.round(IDLE_TIMEOUT_MS / 60000))), "info"); } catch { /* notify unavailable: don't block */ }
305
+ try { latestCtx?.shutdown(); } catch { /* shutdown failure: cleaned up on next start */ }
306
+ }
307
+ const idleArm = () => {
308
+ if (!CLI_AUTO || IDLE_TIMEOUT_MS === 0) return;
309
+ if (idleTimer) clearTimeout(idleTimer);
310
+ idleTimer = setTimeout(onIdleTimeout, IDLE_TIMEOUT_MS);
311
+ };
312
+ const idlePoke = () => { if (CLI_AUTO && IDLE_TIMEOUT_MS !== 0) idleArm(); };
313
+
314
+ // ── Queue mirror (synthesized queue_update echo) ──────────────────────────────────────
315
+ // pi's queue_update never reaches the extension event channel (upstream
316
+ // _emitExtensionEvent only forwards 8 event types, so pi.on("queue_update") never
317
+ // fires) — followUp/steer messages queued while the TUI streams are completely
318
+ // invisible on the pi-web side (empty queue panel, no chat bubble; retry throttling
319
+ // can queue for minutes), i.e. "followUp sent from pi-web has no echo". The bridge
320
+ // keeps its own text mirror and broadcasts synthesized queue_updates:
321
+ // enqueue = this bridge's prompt/steer/follow_up while the TUI is busy
322
+ // (!ctx.isIdle()) → text enters the mirror;
323
+ // dequeue = forwarded message_start(role=user) pops the matching text in pi's own
324
+ // order (steering first, then followUp);
325
+ // self-heal = get_state sees pendingMessageCount=0 while the mirror is non-empty
326
+ // (TUI cleared its local queue) → clear and rebroadcast.
327
+ // Payload {type:"queue_update",steering:[],followUp:[]} matches pi's native shape
328
+ // (no rev; the frontend applies local session semantics). Both enqueue sources are
329
+ // covered: bridge commands (prompt/steer/follow_up while busy) + terminal-local
330
+ // enqueues (input event with source==="interactive" and non-empty streamingBehavior,
331
+ // see registerEventListeners).
332
+ const queueMirror: { steering: string[]; followUp: string[] } = { steering: [], followUp: [] };
333
+ const broadcastQueueUpdate = () => {
334
+ broadcast({ type: "queue_update", steering: [...queueMirror.steering], followUp: [...queueMirror.followUp] });
335
+ };
336
+ const queueMirrorPush = (kind: "steering" | "followUp", text: string) => {
337
+ queueMirror[kind].push(text);
338
+ broadcastQueueUpdate();
339
+ };
340
+ const queueMirrorPopDelivered = (message: unknown): void => {
341
+ const msg = message as { role?: unknown; content?: unknown } | undefined;
342
+ if (!msg || msg.role !== "user") return;
343
+ // Text extraction matches pi's contentText(content, ""): concat type==="text" blocks
344
+ const text = Array.isArray(msg.content)
345
+ ? (msg.content as Array<{ type?: unknown; text?: unknown }>)
346
+ .filter((c) => c && c.type === "text")
347
+ .map((c) => String(c.text ?? ""))
348
+ .join("")
349
+ : "";
350
+ if (!text) return;
351
+ let popped = false;
352
+ const si = queueMirror.steering.indexOf(text);
353
+ if (si !== -1) { queueMirror.steering.splice(si, 1); popped = true; }
354
+ else {
355
+ const fi = queueMirror.followUp.indexOf(text);
356
+ if (fi !== -1) { queueMirror.followUp.splice(fi, 1); popped = true; }
357
+ }
358
+ if (popped) broadcastQueueUpdate();
359
+ };
360
+ const queueMirrorHeal = (ctx: NonNullable<typeof latestCtx>): void => {
361
+ if (!ctx.hasPendingMessages() && (queueMirror.steering.length > 0 || queueMirror.followUp.length > 0)) {
362
+ queueMirror.steering = [];
363
+ queueMirror.followUp = [];
364
+ broadcastQueueUpdate();
365
+ }
366
+ };
367
+ // Image attachments sent from pi-web (same shape as validateAgentImages:
368
+ // {type:"image",data,mimeType}) — strictly validated per item, then passed through to
369
+ // sendUserMessage (previously dropped silently: queued/steered messages with images
370
+ // lost their attachments). The mirror still records text only (queue_update payload
371
+ // is text-only by design).
372
+ const parseImages = (raw: unknown): Array<{ type: "image"; data: string; mimeType: string }> => {
373
+ if (!Array.isArray(raw)) return [];
374
+ const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
375
+ for (const im of raw) {
376
+ if (typeof im !== "object" || im === null) continue;
377
+ const o = im as Record<string, unknown>;
378
+ if (o.type !== "image" || typeof o.data !== "string" || typeof o.mimeType !== "string") continue;
379
+ out.push({ type: "image", data: o.data, mimeType: o.mimeType });
380
+ }
381
+ return out;
382
+ };
383
+
384
+ const broadcast = (event: Record<string, unknown>) => {
385
+ // Slimming (O(n²) serialization-storm fix): message_update carries the message
386
+ // field plus assistantMessageEvent.partial (both the ever-growing full message
387
+ // text); streaming fires message_update on every chunk — without stripping, every
388
+ // broadcast re-stringifies the accumulated full text → O(n²) storm (measured 40-90%
389
+ // CPU; long replies blocked >3s, looked like a hang). Consumers (pi-web
390
+ // agent-event-wire.toClientAgentEvent / handle-adapter) only consume deltas and the
391
+ // official implementation strips partial too; the full message arrives via the
392
+ // low-frequency message_end, so broadcasting deltas only loses nothing.
393
+ // Short-circuit: when /web was never run (clients empty) skip even stringify — this
394
+ // extension is unconditionally loaded by pi-web in-process agents which register
395
+ // event listeners; stringify-per-event just to write to 0 clients burns CPU on air
396
+ // (measured 40-90% of the main thread).
397
+ if (clients.size === 0) return;
398
+ if (event.type === "message_update") {
399
+ const ae = event.assistantMessageEvent as Record<string, unknown> | undefined;
400
+ if (typeof ae === "object" && ae !== null && !Array.isArray(ae)) {
401
+ const { partial: _partial, ...delta } = ae;
402
+ void _partial;
403
+ let next: Record<string, unknown> = delta;
404
+ // Matches the official toJsonAssistantMessageEvent: toolcall_start's
405
+ // id/toolName live only in partial.content[contentIndex] — extract them before
406
+ // stripping partial, otherwise the consumer's streaming reducer drops
407
+ // toolcall_start for missing toolName and the tool block only renders at
408
+ // toolcall_end.
409
+ if (ae.type === "toolcall_start") {
410
+ const p = (ae as { partial?: unknown }).partial;
411
+ const content = (typeof p === "object" && p !== null ? (p as { content?: unknown }).content : undefined);
412
+ const idx = typeof ae.contentIndex === "number" ? ae.contentIndex : -1;
413
+ const block = (Array.isArray(content) ? content[idx] : undefined) as Record<string, unknown> | undefined;
414
+ if (block && block.type === "toolCall") {
415
+ next = { ...delta, id: block.id, toolName: block.name };
416
+ }
417
+ }
418
+ event = { type: "message_update", assistantMessageEvent: next };
419
+ }
420
+ }
421
+ const data = JSON.stringify(event) + "\n";
422
+ for (const c of clients) { try { c.write(data); } catch { clients.delete(c); } } // EPIPE: evict the client, never crash the process
423
+ };
424
+
425
+ // ── ctx capture (needed by abort/state) + event forwarding ──
426
+ pi.on("session_start", async (_event, ctx) => {
427
+ latestCtx = ctx;
428
+ // Auto-start the bridge (headless subagent / CLI full-auto): equivalent to
429
+ // /web --attach --no-spawn --no-open
430
+ // (idempotent: skips when the server is already running; after /web off, a new
431
+ // in-process session re-auto-bridges — off only affects the current state)
432
+ if (AUTO_BRIDGE && !server) {
433
+ try {
434
+ const base = process.env.PI_WEB_BRIDGE_BASE || "";
435
+ await runWeb(`--attach --no-spawn --no-open${base ? ` --base ${base}` : ""}`, ctx);
436
+ console.log(`[pi-web-bridge] auto-bridge started (${HEADLESS_AUTO ? "headless subagent" : "CLI"} mode, registered in registry)`);
437
+ idleArm(); // idle clock starts once the bridge is up (CLI_AUTO only; 0 disables / no-op otherwise)
438
+ } catch (e) {
439
+ console.error("[pi-web-bridge] auto-bridge failed:", e instanceof Error ? e.message : String(e));
440
+ }
441
+ return;
442
+ }
443
+ // In-process session switch (/new, /resume → session_start reason=new|resume):
444
+ // re-register the registry against the current session file (socket reuse — event
445
+ // broadcast is process-level anyway). Without re-registering, the registry still
446
+ // points at the old file and opening the new session in the web takes the local
447
+ // open path = double-writer risk (the registry exists precisely to prevent it).
448
+ if (AUTO_BRIDGE && server) {
449
+ try {
450
+ const file = ctx.sessionManager.getSessionFile() ?? "";
451
+ if (file && file !== registeredFile) {
452
+ if (registeredFile) await unregisterBridge(registeredFile);
453
+ await registerBridge(file);
454
+ registeredFile = file;
455
+ console.log("[pi-web-bridge] session switched → registry re-registered (socket reused)");
456
+ }
457
+ idlePoke(); // session switch (/new, /resume) = activity signal
458
+ } catch (e) {
459
+ console.error("[pi-web-bridge] registry re-register on session switch failed:", e instanceof Error ? e.message : String(e));
460
+ }
461
+ }
462
+ });
463
+ // Event-forwarding listeners are no longer registered unconditionally at the top
464
+ // level (pi.on cannot be unsubscribed and lives for the host process):
465
+ // pi-web in-process agents also load this extension, and registering before /web
466
+ // means every streaming event calls broadcast pointlessly (even short-circuited,
467
+ // that's an extra dispatch). Register only when /web activates (see
468
+ // registerEventListeners); unbridged events never reach this extension at all.
469
+ let eventsRegistered = false;
470
+ const registerEventListeners = () => {
471
+ if (eventsRegistered) return;
472
+ for (const evt of FORWARDED_EVENTS) {
473
+ // Event payloads differ in shape but are all spread and forwarded; route through
474
+ // unknown to avoid per-event overloads
475
+ const handler = (event: unknown) => {
476
+ // Queue dequeue: a user message starting delivery = pi already removed it from
477
+ // its own queue (same ordering as _handleAgentEvent; queue_update reaches the
478
+ // consumer before message_start)
479
+ if (evt === "message_start") queueMirrorPopDelivered((event as { message?: unknown }).message);
480
+ broadcast({ type: evt, ...(event as Record<string, unknown>) }); idlePoke();
481
+ };
482
+ (pi.on as (e: string, h: (x: unknown) => void) => void)(evt, handler);
483
+ }
484
+ // Terminal enqueue awareness (blind-spot fix 2026-10-17): while the TUI streams,
485
+ // Enter = prompt({streamingBehavior: "steer"}), Alt+Enter = "followUp"; pi fires
486
+ // the input extension event before its queueing decision (a separate direct
487
+ // channel that bypasses the _emitExtensionEvent whitelist, so pi.on("input")
488
+ // receives it) with payload {text, source, streamingBehavior} — non-empty means it
489
+ // will be queued. Only source==="interactive" counts (bridge-driven commands have
490
+ // source==="extension" and were already pushed at the command entry; no double
491
+ // count). undefined = idle submit (runs immediately; the bubble arrives via
492
+ // message_end and never enters the mirror). If a push ends up not actually queued
493
+ // (race: the run finishes inside the emitInput await gap), the text is popped
494
+ // normally when the first user message of the new run arrives via
495
+ // message_start/end — the mirror stays consistent.
496
+ (pi.on as (e: string, h: (x: unknown) => void) => void)("input", (raw: unknown) => {
497
+ const ev = raw as { source?: unknown; text?: unknown; streamingBehavior?: unknown };
498
+ if (ev.source !== "interactive") return;
499
+ if (ev.streamingBehavior !== "steer" && ev.streamingBehavior !== "followUp") return;
500
+ const text = String(ev.text ?? "");
501
+ if (!text) return;
502
+ queueMirrorPush(ev.streamingBehavior === "followUp" ? "followUp" : "steering", text);
503
+ });
504
+ // Tools snapshot: before_provider_request's payload = the model request body
505
+ // (including the tools array) — the real data source for get_tools (the extension
506
+ // API has no tools read; without this listener there is no way to get it).
507
+ (pi.on as (e: string, h: (x: unknown) => void) => void)("before_provider_request", (raw: unknown) => {
508
+ idlePoke(); // a model request going out = activity signal
509
+ const payload = (raw as { payload?: { tools?: unknown } } | undefined)?.payload;
510
+ const list = payload?.tools;
511
+ if (!Array.isArray(list)) return;
512
+ lastTools = (list as Array<Record<string, unknown>>).map((t) => {
513
+ const fn = (typeof t.function === "object" && t.function !== null ? t.function : t) as {
514
+ name?: unknown; description?: unknown; parameters?: Record<string, unknown>;
515
+ };
516
+ return {
517
+ name: String(fn.name ?? t.name ?? ""),
518
+ description: String(fn.description ?? t.description ?? ""),
519
+ active: true,
520
+ ...(fn.parameters !== undefined ? { parameters: fn.parameters } : {}),
521
+ };
522
+ });
523
+ });
524
+ eventsRegistered = true;
525
+ };
526
+
527
+ const reply = (socket: Socket, id: string, success: boolean, data?: unknown, error?: string) => {
528
+ // EPIPE (peer already gone) must never become an uncaught exception — that would
529
+ // kill the whole TUI process (confirmed in the field, 06:58:38)
530
+ try {
531
+ socket.write(JSON.stringify({ id, type: "response", success, ...(success ? { data } : { error }) }) + "\n");
532
+ } catch { try { clients.delete(socket); } catch { /* ignore */ } }
533
+ };
534
+
535
+ const handleCommand = async (socket: Socket, cmd: Record<string, unknown>) => {
536
+ const id = String(cmd.id ?? "");
537
+ const type = String(cmd.type ?? "");
538
+ const ctx = latestCtx;
539
+ try {
540
+ switch (type) {
541
+ case "prompt": {
542
+ const text = String(cmd.text ?? "");
543
+ const images = parseImages(cmd.images);
544
+ const content = images.length ? [{ type: "text" as const, text }, ...images] : text;
545
+ // TUI busy → pi will queue the message (prompt()'s streamingBehavior branch)
546
+ // → push into the mirror for the synthesized echo; idle → runs immediately
547
+ // and the user bubble echoes naturally via message_start/end (not mirrored).
548
+ // The mirror records text only: dequeue matching uses contentText
549
+ // (type==="text" blocks), so image-bearing messages are unaffected.
550
+ if (ctx && !ctx.isIdle()) queueMirrorPush(cmd.streamingBehavior === "followUp" ? "followUp" : "steering", text);
551
+ pi.sendUserMessage(content, { deliverAs: cmd.streamingBehavior === "followUp" ? "followUp" : "steer" });
552
+ idlePoke(); // page-driven interaction = activity signal
553
+ return reply(socket, id, true);
554
+ }
555
+ case "steer": {
556
+ const text = String(cmd.text ?? "");
557
+ const images = parseImages(cmd.images);
558
+ if (ctx && !ctx.isIdle()) queueMirrorPush("steering", text);
559
+ pi.sendUserMessage(images.length ? [{ type: "text" as const, text }, ...images] : text, { deliverAs: "steer" });
560
+ idlePoke();
561
+ return reply(socket, id, true);
562
+ }
563
+ case "follow_up": {
564
+ const text = String(cmd.text ?? "");
565
+ const images = parseImages(cmd.images);
566
+ if (ctx && !ctx.isIdle()) queueMirrorPush("followUp", text);
567
+ pi.sendUserMessage(images.length ? [{ type: "text" as const, text }, ...images] : text, { deliverAs: "followUp" });
568
+ idlePoke();
569
+ return reply(socket, id, true);
570
+ }
571
+ case "abort":
572
+ if (!ctx) throw new Error("no active context");
573
+ ctx.abort();
574
+ return reply(socket, id, true);
575
+ case "get_state": {
576
+ if (!ctx) throw new Error("no active context");
577
+ queueMirrorHeal(ctx); // self-heal after TUI-local queue clears (pendingMessageCount=0 with non-empty mirror → clear + rebroadcast)
578
+ return reply(socket, id, true, {
579
+ sessionId: ctx.sessionManager.getSessionId?.() ?? "",
580
+ isStreaming: !ctx.isIdle(),
581
+ model: ctx.model ? { id: ctx.model.id, provider: ctx.model.provider } : undefined,
582
+ thinkingLevel: ctx.thinkingLevel,
583
+ pendingMessageCount: ctx.hasPendingMessages() ? 1 : 0,
584
+ // Queued text (mirror): data source for the pi-web attach backend's
585
+ // queueManage(list) — the queue panel restores from it after page
586
+ // refresh/reconnect (previously omitted → refresh lost the display)
587
+ steering: [...queueMirror.steering],
588
+ followUp: [...queueMirror.followUp],
589
+ });
590
+ }
591
+ case "get_messages": {
592
+ // Flatten to AgentMessage shape (entries are {type,message,...} wrappers)
593
+ const entries = (ctx as NonNullable<typeof ctx>).sessionManager.getEntries() as unknown as Array<Record<string, unknown>>;
594
+ const messages = entries
595
+ .filter((e) => e.type === "message")
596
+ .map((e) => (e.message ?? e) as Record<string, unknown>);
597
+ return reply(socket, id, true, { messages });
598
+ }
599
+ case "get_commands": {
600
+ const commands = pi.getCommands().map((c) => ({ name: c.name, description: c.description, source: c.source }));
601
+ return reply(socket, id, true, { commands });
602
+ }
603
+ case "set_model": {
604
+ if (!ctx) throw new Error("no active context");
605
+ const registry = ctx.modelRegistry as unknown as {
606
+ getAvailableSnapshot?: () => Array<{ provider: string; id: string }>;
607
+ getAvailable?: () => Array<{ provider: string; id: string }>;
608
+ };
609
+ const list = registry.getAvailableSnapshot?.() ?? registry.getAvailable?.() ?? [];
610
+ const found = list.find((m) => m.provider === cmd.provider && m.id === cmd.modelId);
611
+ if (!found) throw new Error(`model not found: ${cmd.provider}/${cmd.modelId}`);
612
+ const ok = await pi.setModel(found as never);
613
+ return ok ? reply(socket, id, true) : reply(socket, id, false, undefined, "no api key");
614
+ }
615
+ case "set_thinking_level":
616
+ pi.setThinkingLevel((cmd.level ?? "off") as never);
617
+ return reply(socket, id, true);
618
+ case "compact":
619
+ if (!ctx) throw new Error("no active context");
620
+ ctx.compact({ customInstructions: typeof cmd.customInstructions === "string" ? cmd.customInstructions : undefined });
621
+ return reply(socket, id, true);
622
+ case "set_session_name":
623
+ pi.setSessionName(String(cmd.name ?? ""));
624
+ return reply(socket, id, true);
625
+ case "get_tools": {
626
+ // Data source = the tools snapshot captured from before_provider_request
627
+ // (the real tool list); an empty array until first captured (frontend shows
628
+ // its normal empty state).
629
+ return reply(socket, id, true, lastTools);
630
+ }
631
+ default:
632
+ return reply(socket, id, false, undefined, `unsupported command: ${type}`);
633
+ }
634
+ } catch (e) {
635
+ return reply(socket, id, false, undefined, e instanceof Error ? e.message : String(e));
636
+ }
637
+ };
638
+
639
+ // Stop the bridge: drop clients + close server + clear URL/executed-args + release the
640
+ // registry entry + kill the spawned pi-web (manual /web off or the management menu's
641
+ // "Stop" = close everything; session_shutdown does NOT kill the child — detached,
642
+ // left to the user)
643
+ const stopBridge = (ctx: { ui: { notify: (m: string, k?: string) => void } }) => {
644
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } // after a manual stop, never auto-exit (the user took over)
645
+ for (const c of clients) c.destroy();
646
+ clients.clear();
647
+ server?.close();
648
+ server = null;
649
+ bridgeUrl = null;
650
+ (globalThis as Record<string, unknown>).__aitiTuiRelay = null; // /web off → reverse RPC offline; connector falls back to direct bridge
651
+ lastCommand = null;
652
+ if (child && child.exitCode === null) {
653
+ try { process.kill(-child.pid, "SIGTERM"); } catch { try { child.kill(); } catch { /* already exited */ } }
654
+ }
655
+ child = null;
656
+ if (latestCtx) void unregisterBridge(registeredFile ?? latestCtx.sessionManager.getSessionFile() ?? "");
657
+ registeredFile = null;
658
+ notifySafe(S.stopped, "info");
659
+ };
660
+
661
+ /** Build the "executed args" string: assemble this run's choices into a reusable /web command line (skips the TUI wizard). */
662
+ function buildCommandArgs(flags: { port?: string; takeover?: boolean; base?: string; url?: string; basePath?: string; scheme?: string; domain?: string; webport?: string }, o: { open: boolean; spawn: boolean }): string {
663
+ const parts: string[] = [flags.takeover ? "--takeover" : "--attach"];
664
+ if (flags.port) parts.push(`--port ${flags.port}`);
665
+ const base = resolveBridgeBase(flags);
666
+ if (base) parts.push(`--base ${base}`);
667
+ parts.push(o.open ? "--open" : "--no-open");
668
+ if (!o.spawn) parts.push("--no-spawn");
669
+ return `/web ${parts.join(" ")}`;
670
+ }
671
+
672
+ // Command body (shared by TUI /web and the auto-bridge): empty rawArgs opens the
673
+ // interactive wizard (TUI only); args go direct. Under AUTO_BRIDGE everything logs to
674
+ // console to keep the UI noise-free — callers guarantee direct args + base ready
675
+ // (env PI_WEB_BRIDGE_BASE); the interactive branch guards itself.
676
+ const runWeb = async (rawArgs: string, ctx: import("@earendil-works/pi-coding-agent").ExtensionContext): Promise<void> => {
677
+ const notifySafe = (msg: string, level: "info" | "error") => {
678
+ if (AUTO_BRIDGE) { console.log(`[pi-web-bridge] ${msg}`); return; }
679
+ try { ctx.ui.notify(msg, level); } catch { /* headless ctx fallback */ }
680
+ };
681
+ // /web off: stop the bridge (server + clients + registry; event listeners can't be
682
+ // unsubscribed since pi.on is one-way, but broadcast short-circuits on empty
683
+ // clients → zero cost; the next /web restores forwarding)
684
+ const arg = typeof rawArgs === "string" ? rawArgs.trim().toLowerCase() : "";
685
+ // Parse args up front: the management branch (server already running) also reads
686
+ // flags.takeover etc., avoiding TDZ
687
+ const flags = parseWebArgs(typeof rawArgs === "string" ? rawArgs : String(rawArgs ?? ""));
688
+ if (arg === "off" || arg === "stop" || arg === "close" || arg === "--off") {
689
+ if (!server) {
690
+ notifySafe("pi-web bridge not running", "info");
691
+ return;
692
+ }
693
+ stopBridge(ctx);
694
+ return;
695
+ }
696
+ if (server) {
697
+ // Idempotent: already running → the four-option "manage running bridge" menu
698
+ // (CLI_AUTO has a TUI too, so the menu is fine; only headless subagents
699
+ // short-circuit to console)
700
+ if (bridgeUrl) {
701
+ if (HEADLESS_AUTO) { console.log(`[pi-web-bridge] already running: ${bridgeUrl}`); return; }
702
+ const act = await ctx.ui.select(S.runningTitle(bridgeUrl), [
703
+ S.openBrowser,
704
+ S.noOpenBrowser,
705
+ S.showInfo,
706
+ S.stop,
707
+ ]);
708
+ if (act === undefined) return; // user cancelled
709
+ if (act.startsWith(S.openBrowser)) openBrowser(bridgeUrl);
710
+ else if (act.startsWith(S.noOpenBrowser)) notifySafe(S.stillRunning(bridgeUrl), "info");
711
+ else if (act.startsWith(S.showInfo)) ctx.ui.notify(S.infoMsg(bridgeUrl, lastCommand ?? buildCommandArgs({ takeover: flags.takeover === true }, { open: true, spawn: true })), "info");
712
+ else if (act.startsWith(S.stop)) stopBridge(ctx);
713
+ return;
714
+ }
715
+ // Older process without a recorded URL: echo the socket path only
716
+ notifySafe(S.alreadyRunning(sockPath ?? ""), "info");
717
+ return;
718
+ }
719
+ const sessionFile = ctx.sessionManager.getSessionFile();
720
+ // Empty session (file not lazily persisted yet) may still bridge: opening the
721
+ // session in pi-web looks the registry up by id and prefers socket attach (the
722
+ // file read is only for history replay) — no file needed, only a session id.
723
+ if (!sessionFile && !ctx.sessionManager.getSessionId()) throw new Error(S.noSession);
724
+
725
+ // ── Mode decision: with args, use them directly; without, show the wizard ──
726
+ let openBrowser_ = true;
727
+ let doSpawn = true;
728
+ if (flags.hasArgs) {
729
+ if (flags.open !== undefined) openBrowser_ = flags.open;
730
+ if (flags.spawn !== undefined) doSpawn = flags.spawn;
731
+ } else {
732
+ // Two-stage wizard: ① custom args (optional, may be empty) → ② mode
733
+ // (takeover/attach) → ③ server × browser three options
734
+ const custom = await ctx.ui.input(
735
+ S.customArgsTitle,
736
+ `--port ${process.env.PI_WEB_BRIDGE_PORT || "3000"}`,
737
+ );
738
+ if (custom === undefined) return; // user cancelled
739
+ if (custom.trim() !== "") {
740
+ const extra = parseWebArgs(custom);
741
+ for (const k of Object.keys(extra) as Array<keyof typeof extra>) {
742
+ if (extra[k] !== undefined) (flags as Record<string, unknown>)[k] = extra[k];
743
+ }
744
+ }
745
+ // Both modes spawn a new pi-web; the difference is the mechanism (see the
746
+ // bridge block below under if (!takeover)):
747
+ // takeover = no rpc bridge; after pi-web opens the file it runs the pi SDK
748
+ // in-process and takes over this session (the TUI stops accepting input)
749
+ // attach = starts the rpc bridge; pi-web attaches to this session, IO is
750
+ // forwarded/broadcast over the bridge, both work together
751
+ const mode = await ctx.ui.select(S.modeTitle, [S.modeTakeover, S.modeAttach]);
752
+ if (mode === undefined) return; // user cancelled
753
+ flags.takeover = mode.startsWith(S.modeTakeover);
754
+ // ③ Last page: "spawn × browser" combined into three options — when a resident
755
+ // dev/prod instance is already running there is no need to spawn a new one
756
+ // every time (a second build + port drift); pick "don't spawn" to reuse it.
757
+ // CLI equivalent: --no-spawn (address from --base/--url, or the port-derived
758
+ // fallback when empty).
759
+ const br = await ctx.ui.select(S.finalTitle, [
760
+ S.finalSpawnOpen,
761
+ S.finalSpawnNoOpen,
762
+ S.finalNoSpawn,
763
+ ]);
764
+ if (br === undefined) return; // user cancelled
765
+ if (br.startsWith(S.finalNoSpawn)) {
766
+ doSpawn = false;
767
+ // Existing instance address: CLI --base/--url/--domain and env
768
+ // PI_WEB_BASE_PATH take priority; only ask when none is set (may stay empty →
769
+ // keep the port-derived fallback URL)
770
+ if (!resolveBridgeBase(flags)) {
771
+ if (HEADLESS_AUTO) throw new Error("headless auto-bridge requires PI_WEB_BRIDGE_BASE pointing at a running pi-web");
772
+ const addr = await ctx.ui.input(S.addrInput, "");
773
+ if (addr === undefined) return; // user cancelled
774
+ if (addr.trim() !== "") flags.base = addr.trim();
775
+ }
776
+ } else {
777
+ openBrowser_ = br.startsWith(S.finalSpawnOpen);
778
+ }
779
+ }
780
+
781
+ const port = flags.port || process.env.PI_WEB_BRIDGE_PORT || "3000";
782
+
783
+ // 1. Socket server (attach mode only: rpc interface + event broadcast; takeover
784
+ // starts no bridge)
785
+ const takeover = flags.takeover === true;
786
+ if (!takeover) {
787
+ sockPath = bridgeSocketName(process.pid);
788
+ // Reverse-RPC response routing: aiti_* tools are forwarded over this socket into
789
+ // the in-process pi-web bridge (merged single stream: no HTTP/auth/port reachability
790
+ // needed — reuses the existing attach connection; frame protocol see
791
+ // aiti-attach-agent/index.ts handleAitiBridgeReq). Response frame ids get the "rr"
792
+ // prefix.
793
+ let relaySeq = 0;
794
+ const relayPending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
795
+ installTuiRelay = {
796
+ active: () => {
797
+ if (server === null) return false;
798
+ // Any peer that pushed a registration frame (even with pages=0 at the time —
799
+ // page claims may arrive after attach) is a forwardable peer
800
+ return relayPages.size > 0;
801
+ },
802
+ call: (tool: string, args: Record<string, unknown>): Promise<unknown> =>
803
+ new Promise((resolve, reject) => {
804
+ const pid = typeof args.pageId === "string" ? args.pageId : null;
805
+ // Self-healing routing: peers holding the pageId go first, tried one by one
806
+ // (20s each); silent peers are evicted; only total failure throws → the
807
+ // connector falls back to the direct bridge. Zombie connections no longer
808
+ // block for 120s.
809
+ const matched: Array<{ sock: Socket; ids: Set<string> }> = [];
810
+ const others: Array<{ sock: Socket; ids: Set<string> }> = [];
811
+ for (const [c, ids] of relayPages) {
812
+ if (pid && ids.has(pid)) matched.push({ sock: c, ids });
813
+ else others.push({ sock: c, ids });
814
+ }
815
+ const candidates = [...matched, ...others];
816
+ console.log(`[pi-web-bridge] relay → ${tool} | clients=${clients.size} candidates=matched${matched.length}+other${others.length} | pageId=${pid ?? "-"}`);
817
+ if (candidates.length === 0) return reject(new Error("relay: no rpc peer has registered pages"));
818
+ const attempt = (idx: number): Promise<unknown> => {
819
+ if (idx >= candidates.length) return Promise.reject(new Error("relay: all candidate peers failed to answer"));
820
+ const { sock, ids } = candidates[idx];
821
+ const id = "rr" + ++relaySeq;
822
+ return new Promise((resolve2, reject2) => {
823
+ const timer = setTimeout(() => {
824
+ if (relayPending.delete(id)) {
825
+ relayPages.delete(sock); // evict silent peer (half-open/zombie connection)
826
+ reject2(new Error(`relay timeout (20s): ${tool} @peer${idx}`));
827
+ }
828
+ }, 20_000);
829
+ relayPending.set(id, { resolve: resolve2, reject: reject2, timer });
830
+ try {
831
+ sock.write(JSON.stringify({ id, type: "aiti_bridge_req", tool, args }) + "\n");
832
+ } catch (e) {
833
+ clearTimeout(timer);
834
+ relayPending.delete(id);
835
+ relayPages.delete(sock);
836
+ reject2(new Error("relay write EPIPE: " + (e instanceof Error ? e.message : String(e))));
837
+ }
838
+ });
839
+ };
840
+ return attempt(0).catch(() => attempt(1).catch(() => (candidates.length > 2 ? attempt(2) : Promise.reject(new Error("relay: all peers failed")))));
841
+ }),
842
+ };
843
+ (globalThis as Record<string, unknown>).__aitiTuiRelay = installTuiRelay;
844
+ await new Promise<void>(async (resolve, reject) => {
845
+ server = createServer((socket) => {
846
+ clients.add(socket);
847
+ idlePoke(); // a page attach connection = activity signal (resets the idle clock)
848
+ // Write errors like EPIPE/ECONNRESET must never become uncaught exceptions
849
+ // that kill the whole TUI process
850
+ // (field-confirmed: reply() writing to a closed client → EPIPE → process exit
851
+ // → relay/registration all lost)
852
+ socket.on("error", () => { try { clients.delete(socket); relayPages.delete(socket); } catch { /* ignore */ } });
853
+ let buf = Buffer.alloc(0);
854
+ socket.on("data", (chunk: Buffer | string) => {
855
+ // Accumulate bytes, split on \n as Buffer, then decode each line as utf8
856
+ // (hardening A1 style) — per-chunk toString() can split multi-byte
857
+ // characters across chunks, JSON.parse fails → the frame is silently lost
858
+ // (relay fake timeouts)
859
+ const c = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
860
+ buf = buf.length === 0 ? c : Buffer.concat([buf, c]);
861
+ let i: number;
862
+ while ((i = buf.indexOf(0x0a)) !== -1) {
863
+ const line = buf.subarray(0, i).toString("utf8");
864
+ buf = buf.subarray(i + 1);
865
+ try {
866
+ const cmd = JSON.parse(line) as Record<string, unknown>;
867
+ // ① Page registry push (pi-web → pi): update this connection's routable
868
+ // pageId set; older instances that never pushed (e.g. production)
869
+ // never become forwarding targets
870
+ if (cmd.type === "aiti_pages") {
871
+ const pages = Array.isArray(cmd.pages) ? cmd.pages : [];
872
+ relayPages.set(socket, new Set(pages.map((p) => String((p as { pageId?: unknown }).pageId ?? "")).filter(Boolean)));
873
+ console.log(`[pi-web-bridge] aiti_pages ← via=${String(cmd.via ?? "?")} pages=${pages.length}`);
874
+ continue;
875
+ }
876
+ // ② Reverse-RPC responses (id=rr*) → route to relayPending; everything
877
+ // else is a forward command frame
878
+ if (cmd.type === "response" && typeof cmd.id === "string" && cmd.id.startsWith("rr")) {
879
+ const p = relayPending.get(cmd.id);
880
+ if (!p) continue;
881
+ clearTimeout(p.timer);
882
+ relayPending.delete(cmd.id);
883
+ if (cmd.success === true) {
884
+ // Consume the piggybacked page-registry refresh (the peer syncs the
885
+ // latest pageId set on every forwarded response, covering the lag of
886
+ // "registered only once at connect": new/closed pages take effect on
887
+ // the next hop)
888
+ const d = cmd.data as { __pages?: { pages?: Array<{ pageId?: unknown }> } } | undefined;
889
+ if (d && d.__pages && Array.isArray(d.__pages.pages)) {
890
+ relayPages.set(socket, new Set(d.__pages.pages.map((q) => String((q as { pageId?: unknown }).pageId ?? "")).filter(Boolean)));
891
+ }
892
+ p.resolve(cmd.data);
893
+ } else p.reject(new Error(String(cmd.error ?? "relay failed")));
894
+ } else {
895
+ void handleCommand(socket, cmd);
896
+ }
897
+ } catch { /* ignore */ }
898
+ }
899
+ });
900
+ socket.on("close", () => { clients.delete(socket); relayPages.delete(socket); });
901
+ });
902
+ server.once("error", (err: Error) => { server = null; reject(err); });
903
+ if (platform() !== "win32") {
904
+ // Clean up stale socket files left by crashes (Windows named pipes manage
905
+ // themselves; no such step needed). ⚠ The await must complete BEFORE listen:
906
+ // otherwise the async import may run unlinkSync after listen already resolved,
907
+ // deleting the just-created socket file (hit in E2E).
908
+ await import("node:fs").then((fs) => { try { if (sockPath) fs.unlinkSync(sockPath); } catch { /* not there */ } });
909
+ }
910
+ // Socket permissions tightened to owner-only (default 0775 would let any other
911
+ // local user connect and drive the session). Windows named-pipe ACLs isolate by
912
+ // default; no such step needed.
913
+ server.listen(sockPath, () => {
914
+ if (platform() !== "win32" && sockPath) {
915
+ void import("node:fs").then((fs) => { try { fs.chmodSync(sockPath!, 0o600); } catch { /* ignore */ } });
916
+ }
917
+ resolve();
918
+ });
919
+ });
920
+
921
+ // 1.5 Register event forwarding only after the bridge is active (zero listeners,
922
+ // zero dispatches before /web)
923
+ registerEventListeners();
924
+ }
925
+
926
+ // 2. Detached-spawn pi-web (socket injected via env; survives pi exiting)
927
+ // Skipped in attach-only mode (doSpawn=false) — the user already has a pi-web
928
+ webPort = port;
929
+ if (doSpawn) {
930
+ const entry = process.env.PI_WEB_BRIDGE_ENTRY || "pi-web";
931
+ const childEnv: Record<string, string> = {
932
+ ...process.env,
933
+ PI_WEB_ATTACH_CWD: ctx.cwd,
934
+ PORT: port,
935
+ };
936
+ if (!takeover) childEnv.PI_WEB_SOCKET = sockPath ?? "";
937
+ applyBridgeDomainEnv(flags, childEnv);
938
+ child = spawn(entry, [], {
939
+ detached: true,
940
+ stdio: "ignore",
941
+ env: childEnv,
942
+ });
943
+ child.unref();
944
+ }
945
+
946
+ // 3. Registry registration (attach mode only: pi-web recognizes the ownership and
947
+ // attaches when opening the file)
948
+ if (!takeover && sessionFile) {
949
+ await registerBridge(sessionFile);
950
+ registeredFile = sessionFile;
951
+ }
952
+
953
+ const basePath = resolveBridgeBase(flags);
954
+ // session param uses the session id (uuid), not the path — AppShell
955
+ // getInitialNavigation matches sessions by id (?session= and ?cwd= are mutually
956
+ // exclusive, cwd wins)
957
+ const sessionParam = ctx.sessionManager.getSessionId() || sessionFile;
958
+ const url = basePath
959
+ ? `${basePath}/?session=${encodeURIComponent(sessionParam)}`
960
+ : `http://127.0.0.1:${port}/?session=${encodeURIComponent(sessionParam)}`;
961
+ bridgeUrl = url;
962
+
963
+ // Merged bridge (two-in-one): if this session has the aiti_* connector installed,
964
+ // its tools should reach the same bridge as the pi-web page being opened (the page
965
+ // launcher connects same-origin to <web>/api/extensions/aiti-devtools/mcp).
966
+ // ⚠ Only merge when there is a real base (the user explicitly pointed at some
967
+ // pi-web) — with CLI full-auto and no PI_WEB_BRIDGE_BASE set, the address derived
968
+ // from the default port is a guess; writing it would only mislead the connector's
969
+ // probing/retries (connecting to a port that does not exist); in that case keep
970
+ // the connector's own bootstrap (standalone bridge).
971
+ if (!process.env.AITI_BRIDGE_URL && basePath) {
972
+ const webBase = (basePath || `http://127.0.0.1:${port}`).replace(/\/+$/, "");
973
+ process.env.AITI_BRIDGE_URL = webBase + "/api/extensions/aiti-devtools/mcp";
974
+ }
975
+
976
+ // 4. Browser open, gated by mode
977
+ if (openBrowser_) openBrowser(url);
978
+
979
+ lastCommand = buildCommandArgs(flags, { open: openBrowser_, spawn: doSpawn });
980
+ notifySafe(
981
+ (takeover ? S.takeoverDone(url) : S.attached(url)) + S.lastCommandLabel + lastCommand,
982
+ "info",
983
+ );
984
+ };
985
+
986
+ pi.registerCommand("web", {
987
+ description: "Bridge this session to the browser (pi-web)",
988
+ handler: (rawArgs, ctx) => runWeb(typeof rawArgs === "string" ? rawArgs : String(rawArgs ?? ""), ctx),
989
+ });
990
+
991
+ pi.on("session_shutdown", () => {
992
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
993
+ for (const c of clients) c.destroy();
994
+ clients.clear();
995
+ server?.close();
996
+ server = null;
997
+ bridgeUrl = null;
998
+ installTuiRelay = null;
999
+ (globalThis as Record<string, unknown>).__aitiTuiRelay = null;
1000
+ if (latestCtx) void unregisterBridge(registeredFile ?? latestCtx.sessionManager.getSessionFile() ?? "");
1001
+ registeredFile = null;
1002
+ if (child && child.exitCode === null) {
1003
+ // Bridge down is enough — pi-web keeps running (detached), the user decides
1004
+ child.unref();
1005
+ }
1006
+ });
1007
+ }