/** * remote-pi-ext — browse and drive the current pi session from a web browser. * * /webserve start [port] (default 8765) — asks for a password, serves the session * /webserve stop * /webserve status * `! ` / `!! ` in the web message box — runs the command on the pi * host, like `!` / `!!` in the terminal * * Zero external runtime dependencies: node:http + node:crypto + node:os + * node:fs + node:path, plus pi's own bundled packages, which the extension * loader aliases to pi's own copies: @earendil-works/pi-tui (matchesKey/ * visibleWidth for the ask bridge's terminal questionnaire) and * @earendil-works/pi-coding-agent (bash execution + settings for web `!`). */ import { createLocalBashOperations, DEFAULT_MAX_BYTES, SettingsManager, truncateTail, type ContextEvent, type ContextUsage, type ExtensionAPI, type ExtensionContext, type SessionManager, } from "@earendil-works/pi-coding-agent"; import type { Component } from "@earendil-works/pi-tui"; import { matchesKey, visibleWidth } from "@earendil-works/pi-tui"; import http from "node:http"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { networkInterfaces, tmpdir } from "node:os"; import { closeSync, createWriteStream, openSync, readSync } from "node:fs"; import { join } from "node:path"; export type AnyRec = Record; // --------------------------------------------------------------------------- // Password (spec §7) // --------------------------------------------------------------------------- /** SHA-256 hex of the password. The only form of the password ever stored. */ export function hashPassword(pw: string): string { return createHash("sha256").update(pw, "utf8").digest("hex"); } /** * Constant-time password check. Never throws: a malformed/short stored hash * simply fails (digest is always 32 bytes; Buffer.from("hex") may yield less). */ export function verifyPassword(pw: string, hashHex: string): boolean { const given = createHash("sha256").update(pw, "utf8").digest(); const expected = Buffer.from(hashHex, "hex"); if (expected.length === 0 || expected.length !== given.length) return false; return timingSafeEqual(given, expected); } // --------------------------------------------------------------------------- // Session tokens + cookie (spec §5 Auth) // --------------------------------------------------------------------------- const COOKIE_NAME = "remote_pi_session"; /** 32 random bytes, hex — 256-bit unguessable token. */ export function issueToken(): string { return randomBytes(32).toString("hex"); } export function cookieHeader(token: string): string { return COOKIE_NAME + "=" + token + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800"; } export function clearCookieHeader(): string { return COOKIE_NAME + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"; } /** Extract our token from a `Cookie` request header. Null when absent/empty. */ export function tokenFromCookie(header: string | undefined): string | null { if (!header) return null; for (const part of header.split(";")) { const kv = part.trim(); if (kv.startsWith(COOKIE_NAME + "=")) { const v = kv.slice(COOKIE_NAME.length + 1); return v.length > 0 ? v : null; } } return null; } // --------------------------------------------------------------------------- // Entry sanitizer (spec §5: strip everything except what the web view needs) // --------------------------------------------------------------------------- const ENTRY_FIELDS: Record = { message: ["type", "id", "timestamp", "message"], compaction: ["type", "id", "timestamp", "summary", "tokensBefore"], branch_summary: ["type", "id", "timestamp", "fromId", "summary"], model_change: ["type", "id", "timestamp", "provider", "modelId"], thinking_level_change: ["type", "id", "timestamp", "thinkingLevel"], session_info: ["type", "id", "timestamp", "name"], custom_message: ["type", "id", "timestamp", "customType", "content", "display"], custom: ["type", "id", "timestamp", "customType", "data"], label: ["type", "id", "timestamp", "targetId", "label"], }; /** Whitelist one entry's fields. `parentId` is never sent; unknown types -> null. */ export function sanitizeEntry(e: AnyRec): AnyRec | null { const fields = ENTRY_FIELDS[e.type as string]; if (!fields) return null; const out: AnyRec = {}; for (const f of fields) { if (e[f] !== undefined) out[f] = e[f]; } return out; } // --------------------------------------------------------------------------- // Leaf diff (spec §5 streaming protocol: append vs resync per client) // --------------------------------------------------------------------------- export type LeafDiff = | { kind: "none" } | { kind: "append"; entries: AnyRec[] } // raw entries, oldest-first, excludes lastLeaf | { kind: "resync" }; // caller sends a full snapshot /** * Decide what one SSE client needs when the session leaf moved from * `lastLeaf` to `newLeaf`. Walks parentId links from newLeaf back to lastLeaf; * any break in the chain (tree nav, resume, compaction re-root, unknown id) * means the client must resync. A 100k-step guard defeats pathological cycles. */ export function diffLeaf( byId: Map, lastLeaf: string | null, newLeaf: string | null, ): LeafDiff { if (!newLeaf || newLeaf === lastLeaf) return { kind: "none" }; if (!lastLeaf) return { kind: "resync" }; const path: AnyRec[] = []; let cur: string | null = newLeaf; let guard = 0; while (cur !== lastLeaf) { const e = byId.get(cur); if (!e) return { kind: "resync" }; path.push(e); cur = (e.parentId as string | null) ?? null; if (cur === null) return { kind: "resync" }; if (++guard > 100000) return { kind: "resync" }; } path.reverse(); return { kind: "append", entries: path }; } // Delivery options for user input (TUI parity). "/" input keeps the proven // command path: registered commands execute immediately (even while streaming), // skills/prompt templates expand, delivery is followUp while busy. Chat // messages are delivered with the given mode: the web's Send steers the // running agent like the terminal's Enter, while "followUp" (available via // the /input API) queues until the agent finishes. Idle = direct delivery. export function inputOpts(text: string, idle: boolean, mode: "steer" | "followUp" = "steer"): { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean } { const cmd = text.startsWith("/"); return { deliverAs: idle ? undefined : cmd ? "followUp" : mode, expandPromptTemplates: cmd, }; } /** * Names of the extension commands registered in the wiring section below. * Injected into the web page: pi's prompt() executes registered commands * immediately and never records a user entry, so the page must not leave * their bubbles pending in userQ (they would never match and would stay * dashed forever). The * selftest asserts this list matches every pi.registerCommand call. */ export const WEB_COMMANDS = ["new", "compact", "model", "tree", "webserve"]; /** One-line preview of a message entry's text (whitespace-collapsed, capped) for the /tree picker. */ export function entryPreview(e: AnyRec): string { const c = (e.message as AnyRec | undefined)?.content; let t = ""; if (typeof c === "string") t = c; else if (Array.isArray(c)) t = c.map((b) => (b && typeof (b as AnyRec).text === "string" ? (b as AnyRec).text : "")).join(" "); return t.replace(/\s+/g, " ").trim().slice(0, 120); } // --------------------------------------------------------------------------- // Web `!` bash — the web equivalent of the TUI's `! cmd` / `!! cmd`: runs the // command on the pi host in the session cwd with the user's shell, streams // the output to the web, records a `bashExecution` session entry, and (single // `!` only) makes the output visible to the agent via the `context` hook in // the wiring section below. Terminal `!` is untouched (pi's own input handler // runs it before prompt()). // --------------------------------------------------------------------------- /** Parse `! cmd` / `!! cmd` web input, TUI-style: `!!` excludes the output * from LLM context. Null when the text is not a bash line; an empty command * (bare `!` / `!!`) is not a bash line either (caller sends it as a message). */ export function parseBashLine(text: string): { command: string; exclude: boolean } | null { if (!text.startsWith("!")) return null; const exclude = text.startsWith("!!"); return { command: (exclude ? text.slice(2) : text.slice(1)).trim(), exclude }; } // ANSI escape stripper — pi's own pattern (dist/utils/ansi.js, MIT, derived // from chalk/ansi-regex): OSC sequences, then CSI with params/intermediates. const BASH_ANSI_RE = /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g; /** Clean one decoded output chunk exactly like pi's executeBashWithOperations: * strip ANSI, drop control chars (keep tab/newline), drop Unicode format * chars, drop CR. */ export function cleanBashText(s: string): string { if (s.includes("\u001B") || s.includes("\u009B")) s = s.replace(BASH_ANSI_RE, ""); return Array.from(s) .filter((ch) => { const c = ch.codePointAt(0); if (c === undefined) return false; if (c === 0x09 || c === 0x0a || c === 0x0d) return true; if (c <= 0x1f) return false; if (c >= 0xfff9 && c <= 0xfffb) return false; return true; }) .join("") .replace(/\r/g, ""); } /** Stop streaming output to the browser once this much has been shown; the * finalized session entry carries the official tail-truncated output. */ export const BASHOUT_STREAM_LIMIT = 200_000; /** The web has no Esc to cancel bash (the TUI's `!` is unbounded + Esc), so * bound it: killed after 10 minutes and recorded as cancelled. */ export const BASH_TIMEOUT_MS = 10 * 60 * 1000; export interface WebBashResult { output: string; exitCode: number | undefined; cancelled: boolean; truncated: boolean; fullOutputPath?: string; } /** * Run one bash line the way pi's TUI does: the user's shell (settings * `shellPath`, `shellCommandPrefix` prepended), the session cwd, pi's own * local BashOperations (same spawn/env/process-tree handling as the agent's * bash tool), output streamed through cleanBashText, tail-truncated at pi's * own 50KB/2000-line limits, overflow captured to a temp file. Resolves for * normal completion and timeout (cancelled); throws only on spawn-level * errors (bad cwd, no shell found) so the caller can report them. */ export async function runWebBash( command: string, cwd: string, shellPath: string | undefined, commandPrefix: string | undefined, onChunk: (text: string) => void, ): Promise { const ops = createLocalBashOperations({ shellPath }); const full = commandPrefix ? commandPrefix + "\n" + command : command; const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), BASH_TIMEOUT_MS); const chunks: string[] = []; let kept = 0; let total = 0; let tmpPath: string | undefined; let tmpStream: ReturnType | undefined; const ensureTmp = (): void => { if (!tmpPath) { tmpPath = join(tmpdir(), "pi-bash-" + randomBytes(8).toString("hex") + ".log"); tmpStream = createWriteStream(tmpPath); for (const c of chunks) tmpStream.write(c); } }; const decoder = new TextDecoder(); const onData = (data: Buffer): void => { total += data.length; const text = cleanBashText(decoder.decode(data, { stream: true })); if (total > DEFAULT_MAX_BYTES) ensureTmp(); tmpStream?.write(text); chunks.push(text); kept += text.length; while (kept > 2 * DEFAULT_MAX_BYTES && chunks.length > 1) kept -= chunks.shift()!.length; if (text) onChunk(text); }; const finish = async (cancelled: boolean, exitCode: number | null): Promise => { const fullOutput = chunks.join(""); const tr = truncateTail(fullOutput); if (tr.truncated && !tmpPath) ensureTmp(); if (tmpStream) { // await the flush so fullOutputPath is complete when this resolves (the // session entry references the file; readers must not see a partial one). const s = tmpStream; // local: keeps the narrowing inside the callbacks await new Promise((res) => { s.once("close", () => res()); s.once("error", () => res()); s.end(); }); } return { output: tr.truncated ? tr.content : fullOutput, exitCode: cancelled ? undefined : exitCode ?? undefined, cancelled, truncated: tr.truncated, fullOutputPath: tmpPath, }; }; try { const r = await ops.exec(full, cwd, { onData, signal: ac.signal }); return await finish(false, r.exitCode); } catch (err) { if (ac.signal.aborted) return await finish(true, null); throw err; } finally { clearTimeout(timer); } } /** Bash messages in the session's context entry list (compaction-aware) that * the agent's in-memory message list is missing — i.e. web `!` results the * agent hasn't seen yet. Matched by command+timestamp (terminal `!` results * live in both places and so never re-inject); `!!` entries never qualify. */ export function missingBashMessages(contextEntries: AnyRec[], liveMessages: AnyRec[]): AnyRec[] { const key = (m: AnyRec): string => m.command + "\u0000" + m.timestamp; const present = new Set(liveMessages.filter((m) => m.role === "bashExecution").map(key)); const out: AnyRec[] = []; for (const e of contextEntries) { const m = e.type === "message" ? (e.message as AnyRec | undefined) : undefined; if (m?.role !== "bashExecution" || m.excludeFromContext) continue; if (!present.has(key(m))) out.push(m); } return out; } /** Merge missing bash messages into the live list in timestamp order (what * the context hook returns as the transformed message set). */ export function mergeBashMessages(liveMessages: AnyRec[], extra: AnyRec[]): AnyRec[] { if (extra.length === 0) return liveMessages; const sorted = [...extra].sort((a, b) => ((a.timestamp as number) ?? 0) - ((b.timestamp as number) ?? 0)); const out: AnyRec[] = []; let i = 0; for (const m of liveMessages) { while (i < sorted.length && ((sorted[i].timestamp as number) ?? 0) <= ((m.timestamp as number) ?? 0)) out.push(sorted[i++]); out.push(m); } while (i < sorted.length) out.push(sorted[i++]); return out; } // --------------------------------------------------------------------------- // ask_user_question bridge — answer the agent's questions from the web // --------------------------------------------------------------------------- // The rpiv-ask-user-question extension's tool blocks on a terminal TUI // overlay. This bridge intercepts the tool call (pi's tool_call hook, which // runs before the tool executes and can short-circuit it with a result the // model sees), shows the questions in the web viewer AND in a minimal // terminal overlay, and feeds whichever answer arrives first back as the // tool's result text — word-for-word the wording the tool itself produces. // When no web client is connected the hook stays out of the way and the // tool's own (richer) TUI flow runs unchanged. // --------------------------------------------------------------------------- export interface AskOption { label: string; description?: string; /** Capped at PREVIEW_LIMIT when forwarded to the web (keeps SSE payloads sane). */ preview?: string; } export interface AskQuestion { question: string; header?: string; multiSelect?: boolean; options: AskOption[]; } export interface AskAnswer { index: number; // question index kind: "option" | "custom" | "multi"; answer?: string | null; // option label or typed text; null for multi selected?: string[]; // chosen labels (multi only) notes?: string; } export interface AskOutcome { cancelled: boolean; answers: AskAnswer[]; } /** Option labels the rpiv tool rejects at runtime; we let its own validator report those. */ export const ASK_RESERVED_LABELS = ["Other", "Type something.", "Next"]; const PREVIEW_LIMIT = 2000; /** Normalize raw `event.input.questions` into AskQuestion[]. null = malformed. */ export function extractAskQuestions(raw: unknown): AskQuestion[] | null { if (!Array.isArray(raw) || raw.length === 0 || raw.length > 4) return null; const out: AskQuestion[] = []; for (const q of raw) { if (!q || typeof (q as AnyRec).question !== "string") return null; const optsRaw = (q as AnyRec).options; if (!Array.isArray(optsRaw) || optsRaw.length < 2 || optsRaw.length > 4) return null; const options: AskOption[] = []; for (const o of optsRaw) { if (!o || typeof (o as AnyRec).label !== "string" || typeof (o as AnyRec).description !== "string") return null; const opt: AskOption = { label: (o as AnyRec).label as string, description: (o as AnyRec).description as string }; if (typeof (o as AnyRec).preview === "string") opt.preview = ((o as AnyRec).preview as string).slice(0, PREVIEW_LIMIT); options.push(opt); } const outQ: AskQuestion = { question: (q as AnyRec).question as string, options }; if (typeof (q as AnyRec).header === "string") outQ.header = (q as AnyRec).header as string; if (typeof (q as AnyRec).multiSelect === "boolean") outQ.multiSelect = (q as AnyRec).multiSelect as boolean; out.push(outQ); } return out; } const ASK_DECLINE = "User declined to answer questions"; const ASK_NO_INPUT = "(no input)"; /** * Build the tool result text for an outcome — word-for-word what * rpiv-ask-user-question returns for the same answers (the agent is * conditioned on this wording, so it must match exactly; pinned by selftest). */ export function buildAskEnvelope(questions: AskQuestion[], outcome: AskOutcome): string { if (outcome.cancelled) return ASK_DECLINE; const segs: string[] = []; for (let i = 0; i < questions.length; i++) { const a = outcome.answers.find((x) => x.index === i); if (!a) continue; // partial submission: unanswered questions contribute no segment const scalar = a.kind === "multi" ? (a.selected && a.selected.length > 0 ? a.selected.join(", ") : ASK_NO_INPUT) : (a.answer && a.answer.length > 0 ? a.answer : ASK_NO_INPUT); const parts = ['"' + questions[i].question + '"="' + scalar + '"']; if (a.notes && a.notes.length > 0) parts.push("user notes: " + a.notes); segs.push(parts.join(". ") + "."); } if (segs.length === 0) return ASK_DECLINE; return "User has answered your questions: " + segs.join(" ") + " You can now continue with the user's answers in mind."; } // --- Minimal terminal questionnaire (terminal side of the bridge) --- function fitText(s: string, max: number): string { if (visibleWidth(s) <= max) return s; const chars = [...s]; while (chars.length > 0 && visibleWidth(chars.join("")) > max) chars.pop(); return chars.join(""); } function wrapText(s: string, max: number, maxLines = 3): string[] { const out: string[] = []; let line = ""; const breakLine = (next: string): void => { const sp = line.lastIndexOf(" "); if (sp > 0 && sp < line.length - 1) { out.push(line.slice(0, sp)); line = line.slice(sp + 1) + next; } else { if (line) out.push(fitText(line, max)); line = fitText(next, max); } if (out.length > maxLines) { out.length = maxLines; const last = out[maxLines - 1]; out[maxLines - 1] = fitText(last.endsWith("…") ? last : last + "…", max); } }; for (const ch of s) { if (visibleWidth(line + ch) > max) breakLine(ch); else line += ch; } if (line) out.push(line); return out.length > 0 ? out : [""]; } function boxTop(title: string, inner: number): string { const label = " " + title + " "; return "┌─" + label + "─".repeat(Math.max(0, inner - visibleWidth(label) - 1)); } function boxLine(content: string, inner: number): string { const body = fitText(content, inner); return "│" + body + " ".repeat(Math.max(0, inner - visibleWidth(body))) + "│"; } function boxBottom(inner: number): string { return "└" + "─".repeat(inner - 1); } /** * One-question-at-a-time terminal questionnaire. ↑/↓ move, Enter picks * (single-select) or advances (multi-select), Space toggles (multi-select), * the "Type something." row takes free text, Esc cancels the whole thing. * `done(outcome)` answers; `done(null)` (via close()) means "no answer, the * web side won" — the overlay hides either way. */ export class AskTuiComponent implements Component { private questions: AskQuestion[]; private tui: { requestRender(): void }; private done: (o: AskOutcome | null) => void; private qIndex = 0; private cursor = 0; private mode: "list" | "type" = "list"; private draft = ""; private selected: boolean[]; private answers: AskAnswer[] = []; private settled = false; constructor(questions: AskQuestion[], tui: { requestRender(): void }, done: (o: AskOutcome | null) => void) { this.questions = questions; this.tui = tui; this.done = done; this.selected = new Array(this.questions[0].options.length).fill(false); } invalidate(): void { this.rerender(); } dispose(): void { /* nothing to release */ } render(width: number): string[] { const q = this.questions[this.qIndex]; const inner = Math.max(24, Math.min(width, 80) - 2); const lines: string[] = []; lines.push(boxTop("Ask you (" + (this.qIndex + 1) + "/" + this.questions.length + ")", inner)); for (const l of wrapText(q.question, inner)) lines.push(boxLine(l, inner)); lines.push(boxLine("", inner)); q.options.forEach((o, i) => { const mark = this.mode === "list" && this.cursor === i ? ">" : " "; const check = q.multiSelect ? (this.selected[i] ? "x" : " ") : " "; const text = o.label + (o.description ? " — " + o.description : ""); lines.push(boxLine(" " + mark + " " + check + " " + (i + 1) + ". " + text, inner)); }); const cmark = this.mode === "list" && this.cursor === q.options.length ? ">" : " "; lines.push(boxLine(" " + cmark + " " + ASK_RESERVED_LABELS[1], inner)); if (this.mode === "type") lines.push(boxLine(" > " + this.draft, inner)); lines.push(boxLine("", inner)); const hint = q.multiSelect ? "↑/↓ move Space toggle Enter next Esc cancel" : "↑/↓ move Enter pick Esc cancel"; lines.push(boxLine(hint, inner)); lines.push(boxBottom(inner)); return lines; } handleInput(data: string): void { if (this.settled) return; const q = this.questions[this.qIndex]; if (matchesKey(data, "escape")) { this.finish(true); return; } if (this.mode === "type") { if (matchesKey(data, "enter")) { const text = this.draft.trim(); this.answers.push({ index: this.qIndex, kind: "custom", answer: text.length > 0 ? text : null }); this.advance(); return; } if (matchesKey(data, "backspace")) { this.draft = this.draft.slice(0, -1); this.rerender(); return; } if (matchesKey(data, "ctrl+u")) { this.draft = ""; this.rerender(); return; } if (data.length === 1 && data >= " " && data <= "\u007e") { this.draft += data; this.rerender(); } // space is a normal char here return; } const rows = q.options.length + 1; if (matchesKey(data, "up")) { this.cursor = (this.cursor + rows - 1) % rows; this.rerender(); return; } if (matchesKey(data, "down")) { this.cursor = (this.cursor + 1) % rows; this.rerender(); return; } if (matchesKey(data, "space")) { if (q.multiSelect && this.cursor < q.options.length) { this.selected[this.cursor] = !this.selected[this.cursor]; this.rerender(); } return; } if (matchesKey(data, "enter")) { if (this.cursor < q.options.length) { if (q.multiSelect) { this.answers.push({ index: this.qIndex, kind: "multi", answer: null, selected: q.options.filter((_, i) => this.selected[i]).map((o) => o.label) }); this.advance(); } else { this.answers.push({ index: this.qIndex, kind: "option", answer: q.options[this.cursor].label }); this.advance(); } } else { this.mode = "type"; this.draft = ""; this.rerender(); } } } private rerender(): void { if (!this.settled) this.tui.requestRender(); } private advance(): void { this.mode = "list"; this.draft = ""; this.cursor = 0; this.qIndex++; if (this.qIndex >= this.questions.length) this.finish(false); else { this.selected = new Array(this.questions[this.qIndex].options.length).fill(false); this.rerender(); } } private finish(cancelled: boolean): void { if (this.settled) return; this.settled = true; this.tui.requestRender(); this.done({ cancelled, answers: this.answers }); } /** * Close without an answer (the web side won). done(null) = "not an answer". * ponytail: pi's hideOverlay() pops the TOPMOST overlay — if the user stacked * another overlay over ours, it gets popped instead (same structure as the * rpiv-ask-user-question overlay; no per-overlay close API exists). */ close(): void { if (this.settled) return; this.settled = true; this.tui.requestRender(); this.done(null); } } // --------------------------------------------------------------------------- // Pages (spec §6). No backticks / ${...} inside page JS — they nest in // template literals below. // --------------------------------------------------------------------------- export const LOGIN_PAGE = ` pi remote — login

