UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

900 lines (891 loc) 31.1 kB
'use strict'; var node_crypto = require('node:crypto'); var fs$1 = require('node:fs'); var path = require('node:path'); var persistence = require('./types-B_i6lpTn.cjs'); var createSessionMetadata = require('./createSessionMetadata-CVgp25Mn.cjs'); var index = require('./index-BMIckAk5.cjs'); var constants = require('./constants-E1WsKKcj.cjs'); var config = require('./config-DbiNH7R2.cjs'); var versionCheck = require('./versionCheck-DMuZYE4L.cjs'); var net = require('node:net'); var os = require('node:os'); var fs = require('node:fs/promises'); var BasePermissionHandler = require('./BasePermissionHandler-DM5JDRsB.cjs'); var installExtension = require('./installExtension-Ju3zVFN-.cjs'); require('axios'); require('chalk'); require('fs'); require('node:events'); require('socket.io-client'); require('zod'); require('tweetnacl'); require('child_process'); require('util'); require('fs/promises'); require('crypto'); require('path'); require('url'); require('os'); require('node:child_process'); require('node:module'); require('node:util'); require('expo-server-sdk'); require('node:readline'); require('ink'); require('react'); require('node:url'); require('ps-list'); require('cross-spawn'); require('tmp'); require('qrcode-terminal'); require('open'); require('fastify'); require('fastify-type-provider-zod'); require('http'); require('@modelcontextprotocol/sdk/client/index.js'); require('@modelcontextprotocol/sdk/client/streamableHttp.js'); require('readline'); require('@modelcontextprotocol/sdk/server/mcp.js'); require('node:http'); require('@modelcontextprotocol/sdk/server/streamableHttp.js'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1); var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path); var net__namespace = /*#__PURE__*/_interopNamespaceDefault(net); var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os); var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs); const READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["read", "grep", "find", "ls"]); const DESTRUCTIVE_TOOLS = /* @__PURE__ */ new Set(["bash", "edit", "write"]); const LOG_PREFIX = "[Pi]"; class PiPermissionHandler extends BasePermissionHandler.BasePermissionHandler { constructor(session) { super(session); } getLogPrefix() { return LOG_PREFIX; } ask(callId, toolName, input) { return this.createPendingRequest(callId, toolName, input); } } function makeSocketPath(sessionId) { const shortHash = node_crypto.createHash("sha256").update(sessionId).digest("hex").slice(0, 12); if (process.platform === "win32") { return `\\\\.\\pipe\\consortium-pi-${shortHash}`; } const tmp = process.env.TMPDIR || os__namespace.tmpdir() || "/tmp"; return path__namespace.join(tmp, `cnsrtm-pi-${shortHash}.sock`); } function localDecision(mode, tool) { const normalized = tool.toLowerCase(); if (mode === "yolo" || mode === "bypassPermissions") return "allow"; if (mode === "plan" && DESTRUCTIVE_TOOLS.has(normalized)) return "deny"; if (mode === "acceptEdits" || mode === "safe-yolo") { return READ_ONLY_TOOLS.has(normalized) ? "allow" : "ask"; } if (mode === "read-only") { return READ_ONLY_TOOLS.has(normalized) ? "allow" : "deny"; } return "ask"; } function failsafeDecision(tool) { const normalized = tool.toLowerCase(); if (READ_ONLY_TOOLS.has(normalized)) return "allow"; return "deny"; } function createPiPermissionBridge(opts) { const socketPath = makeSocketPath(opts.sessionId); const handler = new PiPermissionHandler(opts.apiSession); let currentMode = opts.permissionMode; let server = null; let started = false; let stopped = false; const liveSockets = /* @__PURE__ */ new Set(); const writeResponse = (sock, frame) => { try { sock.write(JSON.stringify(frame) + "\n"); } catch (err) { opts.logger.debug(`${LOG_PREFIX} failed to write permission response`, { err }); } }; const handleFrame = async (sock, raw) => { let frame; try { const parsed = JSON.parse(raw); if (parsed.kind !== "permission_request" || typeof parsed.id !== "string" || typeof parsed.tool !== "string") { throw new Error("malformed permission_request"); } frame = parsed; } catch (err) { opts.logger.debug(`${LOG_PREFIX} dropping malformed frame`, { err, raw: raw.slice(0, 256) }); return; } const decision = localDecision(currentMode, frame.tool); if (decision === "allow") { opts.logger.debug(`${LOG_PREFIX} local-allow id=${frame.id} tool=${frame.tool} mode=${currentMode}`); writeResponse(sock, { kind: "permission_response", id: frame.id, decision: "allow" }); return; } if (decision === "deny") { opts.logger.debug(`${LOG_PREFIX} local-deny id=${frame.id} tool=${frame.tool} mode=${currentMode}`); writeResponse(sock, { kind: "permission_response", id: frame.id, decision: "deny", reason: currentMode === "plan" ? "Plan mode: write tools are not permitted" : `Tool ${frame.tool} not allowed in ${currentMode} mode` }); return; } try { const result = await handler.ask(frame.callId || frame.id, frame.tool, frame.arguments); const approved = result.decision === "approved" || result.decision === "approved_for_session"; writeResponse(sock, { kind: "permission_response", id: frame.id, decision: approved ? "allow" : "deny", reason: approved ? void 0 : `User ${result.decision} the request` }); } catch (err) { const fallback = failsafeDecision(frame.tool); opts.logger.debug(`${LOG_PREFIX} ask path failed, applying failsafe`, { err, tool: frame.tool, fallback }); writeResponse(sock, { kind: "permission_response", id: frame.id, decision: fallback, reason: fallback === "deny" ? "Approval channel error; denying destructive tool" : void 0 }); } }; const handleConnection = (sock) => { liveSockets.add(sock); sock.setEncoding("utf8"); let buffer = ""; sock.on("data", (chunk) => { buffer += chunk; let idx = buffer.indexOf("\n"); while (idx !== -1) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (line.length > 0) { void handleFrame(sock, line); } idx = buffer.indexOf("\n"); } }); sock.on("error", (err) => { opts.logger.debug(`${LOG_PREFIX} client socket error`, { err }); }); sock.on("close", () => { liveSockets.delete(sock); }); }; const start = async () => { if (started) return; started = true; if (process.platform !== "win32") { await fs__namespace.unlink(socketPath).catch(() => void 0); } await new Promise((resolve, reject) => { const srv = net__namespace.createServer(handleConnection); srv.once("error", reject); srv.listen(socketPath, () => { srv.removeListener("error", reject); server = srv; opts.logger.debug(`${LOG_PREFIX} permission bridge listening at ${socketPath}`); resolve(); }); }); }; const stop = async () => { if (stopped) return; stopped = true; handler.rejectAllPending("Pi permission bridge stopping"); for (const sock of Array.from(liveSockets)) { try { sock.destroy(); } catch { } } liveSockets.clear(); if (server) { await new Promise((resolve) => server.close(() => resolve())); server = null; } if (process.platform !== "win32") { await fs__namespace.unlink(socketPath).catch(() => void 0); } opts.logger.debug(`${LOG_PREFIX} permission bridge stopped`); }; const setPermissionMode = (mode) => { if (mode === currentMode) return; opts.logger.debug(`${LOG_PREFIX} permission mode changed: ${currentMode} -> ${mode}`); currentMode = mode; }; const updateSession = (newSession) => { handler.updateSession(newSession); }; return { socketPath, start, stop, setPermissionMode, updateSession }; } const FILENAME_RE = /^(?:.+)_([0-9a-fA-F-]{36})\.jsonl$/; const POLL_INTERVAL_MS = 750; function extractSessionIdFromFilename(filename) { const m = filename.match(FILENAME_RE); return m ? m[1] : null; } async function createPiSessionScanner(opts) { const watched = /* @__PURE__ */ new Map(); let stopped = false; let dirWatcher = null; let dirPollTimer = null; try { await fs__namespace$1.promises.mkdir(opts.sessionDir, { recursive: true }); } catch (err) { persistence.logger.debug(`[PiSessionScanner] Failed to mkdir ${opts.sessionDir}:`, err); } const drainFile = async (state, fromStart) => { let stat; try { stat = await fs__namespace$1.promises.stat(state.filePath); } catch (err) { return; } const startAt = fromStart ? 0 : state.offset; if (stat.size <= startAt) return; let fd; try { fd = await fs__namespace$1.promises.open(state.filePath, "r"); } catch (err) { persistence.logger.debug(`[PiSessionScanner] Failed to open ${state.filePath}:`, err); return; } try { const length = stat.size - startAt; const buf = Buffer.alloc(length); await fd.read(buf, 0, length, startAt); state.offset = stat.size; state.buffer += buf.toString("utf8"); let idx = state.buffer.indexOf("\n"); while (idx !== -1) { const line = state.buffer.slice(0, idx).trim(); state.buffer = state.buffer.slice(idx + 1); if (line.length > 0) { try { const entry = JSON.parse(line); opts.onMessage(entry, { sessionId: state.sessionId, filePath: state.filePath }); } catch (e) { persistence.logger.debug(`[PiSessionScanner] JSON parse failed: ${e.message}; line=${line.slice(0, 200)}`); } } idx = state.buffer.indexOf("\n"); } } finally { await fd.close().catch(() => void 0); } }; const beginWatchingFile = async (filePath, sessionId, fromStart) => { if (watched.has(sessionId)) return; const state = { filePath, sessionId, offset: 0, buffer: "" }; watched.set(sessionId, state); opts.onSessionFound(sessionId, filePath); if (fromStart) { await drainFile(state, true); } else { try { const stat = await fs__namespace$1.promises.stat(filePath); state.offset = stat.size; } catch { state.offset = 0; } } try { state.fsWatcher = fs__namespace$1.watch(filePath, { persistent: false }, () => { void drainFile(state, false); }); state.fsWatcher.on("error", (err) => { persistence.logger.debug(`[PiSessionScanner] File watcher error on ${filePath}:`, err); }); } catch (err) { persistence.logger.debug(`[PiSessionScanner] fs.watch failed on ${filePath}:`, err); } state.pollTimer = setInterval(() => { void drainFile(state, false); }, POLL_INTERVAL_MS); }; const scanDirOnce = async () => { let entries = []; try { entries = await fs__namespace$1.promises.readdir(opts.sessionDir); } catch (err) { persistence.logger.debug(`[PiSessionScanner] readdir(${opts.sessionDir}) failed:`, err); return; } for (const name of entries) { if (!name.endsWith(".jsonl")) continue; const sid = extractSessionIdFromFilename(name); if (!sid) continue; if (watched.has(sid)) continue; const filePath = path__namespace.join(opts.sessionDir, name); const fromStart = !!(opts.initialSessionId && opts.initialSessionId === sid); await beginWatchingFile(filePath, sid, fromStart); } }; if (opts.initialSessionId) { try { const entries = await fs__namespace$1.promises.readdir(opts.sessionDir); const match = entries.find((n) => extractSessionIdFromFilename(n) === opts.initialSessionId); if (match) { await beginWatchingFile(path__namespace.join(opts.sessionDir, match), opts.initialSessionId, true); } } catch (err) { persistence.logger.debug(`[PiSessionScanner] resume pre-attach failed:`, err); } } await scanDirOnce(); try { dirWatcher = fs__namespace$1.watch(opts.sessionDir, { persistent: false }, (_event, _filename) => { void scanDirOnce(); }); dirWatcher.on("error", (err) => { persistence.logger.debug(`[PiSessionScanner] dir watcher error:`, err); }); } catch (err) { persistence.logger.debug(`[PiSessionScanner] fs.watch on dir failed:`, err); } dirPollTimer = setInterval(() => { void scanDirOnce(); }, POLL_INTERVAL_MS * 2); return { stop: () => { if (stopped) return; stopped = true; if (dirWatcher) { try { dirWatcher.close(); } catch { } dirWatcher = null; } if (dirPollTimer) { clearInterval(dirPollTimer); dirPollTimer = null; } for (const state of watched.values()) { if (state.fsWatcher) { try { state.fsWatcher.close(); } catch { } } if (state.pollTimer) clearInterval(state.pollTimer); } watched.clear(); } }; } function publishPiEntryToConsortium(entry, session, hooks = {}) { try { switch (entry.type) { case "session": persistence.logger.debug(`[Pi] session start entry v=${entry.version} cwd=${entry.cwd ?? "?"}`); return; case "model_change": { const model = entry.modelId; const provider = entry.provider; if (model || provider) { session.updateMetadata((m) => ({ ...m, ...model ? { model } : {}, ...provider ? { provider } : {} })); } return; } case "thinking_level_change": persistence.logger.debug(`[Pi] thinking level -> ${entry.thinkingLevel}`); return; case "message": { const msg = entry.message; if (!msg) return; const role = msg.role; if (role === "user") { const text = extractText(msg.content); if (text) hooks.onLocalUserMessage?.(text); return; } if (role === "assistant") { const items = Array.isArray(msg.content) ? msg.content : []; let sawContent = false; for (const item of items) { if (!item || typeof item !== "object") continue; if (item.type === "text" && typeof item.text === "string" && item.text.length > 0) { session.sendAgentMessage("pi", { type: "message", message: item.text, id: node_crypto.randomUUID() }); sawContent = true; } else if (item.type === "toolCall" || item.type === "tool_use") { session.sendAgentMessage("pi", { type: "tool-call", callId: typeof item.id === "string" ? item.id : node_crypto.randomUUID(), name: typeof item.name === "string" ? item.name : "tool", input: item.arguments, id: node_crypto.randomUUID() }); sawContent = true; } } if (msg.errorMessage) { session.sendAgentMessage("pi", { type: "message", message: `Error: ${msg.errorMessage}`, id: node_crypto.randomUUID() }); session.sendAgentMessage("pi", { type: "turn_aborted", id: node_crypto.randomUUID() }); } if (msg.model || msg.provider) { session.updateMetadata((m) => ({ ...m, ...msg.model ? { model: msg.model } : {}, ...msg.provider ? { provider: msg.provider } : {} })); } if (sawContent) hooks.onAssistantMessage?.(); return; } if (role === "toolResult" || role === "tool_result") { const items = Array.isArray(msg.content) ? msg.content : []; for (const item of items) { if (!item || typeof item !== "object") continue; const callId = typeof item.toolCallId === "string" ? item.toolCallId : node_crypto.randomUUID(); const output = item.output ?? item.content ?? item.text; const isError = Boolean(item.isError); session.sendAgentMessage("pi", { type: "tool-result", callId, output, isError, id: node_crypto.randomUUID() }); } return; } persistence.logger.debug(`[Pi] unhandled message role: ${role}`); return; } default: persistence.logger.debug(`[Pi] unhandled entry type: ${entry.type}`); return; } } catch (err) { persistence.logger.debug("[Pi] publishPiEntryToConsortium error:", err); } } function extractText(content) { if (!Array.isArray(content)) return ""; return content.filter((c) => c && typeof c === "object" && c.type === "text" && typeof c.text === "string").map((c) => c.text).join(""); } const KEEPALIVE_MS = 2e3; const MAX_PTY_INPUT_LENGTH = 10240; async function resolvePiProviderEnv(providerId, credentialsToken) { const env = {}; const provider = index.getProvider("pi", providerId); if (!provider) { persistence.logger.debug(`[Pi] Unknown providerId='${providerId}' \u2014 leaving env untouched`); return env; } if (provider.isConsortiumProxy) { const proxyBase = process.env.CONSORTIUM_PROXY_URL ?? `${persistence.configuration.serverUrl}/v1/proxy/consortium`; env.ANTHROPIC_BASE_URL = proxyBase; const token = process.env.CONSORTIUM_PROXY_TOKEN_CONSORTIUM ?? process.env.CONSORTIUM_PROXY_TOKEN ?? credentialsToken; env.ANTHROPIC_API_KEY = token; return env; } if (!provider.keyEnvVar) return env; const existing = process.env[provider.keyEnvVar]; if (existing && existing.length > 0) { env[provider.keyEnvVar] = existing; return env; } try { const stashed = await index.getProviderKeyViaDaemon(providerId); if (stashed) { env[provider.keyEnvVar] = stashed; return env; } } catch (err) { persistence.logger.debug(`[Pi] getProviderKeyViaDaemon(${providerId}) threw:`, err); } persistence.logger.debug(`[Pi] No key available for provider='${providerId}' (env and daemon both empty)`); return env; } async function runPi(opts) { const sessionTag = node_crypto.randomUUID(); persistence.connectionState.setBackend("Pi"); const remoteOnly = opts.startingMode === "remote"; const versionResult = await versionCheck.checkPiVersion(); if (!versionResult.ok) { console.error( `Pi CLI not available: ${versionResult.message} Install Pi (>= ${constants.MIN_PI_VERSION}) via: npm install -g @earendil-works/pi-coding-agent` ); process.exit(1); } let permissionExtensionInstalled = true; try { const extResult = await installExtension.ensurePiExtensionInstalled(); if (extResult.skipped) { persistence.logger.debug(`[Pi] Extension install skipped: ${extResult.skipped}`); } else { persistence.logger.debug(`[Pi] Extension installed at ${extResult.targetDir}`); } } catch (err) { permissionExtensionInstalled = false; persistence.logger.debug("[Pi] Extension install failed (continuing without permission gating):", err); } const api = await persistence.ApiClient.create(opts.credentials); const settings = await persistence.readSettings(); const machineId = settings?.machineId; if (!machineId) { console.error("[START] No machine ID found in settings. Run `consortium auth` first."); process.exit(1); } await api.getOrCreateMachine({ machineId, metadata: index.initialMachineMetadata }); const fallback = config.getInitialPiProviderAndModel(); const piSupport = index.AGENT_PROVIDERS.pi; const catalogDefaultProvider = piSupport.defaultProviderId; const catalogDefaultModel = piSupport.providers.find( (p) => p.id === catalogDefaultProvider )?.models[0]?.id ?? fallback.model; const initial = { provider: opts.cliProviderId ?? fallback.provider ?? catalogDefaultProvider, model: opts.cliModelId ?? fallback.model ?? catalogDefaultModel }; let currentProviderId = initial.provider; let currentModelId = initial.model; let currentPermissionMode = "default"; const { state, metadata: baseMetadata } = createSessionMetadata.createSessionMetadata({ flavor: "pi", machineId, startedBy: opts.startedBy }); const metadata = { ...baseMetadata, ...initial.model ? { model: initial.model } : {}, ...initial.provider ? { provider: initial.provider } : {} }; const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); if (!response) { console.error("Failed to create Consortium session (server unreachable)."); process.exit(1); } const session = api.sessionSyncClient(response); try { const result = await index.notifyDaemonSessionStarted(response.id, metadata); if (result.error) persistence.logger.debug("[Pi] notifyDaemonSessionStarted error:", result.error); } catch (err) { persistence.logger.debug("[Pi] notifyDaemonSessionStarted threw:", err); } session.updateAgentState((s) => ({ ...s, controlledByUser: !remoteOnly })); let permissionBridge = null; if (permissionExtensionInstalled) { try { permissionBridge = createPiPermissionBridge({ sessionId: session.sessionId, apiSession: session, permissionMode: currentPermissionMode, logger: persistence.logger }); await permissionBridge.start(); } catch (err) { persistence.logger.debug("[Pi] Failed to start permission bridge:", err); permissionBridge = null; } } const sessionDir = path.join(config.getPiAgentDir(), "sessions"); try { fs$1.mkdirSync(sessionDir, { recursive: true }); } catch { } let piSessionId = opts.resumeSessionId ?? null; let lastUserMessageAt = null; let thinking = false; const setThinking = (v) => { if (thinking === v) return; thinking = v; session.keepAlive(thinking, "remote"); try { session.sendAgentMessage( "pi", v ? { type: "task_started", id: node_crypto.randomUUID() } : { type: "task_complete", id: node_crypto.randomUUID() } ); } catch (err) { persistence.logger.debug("[Pi] sendAgentMessage(task_*) failed:", err); } }; const scanner = await createPiSessionScanner({ sessionDir, initialSessionId: piSessionId, onSessionFound: (sid) => { piSessionId = sid; session.updateMetadata((m) => ({ ...m, agentSessionId: sid })); persistence.logger.debug(`[Pi] Discovered Pi session id: ${sid}`); }, onMessage: (entry) => { if (entry.type === "message" && entry.message?.role === "user") { lastUserMessageAt = Date.now(); setThinking(true); } else if (entry.type === "message" && entry.message?.role === "assistant") { lastUserMessageAt = null; } publishPiEntryToConsortium(entry, session, { onAssistantMessage: () => setThinking(false) }); } }); const args = [ "--session-dir", sessionDir, "--provider", currentProviderId, "--model", currentModelId, ...opts.resumeSessionId ? ["--session", opts.resumeSessionId] : [] ]; const env = {}; for (const [k, v] of Object.entries(process.env)) { if (typeof v === "string") env[k] = v; } if (permissionBridge) { env.PI_CONSORTIUM_PERMISSION_SOCKET = permissionBridge.socketPath; } try { const providerEnv = await resolvePiProviderEnv(currentProviderId, opts.credentials.token); Object.assign(env, providerEnv); } catch (err) { persistence.logger.debug("[Pi] resolvePiProviderEnv threw at spawn:", err); } const cols = process.stdout.columns ?? 100; const rows = process.stdout.rows ?? 30; persistence.logger.debug(`[Pi] Spawning ${constants.PI_BINARY} ${args.join(" ")}`); let term; try { term = persistence.spawnPtyWithDiagnostics(constants.PI_BINARY, args, { name: process.env.TERM || "xterm-256color", cols, rows, cwd: process.cwd(), env }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`Failed to spawn Pi: ${msg}`); await permissionBridge?.stop().catch(() => void 0); scanner.stop(); try { session.sendSessionDeath(); await session.flush(); await session.close(); } catch { } process.exit(1); } const hasTTY = !remoteOnly && process.stdout.isTTY && process.stdin.isTTY; const onStdinData = (chunk) => { term.write(chunk.toString("utf8")); }; const onResize = () => { try { term.resize(process.stdout.columns ?? cols, process.stdout.rows ?? rows); } catch { } }; if (hasTTY) { try { process.stdin.setRawMode(true); } catch { } process.stdin.resume(); process.stdin.on("data", onStdinData); term.onData((data) => process.stdout.write(data)); process.stdout.on("resize", onResize); } else { term.onData((data) => persistence.logger.debug(`[Pi:pty] ${data.slice(0, 500)}`)); } let hasStoredFirstMessage = false; session.onUserMessage((message) => { const text = message.content?.text; if (!text) return; if (!hasStoredFirstMessage) { hasStoredFirstMessage = true; session.updateMetadata((m) => ({ ...m, firstMessage: text.substring(0, 100) })); } const metaAny = message.meta ?? {}; const requestedProviderRaw = metaAny.providerId ?? metaAny.provider; const requestedProvider = typeof requestedProviderRaw === "string" ? requestedProviderRaw : void 0; const requestedModel = typeof message.meta?.model === "string" ? message.meta.model : void 0; if (requestedProvider && requestedProvider !== currentProviderId) { persistence.logger.debug( `[Pi] Provider change requested (${currentProviderId} \u2192 ${requestedProvider}); not hot-swappable, asking the user to fork.` ); try { session.sendAgentMessage("pi", { type: "message", message: `Provider change (${currentProviderId} \u2192 ${requestedProvider}) requires a new session. Fork this session to switch providers.`, id: node_crypto.randomUUID() }); session.sendAgentMessage("pi", { type: "task_complete", id: node_crypto.randomUUID() }); } catch (err) { persistence.logger.debug("[Pi] failed to surface provider-switch warning:", err); } } else if (requestedModel && requestedModel !== currentModelId) { persistence.logger.debug(`[Pi] Hot-swapping model ${currentModelId} \u2192 ${requestedModel}`); try { term.write(`/model ${requestedModel}\r`); currentModelId = requestedModel; session.updateMetadata((m) => ({ ...m, model: requestedModel })); } catch (err) { persistence.logger.debug("[Pi] /model hot-swap failed:", err); } } if (message.meta?.permissionMode) { const valid = [ "default", "read-only", "safe-yolo", "yolo", "plan", "acceptEdits", "bypassPermissions" ]; const mode = message.meta.permissionMode; if (valid.includes(mode)) { currentPermissionMode = mode; permissionBridge?.setPermissionMode(mode); } } const sanitized = sanitizePtyInput(text); if (!sanitized) return; term.write(sanitized); setTimeout(() => term.write("\r"), 50); lastUserMessageAt = Date.now(); setThinking(true); }); session.rpcHandlerManager.registerHandler("abort", async () => { try { term.write(""); } catch { } try { session.sendAgentMessage("pi", { type: "turn_aborted", id: node_crypto.randomUUID() }); } catch { } setThinking(false); return { ok: true }; }); session.keepAlive(thinking, "remote"); const keepAliveTimer = setInterval(() => session.keepAlive(thinking, "remote"), KEEPALIVE_MS); let killed = false; const teardown = async (exitCode) => { if (killed) return; killed = true; try { if (hasTTY) process.stdin.removeListener("data", onStdinData); } catch { } try { if (hasTTY) process.stdin.setRawMode(false); } catch { } try { if (hasTTY) process.stdin.pause(); } catch { } try { process.stdout.removeListener("resize", onResize); } catch { } clearInterval(keepAliveTimer); scanner.stop(); await permissionBridge?.stop().catch(() => void 0); index.stopCaffeinate(); try { session.updateMetadata((m) => ({ ...m, lifecycleState: "archived", lifecycleStateSince: Date.now(), archivedBy: "cli", archiveReason: exitCode === 0 ? "Pi exited" : `Pi exited with code ${exitCode}` })); session.sendSessionDeath(); await session.flush(); await session.close(); } catch (err) { persistence.logger.debug("[Pi] error closing session:", err); } }; index.registerKillSessionHandler(session.rpcHandlerManager, async () => { persistence.logger.debug("[Pi] killSession received"); try { term.kill(); } catch { } await teardown(0); process.exit(0); }); session.sendSessionEvent({ type: "ready" }); const stuckThinkingTimer = setInterval(() => { if (!thinking || lastUserMessageAt === null) return; if (Date.now() - lastUserMessageAt > 5 * 60 * 1e3) { persistence.logger.debug("[Pi] thinking watchdog: clearing stale thinking state"); setThinking(false); lastUserMessageAt = null; } }, 3e4); await new Promise((resolve) => { term.onExit(async ({ exitCode }) => { persistence.logger.debug(`[Pi] PTY exited code=${exitCode}`); clearInterval(stuckThinkingTimer); await teardown(exitCode); resolve(); }); }); process.exit(0); } function sanitizePtyInput(text) { const cleaned = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b[^[\]]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ""); return cleaned.slice(0, MAX_PTY_INPUT_LENGTH); } exports.runPi = runPi; exports.sanitizePtyInput = sanitizePtyInput;