/** * pi-ask-bridge — Unix-domain socket server that pipes ask requests from * the pi-ask-mcp subprocess (running inside Claude CLI inside chat-claude) * into pi's native ask UI (askSingleQuestionWithInlineNote / askQuestionsWithTabs). * * Architecture: * * pi process * └── chat-claude extension * ├── AskBridge (here) — listens on $PI_ASK_SOCKET * └── claude -p ... --mcp-config * └── pi-ask-mcp/server.js * ↳ on tools/call ask → connects to $PI_ASK_SOCKET, * sends question, awaits answer * * Lifecycle: start one bridge per chat-claude session; close on exit. * Concurrency: pi's ui.custom overlay is modal, so asks are serialised * across all open connections via an internal promise chain. * * Wire format (NDJSON, one message per line): * * request → { id, type: "ask", questions: [{id, question, options[], multi?, recommended?}, ...] } * response ← { id, type: "result", results: [{id, selectedOptions[], customInput?}, ...] } * error ← { id, type: "error", message: "…" } */ import { createServer, type Server as NetServer, type Socket } from "node:net"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent"; import { askSingleQuestionWithInlineNote } from "../extensions/pi-ask-tool/ask-inline-ui.js"; import { askQuestionsWithTabs } from "../extensions/pi-ask-tool/ask-tabs-ui.js"; import type { AskQuestion, AskSelection } from "../extensions/pi-ask-tool/ask-logic.js"; // ============================================================================= // Public API // ============================================================================= export interface AskBridge { /** Path to the generated --mcp-config JSON, suitable for `claude --mcp-config`. */ mcpConfigPath: string; /** Absolute path to the underlying Unix socket (informational). */ socketPath: string; /** How many ask requests this bridge has served so far. */ count(): number; /** Stop accepting connections, remove socket + temp dir. Idempotent. */ close(): void; } export interface StartAskBridgeOptions { /** pi UI context (must come from an interactive session). */ ui: ExtensionUIContext; /** * Absolute path to extensions/pi-ask-mcp/server.js. Auto-derived from * import.meta.url when omitted (assumes the conventional layout). */ mcpServerEntry?: string; /** * MCP server name surfaced in the tool prefix. Defaults to "pi", which * makes the tool name `mcp__pi__ask` in Claude's tool stream. */ serverName?: string; /** Optional notification fired whenever a new ask is served. */ onAsk?: (totalSoFar: number) => void; } export function startAskBridge(opts: StartAskBridgeOptions): AskBridge { const dir = mkdtempSync(join(tmpdir(), "pi-ask-")); // 0700 perms const sock = join(dir, "ask.sock"); let askCount = 0; let closed = false; const server: NetServer = createServer((conn) => handleConnection(conn, opts, () => { askCount += 1; opts.onAsk?.(askCount); return askCount; }), ); server.on("error", () => { /* socket disappeared, etc. — bridge is single-tenant, ignore */ }); server.listen(sock); const mcpEntry = opts.mcpServerEntry ?? defaultMcpEntry(); const serverName = opts.serverName ?? "pi"; const cfgPath = join(dir, "mcp.json"); writeFileSync(cfgPath, JSON.stringify({ mcpServers: { [serverName]: { command: "node", args: [mcpEntry], env: { PI_ASK_SOCKET: sock }, }, }, })); return { socketPath: sock, mcpConfigPath: cfgPath, count: () => askCount, close: () => { if (closed) return; closed = true; try { server.close(); } catch { /* noop */ } try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }, }; } // ============================================================================= // Internals // ============================================================================= function defaultMcpEntry(): string { // shared/pi-ask-bridge.ts → ../extensions/pi-ask-mcp/server.js const here = dirname(fileURLToPath(import.meta.url)); return join(here, "..", "extensions", "pi-ask-mcp", "server.js"); } // pi.ui.custom is modal — only one overlay can be on screen at a time. // Serialise asks across ALL connections via this single promise chain. let askChain: Promise = Promise.resolve(); function handleConnection( conn: Socket, opts: StartAskBridgeOptions, bumpCount: () => number, ) { let buf = ""; conn.on("data", (data) => { buf += data.toString(); let nl = buf.indexOf("\n"); while (nl >= 0) { const line = buf.slice(0, nl); buf = buf.slice(nl + 1); handleLine(line, conn, opts, bumpCount); nl = buf.indexOf("\n"); } }); conn.on("error", () => { /* peer might disappear if Claude is killed mid-flight */ }); } function handleLine( line: string, conn: Socket, opts: StartAskBridgeOptions, bumpCount: () => number, ) { if (!line.trim()) return; let msg: any; try { msg = JSON.parse(line); } catch { return; } if (msg.type !== "ask") return; const id = String(msg.id ?? ""); const questions = Array.isArray(msg.questions) ? (msg.questions as AskQuestion[]) : []; askChain = askChain.then(async () => { bumpCount(); try { const results = await askViaPiUI(opts.ui, questions); writeReply(conn, { id, type: "result", results }); } catch (err) { writeReply(conn, { id, type: "error", message: err instanceof Error ? err.message : String(err), }); } }); } function writeReply(conn: Socket, msg: unknown) { try { conn.write(JSON.stringify(msg) + "\n"); conn.end(); } catch { /* gone */ } } interface QuestionResult { id: string; selectedOptions: string[]; customInput?: string; } async function askViaPiUI( ui: ExtensionUIContext, questions: AskQuestion[], ): Promise { if (questions.length === 0) return []; if (questions.length === 1 && !questions[0].multi) { const sel: AskSelection = await askSingleQuestionWithInlineNote(ui, questions[0]); return [toResult(questions[0], sel)]; } const tab = await askQuestionsWithTabs(ui, questions); return questions.map((q, i) => toResult(q, tab.selections[i] ?? { selectedOptions: [] })); } function toResult(q: AskQuestion, sel: AskSelection): QuestionResult { const out: QuestionResult = { id: q.id, selectedOptions: [...sel.selectedOptions] }; if (sel.customInput) out.customInput = sel.customInput; return out; }