pi session viewer

`; export const CHAT_PAGE = ` pi session
`; // --------------------------------------------------------------------------- // Web server (spec §5). Zero-dependency node:http. // --------------------------------------------------------------------------- const BODY_LIMIT = 100 * 1024; // spec: 100 KB body limit // deviation from the spec's 100 KB body limit: /input also carries base64 // images, so only /input gets a larger cap (up to 3 images x ~6 MB base64). const INPUT_BODY_LIMIT = 12 * 1024 * 1024; const INPUT_LIMIT = 32 * 1024; // spec: input text 32 KB const MAX_IMAGES = 3; const MAX_IMAGE_B64 = 6 * 1024 * 1024; // ≈ 4.5 MB raw per image const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); const KEEPALIVE_MS = 15000; // spec: SSE keep-alive comment every 15s export interface SnapshotMeta { cwd: string; model: string; sessionName: string | null; leafId: string | null; usage: ContextUsage | null; } /** Pi-side dependency seam. Task 7 implements this against a live session. */ export interface WebApi { getSnapshot(): { entries: AnyRec[]; meta: SnapshotMeta }; allEntries(): Map; sendInput(text: string, mode?: "steer" | "followUp", images?: ImageInput[]): Promise<{ queued: boolean }>; stopAgent(): { aborted: boolean }; } export interface WebServer { port: number; // actual bound port (may differ from requested) stop(): void; /** Push one SSE event to every authenticated, connected client. */ broadcast(name: string, data: unknown): void; /** Full resync to every client (session replaced: /new, /resume, ...). */ resyncAll(): void; /** Session leaf moved: per-client append / resync (spec §5 change-detection). */ onSessionChanged(newLeaf: string | null): void; /** Currently connected SSE clients (0 = nobody is watching from the web). */ clientCount(): number; /** * Ask for answers over SSE: broadcasts `ask {id, questions}` and resolves * when `POST /ask-answer` matches `id` — or null when the server stops or * `signal` aborts first (whichever the caller should fall back from). * Client departures do NOT settle the ask: it stays pending and is replayed * to any client that (re)connects, so a mobile tab that dies and comes back * still gets the modal and its answer still lands. First answer wins; later * posts for the same id get 409. */ askUser(id: string, questions: AskQuestion[], signal?: AbortSignal): Promise; /** Resolve a pending askUser outside the HTTP path (closes the web modal). */ settleAsk(id: string, outcome: AskOutcome | null): boolean; } interface SseClient { res: http.ServerResponse; hb: ReturnType; lastLeaf: string | null; } function readBody(req: http.IncomingMessage, limit: number): Promise { return new Promise((resolve, reject) => { let size = 0; const chunks: Buffer[] = []; req.on("data", (c: Buffer) => { size += c.length; if (size > limit) { const err = new Error("body too large"); (err as { code?: string }).code = "BODY_TOO_LARGE"; reject(err); // deviation (kept from Task 5): the brief's code called req.destroy() here; that // also kills the socket, so the 413 could never be delivered (client saw // ECONNRESET, verified by the /input oversized test). Stream keeps flowing (drains // the body); chunks is not buffered past the limit, so memory stays bounded. return; } chunks.push(c); }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } /** Image attached to a web message: base64 data + MIME type. */ export type ImageInput = { data: string; mimeType: string }; /** Validates the `images` field of an /input body; returns the images or an error message. */ function parseImages(v: unknown): { ok: ImageInput[] } | { ok: null; error: string } { if (v === undefined || v === null) return { ok: [] }; if (!Array.isArray(v) || v.length > MAX_IMAGES) return { ok: null, error: "too many images (max " + MAX_IMAGES + ")" }; const out: ImageInput[] = []; for (const it of v) { const r = it as { data?: unknown; mimeType?: unknown } | null | undefined; if (!r || typeof r.mimeType !== "string" || !IMAGE_TYPES.has(r.mimeType) || typeof r.data !== "string" || !r.data) return { ok: null, error: "bad image" }; if (r.data.length > MAX_IMAGE_B64) return { ok: null, error: "image too large (max ~4.5 MB)" }; out.push({ data: r.data, mimeType: r.mimeType }); } return { ok: out }; } function json(res: http.ServerResponse, status: number, obj: unknown, headers: Record = {}): void { res.writeHead(status, { "Content-Type": "application/json", ...headers }); res.end(JSON.stringify(obj)); } function html(res: http.ServerResponse, status: number, body: string): void { res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" }); res.end(body); } function parseJsonBody(raw: string): AnyRec | null { try { return JSON.parse(raw) as AnyRec; } catch { return null; } } function safeWrite(res: http.ServerResponse, s: string): boolean { if (res.writableEnded || res.destroyed) return false; try { res.write(s); return true; } catch { return false; } } /** One SSE frame. JSON.stringify keeps data on a single line (newlines escaped). */ function writeSse(res: http.ServerResponse, name: string, data: unknown): boolean { let payload: string; try { payload = JSON.stringify(data); } catch { return false; } // deviation: non-serializable payload (circular ref/BigInt) must read as a write failure so broadcast never throws return safeWrite(res, "event: " + name + "\ndata: " + payload + "\n\n"); } export function startServer(opts: { host?: string; port: number; passwordHash: string; tokens: Set; api: WebApi; }): Promise { const { passwordHash, tokens, api } = opts; const clients = new Set(); // Questions are kept with the waiter so a (re)connecting client can be // replayed the pending ask (openSse) — the one-shot `ask` broadcast at ask // time is not enough: mobile tabs die and come back. const askWaiters = new Map void }>(); let closed = false; const server = http.createServer((req, res) => { void handle(req, res); }); function settleAsk(id: string, outcome: AskOutcome | null): boolean { const w = askWaiters.get(id); if (!w) return false; askWaiters.delete(id); broadcast("ask-resolved", { id, outcome }); w.finish(outcome); return true; } function broadcast(name: string, data: unknown): void { if (closed) return; for (const c of [...clients]) { if (!writeSse(c.res, name, data)) c.res.end(); } } function askUser(id: string, questions: AskQuestion[], signal?: AbortSignal): Promise { if (closed || clients.size === 0) return Promise.resolve(null); // An ALREADY-aborted signal can never fire an "abort" listener (the event // was dispatched before we could listen), so the waiter would hang for // the rest of the server's life — and the broadcast would open a zombie // modal nobody can answer (every POST would 409). Settle up front instead. if (signal && signal.aborted) return Promise.resolve(null); let onAbort: (() => void) | undefined; const p = new Promise((resolve) => { const finish = (o: AskOutcome | null) => { if (signal && onAbort) signal.removeEventListener("abort", onAbort); resolve(o); }; const prev = askWaiters.get(id); if (prev) prev.finish(null); // duplicate hook invocation: settle the stale waiter so the old await can't hang askWaiters.set(id, { questions, finish }); onAbort = () => { settleAsk(id, null); }; if (signal) signal.addEventListener("abort", onAbort, { once: true }); }); broadcast("ask", { id, questions }); return p; } function openSse(res: http.ServerResponse): void { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); safeWrite(res, "retry: 2000\n\n"); const snap = api.getSnapshot(); writeSse(res, "snapshot", { entries: snap.entries, meta: snap.meta }); // (Re)connecting client: replay every pending question (written before the // client joins `clients`, so no double-send via a concurrent broadcast). for (const [id, a] of askWaiters) writeSse(res, "ask", { id, questions: a.questions }); const client: SseClient = { res, hb: setInterval(() => { if (!safeWrite(res, ": hb\n\n")) res.end(); }, KEEPALIVE_MS), lastLeaf: snap.meta.leafId, }; clients.add(client); res.on("close", () => { clearInterval(client.hb); clients.delete(client); }); } async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { const url = (req.url || "/").split("?")[0]; const token = tokenFromCookie(req.headers.cookie); const authed = token !== null && tokens.has(token); try { // --- public: page + login --- if (req.method === "GET" && url === "/") { html(res, 200, authed ? CHAT_PAGE : LOGIN_PAGE); return; } if (req.method === "POST" && url === "/login") { const raw = await readBody(req, BODY_LIMIT); const body = parseJsonBody(raw); const pw = typeof body?.password === "string" ? (body.password as string) : ""; if (!verifyPassword(pw, passwordHash)) { json(res, 401, { error: "bad password" }); return; } const t = issueToken(); tokens.add(t); json(res, 200, { ok: true }, { "Set-Cookie": cookieHeader(t) }); return; } // --- everything below requires a valid token cookie --- if (!authed) { json(res, 401, { error: "unauthenticated" }); return; } if (req.method === "GET" && url === "/events") { openSse(res); return; } if (req.method === "POST" && url === "/input") { const raw = await readBody(req, INPUT_BODY_LIMIT); const body = parseJsonBody(raw); if (!body) { json(res, 400, { error: "bad json" }); return; } const text = typeof body.text === "string" ? (body.text as string).trim() : ""; const img = parseImages(body.images); if (img.ok === null) { json(res, 400, { error: img.error }); return; } if (!text && img.ok.length === 0) { json(res, 400, { error: "empty message" }); return; } if (text.length > INPUT_LIMIT) { json(res, 400, { error: "message too long" }); return; } const mode = body.mode === "followUp" ? "followUp" : "steer"; const r = await api.sendInput(text, mode, img.ok); json(res, 200, { ok: true, queued: r.queued }); return; } if (req.method === "POST" && url === "/stop") { json(res, 200, api.stopAgent()); return; } if (req.method === "POST" && url === "/ask-answer") { const raw = await readBody(req, BODY_LIMIT); const body = parseJsonBody(raw); if (!body || typeof body.id !== "string" || (body.id as string).length > 200) { json(res, 400, { error: "bad request" }); return; } const cancelled = body.cancelled === true; const answers: AskAnswer[] = []; if (!cancelled) { const rawAnswers = Array.isArray(body.answers) ? body.answers : []; for (const a of rawAnswers) { if (!a || typeof (a as AnyRec).index !== "number") continue; const kind = (a as AnyRec).kind; if (kind !== "option" && kind !== "custom" && kind !== "multi") continue; const out: AskAnswer = { index: (a as AnyRec).index as number, kind: kind as AskAnswer["kind"] }; const ans = (a as AnyRec).answer; out.answer = typeof ans === "string" ? ans.slice(0, 8000) : null; if (Array.isArray((a as AnyRec).selected)) { out.selected = ((a as AnyRec).selected as unknown[]) .filter((x): x is string => typeof x === "string") .slice(0, 10) .map((s) => s.slice(0, 200)); } const notes = (a as AnyRec).notes; if (typeof notes === "string" && notes.length > 0) out.notes = notes.slice(0, 2000); answers.push(out); } } const ok = settleAsk(body.id as string, { cancelled, answers }); json(res, ok ? 200 : 409, ok ? { ok: true } : { error: "no pending question" }); return; } if (req.method === "POST" && url === "/logout") { if (token) tokens.delete(token); json(res, 200, { ok: true }, { "Set-Cookie": clearCookieHeader() }); return; } json(res, 404, { error: "not found" }); } catch (err) { const e = err as { code?: string; message?: string }; if (res.headersSent) { res.end(); return; } if (e.code === "BODY_TOO_LARGE") { json(res, 413, { error: "body too large" }); return; } if (e.code === "NO_CTX") { json(res, 503, { error: "session is ending" }); return; } json(res, 500, { error: e.message || "server error" }); } } const web: WebServer = { port: opts.port, stop() { closed = true; for (const id of [...askWaiters.keys()]) settleAsk(id, null); const cs = [...clients]; clients.clear(); for (const c of cs) { clearInterval(c.hb); c.res.end(); } server.close(); }, broadcast: (name, data) => broadcast(name, data), resyncAll() { if (closed) return; // Session replaced: its pending questions are gone. The ask-resolved // broadcast below closes any stale modal on still-connected clients. for (const id of [...askWaiters.keys()]) settleAsk(id, null); for (const c of [...clients]) { let snap: { entries: AnyRec[]; meta: SnapshotMeta }; try { snap = api.getSnapshot(); } catch { clearInterval(c.hb); c.res.end(); clients.delete(c); continue; } writeSse(c.res, "resync", { entries: snap.entries, meta: snap.meta }); c.lastLeaf = snap.meta.leafId ?? null; } }, onSessionChanged(newLeaf) { if (closed) return; const byId = api.allEntries(); for (const c of [...clients]) { const diff = diffLeaf(byId, c.lastLeaf, newLeaf); if (diff.kind === "append") { writeSse(c.res, "append", { entries: diff.entries.map(sanitizeEntry).filter((x): x is AnyRec => x !== null), }); c.lastLeaf = newLeaf; } else if (diff.kind === "resync") { let snap: { entries: AnyRec[]; meta: SnapshotMeta }; try { snap = api.getSnapshot(); } catch { // deviation: getSnapshot can throw (e.g. NO_CTX while the session is ending); close this one client instead of propagating to the pi event handler clearInterval(c.hb); c.res.end(); clients.delete(c); continue; } writeSse(c.res, "resync", { entries: snap.entries, meta: snap.meta }); c.lastLeaf = snap.meta.leafId ?? newLeaf; } else { c.lastLeaf = newLeaf; } if (c.res.writableEnded || c.res.destroyed) c.res.end(); } }, clientCount() { return clients.size; }, askUser, settleAsk, }; return new Promise((resolve, reject) => { server.once("error", reject); // surfaces EADDRINUSE to the caller server.listen(opts.port, opts.host ?? "0.0.0.0", () => { const addr = server.address(); if (addr && typeof addr === "object") web.port = addr.port; resolve(web); }); }); } // --------------------------------------------------------------------------- // Pi wiring (spec §3–§4). Server state lives at MODULE scope (in-memory, per // pi process, never persisted): pi re-invokes the factory with a fresh closure // on every session replacement (/new, /resume, /fork, /reload), invalidates // the old pi/ctx (see AgentSession.dispose), but the module is imported once, // so only module-level state survives across invocations. Handlers in the new // closure rebind curCtx/curPi and resync the same running server — including // the server's api object, which must route sendInput through the CURRENT pi, // not the (invalidated) one captured when the server was started. // --------------------------------------------------------------------------- function lanUrls(port: number): string[] { const out = ["http://localhost:" + port]; const ifaces = networkInterfaces(); for (const list of Object.values(ifaces)) { for (const i of list ?? []) { if (i.family === "IPv4" && !i.internal) out.push("http://" + i.address + ":" + port); } } return out; } function noCtxError(): Error { const e = new Error("session is ending"); (e as { code?: string }).code = "NO_CTX"; return e; } /** * cwd from a session JSONL file's header line, or null if unreadable. * Used to detect cross-cwd /resume targets (see session_shutdown below). */ function sessionFileCwd(file: string): string | null { let fd: number | null = null; try { fd = openSync(file, "r"); const buf = Buffer.alloc(64 * 1024); // header entry is small; 64KB covers it const n = readSync(fd, buf, 0, buf.length, 0); const firstLine = buf.toString("utf8", 0, n).split("\n")[0].trim(); if (!firstLine) return null; const head = JSON.parse(firstLine) as { cwd?: unknown }; return typeof head.cwd === "string" ? head.cwd : null; } catch { return null; } finally { if (fd !== null) closeSync(fd); } } let server: WebServer | null = null; let tokens = new Set(); let passwordHash = ""; let curCtx: ExtensionContext | null = null; let curPi: ExtensionAPI | null = null; // Settings source for web `!` (one SettingsManager per command). The selftest // swaps this for an in-memory instance so tests never read the user's real // settings. export const bashSettingsRef: { factory: (cwd: string) => SettingsManager } = { factory: (cwd) => SettingsManager.create(cwd), }; const stopServer = (): void => { if (!server) return; server.stop(); server = null; if (curCtx) curCtx.ui.setStatus("webserve", undefined); }; export default function (pi: ExtensionAPI): void { curPi = pi; const api: WebApi = { getSnapshot() { const ctx = curCtx; if (!ctx) throw noCtxError(); const sm = ctx.sessionManager; return { // deviation: pi entry types are interfaces (no implicit index signature), so a plain // `e as AnyRec` is a TS2352 error; the `unknown` hop is erased at runtime, behavior unchanged. entries: sm.buildContextEntries().map((e) => sanitizeEntry(e as unknown as AnyRec)).filter((x): x is AnyRec => x !== null), meta: { cwd: sm.getCwd(), model: ctx.model ? (ctx.model as { provider: string; id: string }).provider + "/" + (ctx.model as { provider: string; id: string }).id : "", sessionName: sm.getSessionName() ?? null, leafId: sm.getLeafId(), // Same source as the terminal's /context: last real assistant usage + estimate for trailing messages. usage: ctx.getContextUsage() ?? null, }, }; }, allEntries() { const m = new Map(); if (curCtx) { // deviation: same interface -> AnyRec assertion fix as in getSnapshot above. for (const e of curCtx.sessionManager.getEntries()) m.set(e.id, e as unknown as AnyRec); } return m; }, async sendInput(text, mode: "steer" | "followUp" = "steer", images: ImageInput[] = []) { // Web `!`/`!!`: host bash, never a user message. A bang with images // falls through as a normal message, matching the terminal (a bang // means bash only as a plain-text line). if (images.length === 0) { const bash = parseBashLine(text); if (bash && bash.command) { void webBash(bash.command, bash.exclude).catch((err) => server?.broadcast("note", { text: "bash: " + (err as Error).message }), ); return { queued: false }; } } const ctx = curCtx; const p = curPi; if (!ctx || !p) throw noCtxError(); const idle = ctx.isIdle(); const opts = inputOpts(text, idle, mode); if (images.length) { // Images (base64 png/jpeg/webp/gif) ride as ImageContent parts alongside text. const parts: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = []; if (text) parts.push({ type: "text", text }); for (const im of images) parts.push({ type: "image", data: im.data, mimeType: im.mimeType }); p.sendUserMessage(parts, opts); } else { p.sendUserMessage(text, opts); } return { queued: !idle && opts.deliverAs === "followUp" }; }, stopAgent() { const ctx = curCtx; if (!ctx || ctx.isIdle()) return { aborted: false }; ctx.abort(); // programmatic Esc (spec §5 /stop) return { aborted: true }; }, }; const leaf = (): string | null => (curCtx ? curCtx.sessionManager.getLeafId() : null); const changed = (): void => { server?.onSessionChanged(leaf()); }; // --- web `!` bash: run in the background, stream to the web, record on completion --- let bashSeq = 0; let bashRunning = false; const webBash = async (command: string, exclude: boolean): Promise => { const ctx = curCtx; if (!ctx) { server?.broadcast("note", { text: "bash: session is ending" }); return; } if (bashRunning) { // parity with the TUI's "A bash command is already running. Press Esc to // cancel it first" — the web has no Esc, so the second command waits. server?.broadcast("note", { text: "bash already running — wait for it to finish" }); return; } bashRunning = true; const id = String(++bashSeq); const sm = ctx.sessionManager; server?.broadcast("bashstart", { id, command }); let shown = 0; let res: WebBashResult; try { // ponytail: fresh SettingsManager per command (two small JSON reads); cache it if bash gets frequent. const settings = bashSettingsRef.factory(ctx.cwd); res = await runWebBash( command, ctx.cwd, settings.getShellPath(), settings.getShellCommandPrefix(), (text) => { shown += text.length; if (shown <= BASHOUT_STREAM_LIMIT) server?.broadcast("bashout", { id, delta: text }); }, ); } catch (err) { // spawn-level failure (bad cwd, no shell found) — no session entry, just report. server?.broadcast("bashend", { id, error: true, text: (err as Error).message }); bashRunning = false; return; } // The session may have been replaced (/new, /fork, /resume) while the // command ran: the captured manager is stale — don't pollute the new // session's file with a result the user ran against the old one. The web // already streamed and showed the output. if (curCtx?.sessionManager === sm) { // deviation: the ctx type only exposes the read-only session manager; // at runtime it is the full SessionManager, and web `!` needs // appendMessage (the equivalent of pi's recordBashResult, which is // AgentSession-only). The BashExecutionMessage shape is pi's own // (dist/core/types.ts) — no exported name to import, so the cast is // structural. (sm as unknown as SessionManager).appendMessage({ role: "bashExecution", command, output: res.output, exitCode: res.exitCode, // type: number | undefined (absent when killed); convertToLlm treats absent and null the same cancelled: res.cancelled, truncated: res.truncated, fullOutputPath: res.fullOutputPath, timestamp: Date.now(), excludeFromContext: exclude, }); changed(); } server?.broadcast("bashend", { id, exitCode: res.exitCode, cancelled: res.cancelled, truncated: res.truncated }); bashRunning = false; }; // --- session lifecycle --- pi.on("session_start", (_e, ctx) => { curCtx = ctx; server?.resyncAll(); }); pi.on("session_shutdown", (e, ctx) => { curCtx = null; // quit/reload: this pi (or the extension) is going away — release the server. if (e.reason === "quit" || e.reason === "reload") { stopServer(); return; } // new/fork (and same-cwd resume): the session is being replaced — keep the // server; the following session_start resyncs all clients to the new session. if (e.reason === "resume" && e.targetSessionFile) { // Cross-cwd resume: pi's extension loader cache is keyed by cwd, so a // different-cwd target RE-IMPORTS this module — a fresh instance whose // module state is null, which would orphan the running server (stale // page, /input 503, port held, not stoppable from the new instance). // Stop it here so the browser gets a clean dead server instead. const curCwd = ctx.sessionManager.getCwd(); const targetCwd = sessionFileCwd(e.targetSessionFile); if (targetCwd !== null && targetCwd !== curCwd) stopServer(); } }); // --- live stream (spec §5 SSE events) --- pi.on("message_update", (e, ctx) => { curCtx = ctx; server?.broadcast("update", e.message); }); pi.on("message_end", (_e, ctx) => { curCtx = ctx; changed(); }); pi.on("tool_execution_start", (e, _ctx) => { server?.broadcast("toolstart", { id: e.toolCallId, name: e.toolName, args: e.args }); }); pi.on("tool_execution_end", (_e, ctx) => { curCtx = ctx; changed(); }); pi.on("model_select", (e, ctx) => { curCtx = ctx; server?.broadcast("meta", { model: (e.model as { provider: string; id: string }).provider + "/" + (e.model as { provider: string; id: string }).id, usage: ctx.getContextUsage() ?? null }); changed(); }); pi.on("session_compact", (_e, ctx) => { curCtx = ctx; changed(); }); pi.on("session_tree", (_e, ctx) => { curCtx = ctx; changed(); }); pi.on("session_info_changed", (_e, ctx) => { curCtx = ctx; changed(); }); pi.on("agent_start", (_e, ctx) => { curCtx = ctx; server?.broadcast("status", { busy: true }); }); pi.on("agent_settled", (_e, ctx) => { curCtx = ctx; server?.broadcast("status", { busy: false }); server?.broadcast("meta", { usage: ctx.getContextUsage() ?? null }); // pi persists each message after its message_end handlers ran, so the run's // trailing entry was one leaf behind; the run is fully settled and persisted // now, so flush it and let the final message finalize as markdown without // waiting for the next interaction. changed(); }); // --- web `!` visibility --- // Extensions can't push into the agent's in-memory state (recordBashResult // is AgentSession-only), so on every provider request we inject the // session's bashExecution entries the live message list is missing — web `!` // results the agent hasn't seen yet. Terminal `!` results are already in // state (matched by command+timestamp) and never double up; `!!` results // stay invisible; compaction drops still apply, because the source list is // the compaction-aware buildContextEntries(), exactly what the TUI does. pi.on("context", (e, ctx) => { const missing = missingBashMessages( ctx.sessionManager.buildContextEntries() as unknown as AnyRec[], e.messages as unknown as AnyRec[], ); if (missing.length === 0) return; const merged = mergeBashMessages(e.messages as unknown as AnyRec[], missing); return { messages: merged as unknown as ContextEvent["messages"] }; }); // --- ask_user_question bridge: web + terminal, first answer wins --- // The rpiv-ask-user-question tool would otherwise block on a TUI overlay in // the local terminal only. When a web client is watching, we answer the // call from the web modal and/or a minimal terminal overlay (this hook runs // before the tool executes; a returned {block} becomes the tool result the // model sees — we use the tool's own result wording so behavior is // indistinguishable). No web client -> return undefined -> the tool's own // rich TUI flow runs exactly as before. A web client that LEAVES mid-question // does not kill the web side: the ask stays pending and is replayed when a // client (re)connects, so a mobile tab that dies and comes back still gets // the modal. (ponytail: with no terminal side — headless — a client that // leaves and never returns leaves the agent waiting for Stop/abort instead // of falling through to the tool's own flow, which cannot render headless // either; acceptable for that corner case.) pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "ask_user_question") return; const questions = extractAskQuestions((event.input as AnyRec).questions); if (!questions) return; // malformed: the tool's own validation reports it if (questions.some((q) => q.options.some((o) => ASK_RESERVED_LABELS.includes(o.label)))) { return; // reserved label: the tool's own validator rejects it } if (!server || server.clientCount() === 0) return; // nobody on the web: TUI as usual const id = event.toolCallId; // Web side: modal in every connected client; first POST /ask-answer wins. const webSide = server.askUser(id, questions, ctx.signal); // Terminal side: our own closable overlay (only when this pi has a TUI). let closeTui: (() => void) | null = null; let termSide: Promise | null = null; if (ctx.hasUI && ctx.mode === "tui") { let comp: AskTuiComponent | null = null; termSide = ctx.ui.custom( (tui, _theme, _kb, done) => { comp = new AskTuiComponent(questions, tui, done); closeTui = () => comp?.close(); if (ctx.signal) ctx.signal.addEventListener("abort", closeTui, { once: true }); // don't leave a stuck overlay on Esc return comp; }, { overlay: true, overlayOptions: { anchor: "bottom-center", width: "100%" }, }, ).then((r) => r ?? null).catch(() => null); } const sides = termSide ? [webSide, termSide] : [webSide]; // First real answer wins; a null side means that surface died (server // stopped / signal aborted / host can't render / closed by the other side), // so keep waiting for the rest. All dead -> undefined -> the tool's own // flow. (Clients leaving is NOT a null side anymore — see above.) const outcome = await new Promise((resolve) => { let pending = sides.length; let done = false; for (const s of sides) { void s.then((o) => { if (done) return; if (o) { done = true; closeTui?.(); // terminal loses: hide the overlay (done(null)) server?.settleAsk(id, null); // web loses: close the modal (broadcasts ask-resolved) resolve(o); } else if (--pending === 0) { done = true; resolve(null); } }); } }); if (!outcome) return; return { block: true, reason: buildAskEnvelope(questions, outcome) }; }); // --- web-side equivalents of built-in commands --- // Same names as the TUI built-ins: in the terminal pi checks the built-ins // first, so TUI behavior is unchanged (cosmetic conflict diagnostic only); // the web path reaches these handlers via inputOpts' expandPromptTemplates. pi.registerCommand("new", { description: "Start a new session", handler: async (_args, ctx) => { const r = await ctx.newSession(); server?.broadcast("note", { text: r.cancelled ? "/new: cancelled" : "/new: new session started" }); }, }); pi.registerCommand("compact", { description: "Compact context: /compact [instructions]", handler: async (args, ctx) => { const instructions = (args ?? "").trim(); server?.broadcast("note", { text: "/compact: started" + (instructions ? " — " + instructions : "") }); ctx.compact(instructions ? { customInstructions: instructions, onComplete: () => { server?.broadcast("note", { text: "/compact: done" }); }, onError: (err) => { server?.broadcast("note", { text: "/compact failed: " + err.message }); }, } : undefined); }, }); // /model — the built-in one is TUI-only; this gives the web viewer its own. // Bare /model broadcasts the selectable set (scoped models when scoping is // configured, else everything with valid auth — the set the TUI picker // shows) as a `modelpick` event; the web page renders a one-click picker // modal whose pick re-sends `/model provider/model-id`. A bare id // (unambiguous) switches too. The model_select listener above updates the // web header automatically. pi.registerCommand("model", { description: "Show or switch the model: /model [provider/model-id]", handler: async (args, ctx) => { const scoped = ctx.scopedModels.map((s) => s.model); const pool = scoped.length > 0 ? scoped : ctx.modelRegistry.getAvailable(); const pick = (args ?? "").trim(); if (pick === "") { if (pool.length === 0) { server?.broadcast("note", { text: "/model: no available models — check API keys / models.json" }); return; } const cur = ctx.model ? ctx.model.provider + "/" + ctx.model.id : ""; server?.broadcast("modelpick", { choices: pool.map((m) => m.provider + "/" + m.id), current: cur }); return; } const slash = pick.indexOf("/"); let m = slash >= 0 ? ctx.modelRegistry.find(pick.slice(0, slash), pick.slice(slash + 1)) : undefined; if (slash < 0) { const cands = pool.filter((x) => x.id === pick); if (cands.length === 1) m = cands[0]; else if (cands.length > 1) { server?.broadcast("note", { text: "/model: '" + pick + "' is ambiguous — try: " + cands.map((x) => x.provider + "/" + x.id).join(", ") }); return; } } if (!m) { server?.broadcast("note", { text: "/model: unknown model '" + pick + "' — use bare /model to list available ones" }); return; } const ok = await pi.setModel(m); server?.broadcast("note", { text: ok ? "/model: " + m.provider + "/" + m.id : "/model: no API key for " + m.provider + "/" + m.id }); }, }); // /tree — the built-in one is a TUI picker; this gives the web viewer its // own. Bare /tree broadcasts the session's user messages as a `treepick` // event; the web page renders a one-click picker whose pick re-sends // `/tree `. ctx.navigateTree is the same runtime op as the TUI's // /tree: it throws while streaming, no-ops at the target, and fires // session_tree (listened above), which resyncs every web client. No branch // summary is requested (the TUI prompts for one; the web stays minimal). pi.registerCommand("tree", { description: "Jump to a previous point in this session: /tree [entry-id]", handler: async (args, ctx) => { const pick = (args ?? "").trim(); const leaf = ctx.sessionManager.getLeafId(); if (pick === "") { const raw = ctx.sessionManager.getEntries().map((e) => e as unknown as AnyRec); const points = raw .filter((e) => e.type === "message" && (e.message as AnyRec | undefined)?.role === "user") .map((e) => ({ id: e.id as string, text: entryPreview(e), ts: (e.timestamp as string) ?? "" })); if (points.length === 0) { server?.broadcast("note", { text: "/tree: no messages in this session yet" }); return; } server?.broadcast("treepick", { points, current: leaf }); return; } const before = leaf; let r: { cancelled: boolean }; try { r = await ctx.navigateTree(pick); } catch (err) { server?.broadcast("note", { text: "/tree: " + (err as Error).message }); return; } const after = ctx.sessionManager.getLeafId(); server?.broadcast("note", { text: r.cancelled ? "/tree: cancelled" : after !== before ? "/tree: jumped to " + pick : "/tree: already at " + pick }); }, }); // --- commands (spec §3) --- pi.registerCommand("webserve", { description: "Web session viewer: /webserve start [port] | stop | status", handler: async (args, ctx) => { const parts = (args ?? "").trim().split(/\s+/).filter(Boolean); const sub = parts[0]; if (sub === undefined || sub === "start") { if (server) { ctx.ui.notify("web viewer already running on port " + server.port, "info"); return; } const reqPort = sub === undefined ? 8765 : Number(parts[1]); if (!Number.isInteger(reqPort) || reqPort < 1 || reqPort > 65535) { ctx.ui.notify("Usage: /webserve start [port] (port 1-65535, default 8765)", "warning"); return; } const pw = await ctx.ui.input("Web viewer password", "min 4 chars"); if (!pw || pw.length < 4) { ctx.ui.notify("password must be 4+ chars; server not started", "warning"); return; } passwordHash = hashPassword(pw); tokens = new Set(); curCtx = ctx; // command ctx is valid for this session; covers the gap before the first event let lastErr: { message?: string } | null = null; for (let p = reqPort; p < reqPort + 10; p++) { try { server = await startServer({ host: "0.0.0.0", port: p, passwordHash, tokens, api }); break; } catch (err) { lastErr = err as { message?: string }; if ((err as { code?: string }).code !== "EADDRINUSE") break; } } if (!server) { passwordHash = ""; ctx.ui.notify("web viewer failed to start: " + (lastErr?.message ?? "unknown error"), "error"); return; } ctx.ui.notify("web viewer: " + lanUrls(server.port).join(" ") + " (password required)", "info"); ctx.ui.setStatus("webserve", "web :" + server.port); } else if (sub === "stop") { stopServer(); ctx.ui.notify("web viewer stopped", "info"); } else if (sub === "status") { ctx.ui.notify(server ? "web viewer running on port " + server.port : "web viewer not running", "info"); } else { ctx.ui.notify("Usage: /webserve start [port] | stop | status", "warning"); } }, }); }