consortium
Version:
Remote control and session sharing CLI for AI coding agents
876 lines (870 loc) • 29.6 kB
JavaScript
import { createHash, randomUUID } from 'node:crypto';
import * as fs$1 from 'node:fs';
import { mkdirSync } from 'node:fs';
import * as path from 'node:path';
import { join } from 'node:path';
import { l as logger, h as connectionState, A as ApiClient, r as readSettings, i as spawnPtyWithDiagnostics, c as configuration } from './types-DETLaopx.mjs';
import { c as createSessionMetadata } from './createSessionMetadata-I6-IgJ9q.mjs';
import { i as initialMachineMetadata, A as AGENT_PROVIDERS, n as notifyDaemonSessionStarted, r as registerKillSessionHandler, j as getProvider, k as getProviderKeyViaDaemon, s as stopCaffeinate } from './index-DiNLHtkZ.mjs';
import { MIN_PI_VERSION, PI_BINARY } from './constants-Ca5m6O9r.mjs';
import { getInitialPiProviderAndModel, getPiAgentDir } from './config-CuH6yYUW.mjs';
import { checkPiVersion } from './versionCheck-B7WDZlw9.mjs';
import * as net from 'node:net';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import { B as BasePermissionHandler } from './BasePermissionHandler-DT5te0lc.mjs';
import { ensurePiExtensionInstalled } from './installExtension-DMdHagAF.mjs';
import 'axios';
import 'chalk';
import 'fs';
import 'node:events';
import 'socket.io-client';
import 'zod';
import 'tweetnacl';
import 'child_process';
import 'util';
import 'fs/promises';
import 'crypto';
import 'path';
import 'url';
import 'os';
import 'node:child_process';
import 'node:module';
import 'node:util';
import 'expo-server-sdk';
import 'node:readline';
import 'ink';
import 'react';
import 'node:url';
import 'ps-list';
import 'cross-spawn';
import 'tmp';
import 'qrcode-terminal';
import 'open';
import 'fastify';
import 'fastify-type-provider-zod';
import 'http';
import '@modelcontextprotocol/sdk/client/index.js';
import '@modelcontextprotocol/sdk/client/streamableHttp.js';
import 'readline';
import '@modelcontextprotocol/sdk/server/mcp.js';
import 'node:http';
import '@modelcontextprotocol/sdk/server/streamableHttp.js';
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 {
constructor(session) {
super(session);
}
getLogPrefix() {
return LOG_PREFIX;
}
ask(callId, toolName, input) {
return this.createPendingRequest(callId, toolName, input);
}
}
function makeSocketPath(sessionId) {
const shortHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 12);
if (process.platform === "win32") {
return `\\\\.\\pipe\\consortium-pi-${shortHash}`;
}
const tmp = process.env.TMPDIR || os.tmpdir() || "/tmp";
return path.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.unlink(socketPath).catch(() => void 0);
}
await new Promise((resolve, reject) => {
const srv = net.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.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$1.promises.mkdir(opts.sessionDir, { recursive: true });
} catch (err) {
logger.debug(`[PiSessionScanner] Failed to mkdir ${opts.sessionDir}:`, err);
}
const drainFile = async (state, fromStart) => {
let stat;
try {
stat = await fs$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$1.promises.open(state.filePath, "r");
} catch (err) {
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) {
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$1.promises.stat(filePath);
state.offset = stat.size;
} catch {
state.offset = 0;
}
}
try {
state.fsWatcher = fs$1.watch(filePath, { persistent: false }, () => {
void drainFile(state, false);
});
state.fsWatcher.on("error", (err) => {
logger.debug(`[PiSessionScanner] File watcher error on ${filePath}:`, err);
});
} catch (err) {
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$1.promises.readdir(opts.sessionDir);
} catch (err) {
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.join(opts.sessionDir, name);
const fromStart = !!(opts.initialSessionId && opts.initialSessionId === sid);
await beginWatchingFile(filePath, sid, fromStart);
}
};
if (opts.initialSessionId) {
try {
const entries = await fs$1.promises.readdir(opts.sessionDir);
const match = entries.find((n) => extractSessionIdFromFilename(n) === opts.initialSessionId);
if (match) {
await beginWatchingFile(path.join(opts.sessionDir, match), opts.initialSessionId, true);
}
} catch (err) {
logger.debug(`[PiSessionScanner] resume pre-attach failed:`, err);
}
}
await scanDirOnce();
try {
dirWatcher = fs$1.watch(opts.sessionDir, { persistent: false }, (_event, _filename) => {
void scanDirOnce();
});
dirWatcher.on("error", (err) => {
logger.debug(`[PiSessionScanner] dir watcher error:`, err);
});
} catch (err) {
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":
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":
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: 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 : randomUUID(),
name: typeof item.name === "string" ? item.name : "tool",
input: item.arguments,
id: randomUUID()
});
sawContent = true;
}
}
if (msg.errorMessage) {
session.sendAgentMessage("pi", {
type: "message",
message: `Error: ${msg.errorMessage}`,
id: randomUUID()
});
session.sendAgentMessage("pi", {
type: "turn_aborted",
id: 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 : randomUUID();
const output = item.output ?? item.content ?? item.text;
const isError = Boolean(item.isError);
session.sendAgentMessage("pi", {
type: "tool-result",
callId,
output,
isError,
id: randomUUID()
});
}
return;
}
logger.debug(`[Pi] unhandled message role: ${role}`);
return;
}
default:
logger.debug(`[Pi] unhandled entry type: ${entry.type}`);
return;
}
} catch (err) {
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 = getProvider("pi", providerId);
if (!provider) {
logger.debug(`[Pi] Unknown providerId='${providerId}' \u2014 leaving env untouched`);
return env;
}
if (provider.isConsortiumProxy) {
const proxyBase = process.env.CONSORTIUM_PROXY_URL ?? `${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 getProviderKeyViaDaemon(providerId);
if (stashed) {
env[provider.keyEnvVar] = stashed;
return env;
}
} catch (err) {
logger.debug(`[Pi] getProviderKeyViaDaemon(${providerId}) threw:`, err);
}
logger.debug(`[Pi] No key available for provider='${providerId}' (env and daemon both empty)`);
return env;
}
async function runPi(opts) {
const sessionTag = randomUUID();
connectionState.setBackend("Pi");
const remoteOnly = opts.startingMode === "remote";
const versionResult = await checkPiVersion();
if (!versionResult.ok) {
console.error(
`Pi CLI not available: ${versionResult.message}
Install Pi (>= ${MIN_PI_VERSION}) via:
npm install -g @earendil-works/pi-coding-agent`
);
process.exit(1);
}
let permissionExtensionInstalled = true;
try {
const extResult = await ensurePiExtensionInstalled();
if (extResult.skipped) {
logger.debug(`[Pi] Extension install skipped: ${extResult.skipped}`);
} else {
logger.debug(`[Pi] Extension installed at ${extResult.targetDir}`);
}
} catch (err) {
permissionExtensionInstalled = false;
logger.debug("[Pi] Extension install failed (continuing without permission gating):", err);
}
const api = await ApiClient.create(opts.credentials);
const settings = await 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: initialMachineMetadata });
const fallback = getInitialPiProviderAndModel();
const piSupport = 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({
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 notifyDaemonSessionStarted(response.id, metadata);
if (result.error) logger.debug("[Pi] notifyDaemonSessionStarted error:", result.error);
} catch (err) {
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
});
await permissionBridge.start();
} catch (err) {
logger.debug("[Pi] Failed to start permission bridge:", err);
permissionBridge = null;
}
}
const sessionDir = join(getPiAgentDir(), "sessions");
try {
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: randomUUID() } : { type: "task_complete", id: randomUUID() }
);
} catch (err) {
logger.debug("[Pi] sendAgentMessage(task_*) failed:", err);
}
};
const scanner = await createPiSessionScanner({
sessionDir,
initialSessionId: piSessionId,
onSessionFound: (sid) => {
piSessionId = sid;
session.updateMetadata((m) => ({ ...m, agentSessionId: sid }));
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) {
logger.debug("[Pi] resolvePiProviderEnv threw at spawn:", err);
}
const cols = process.stdout.columns ?? 100;
const rows = process.stdout.rows ?? 30;
logger.debug(`[Pi] Spawning ${PI_BINARY} ${args.join(" ")}`);
let term;
try {
term = spawnPtyWithDiagnostics(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) => 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) {
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: randomUUID()
});
session.sendAgentMessage("pi", {
type: "task_complete",
id: randomUUID()
});
} catch (err) {
logger.debug("[Pi] failed to surface provider-switch warning:", err);
}
} else if (requestedModel && requestedModel !== currentModelId) {
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) {
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: 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);
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) {
logger.debug("[Pi] error closing session:", err);
}
};
registerKillSessionHandler(session.rpcHandlerManager, async () => {
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) {
logger.debug("[Pi] thinking watchdog: clearing stale thinking state");
setThinking(false);
lastUserMessageAt = null;
}
}, 3e4);
await new Promise((resolve) => {
term.onExit(async ({ exitCode }) => {
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);
}
export { runPi, sanitizePtyInput };