0949c6f0d7
Use native browser and Tauri behavior instead of custom selector, dialog, and window-state implementations. Remove obsolete bridge, relay, asset, and dependency paths to keep the supported surface small. BREAKING CHANGE: The Noctalia relay no longer accepts --agent, and the Tauri host no longer exposes the legacy load_agent command.
1819 lines
57 KiB
JavaScript
1819 lines
57 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
unlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
|
|
import { createRecoverySupervisor } from "./recovery-supervisor.js";
|
|
import { createWorkspaceStore } from "./workspace-store.js";
|
|
|
|
const SESSION_DIRECTORY_MODE = 0o700;
|
|
const SESSION_REFERENCE_FILE = "bridge-agent.json";
|
|
const DEFAULT_EVENT_LIMIT = 1_000;
|
|
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000;
|
|
const RESTORE_CONCURRENCY = 2;
|
|
const RESOURCE_WARNING_COUNT = 6;
|
|
const INITIAL_LAUNCH_GET_STATE_TIMEOUT_MS = 90_000;
|
|
|
|
export class UnknownAgentError extends Error {
|
|
constructor(agentId) {
|
|
super(`unknown agent: ${agentId}`);
|
|
this.name = "UnknownAgentError";
|
|
}
|
|
}
|
|
|
|
export class UnknownRuntimeError extends Error {
|
|
constructor(runtimeId) {
|
|
super(`unknown session runtime: ${runtimeId}`);
|
|
this.name = "UnknownRuntimeError";
|
|
}
|
|
}
|
|
|
|
function sessionDirectoryFor(sessionRoot, worktreePath) {
|
|
const identity = createHash("sha256")
|
|
.update(worktreePath)
|
|
.digest("hex")
|
|
.slice(0, 24);
|
|
return path.join(sessionRoot, `agent-${identity}`);
|
|
}
|
|
|
|
async function readSessionReference(sessionDir) {
|
|
try {
|
|
const value = JSON.parse(
|
|
await readFile(path.join(sessionDir, SESSION_REFERENCE_FILE), "utf8"),
|
|
);
|
|
return {
|
|
sessionPath:
|
|
typeof value?.sessionPath === "string" && path.isAbsolute(value.sessionPath)
|
|
? value.sessionPath
|
|
: undefined,
|
|
worktreePath:
|
|
typeof value?.worktreePath === "string" &&
|
|
path.isAbsolute(value.worktreePath)
|
|
? value.worktreePath
|
|
: undefined,
|
|
};
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT" || error instanceof SyntaxError) return {};
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function persistSessionReference(sessionDir, sessionPath, worktreePath) {
|
|
await writeFile(
|
|
path.join(sessionDir, SESSION_REFERENCE_FILE),
|
|
`${JSON.stringify({ sessionPath, worktreePath })}\n`,
|
|
{ encoding: "utf8", mode: 0o600 },
|
|
);
|
|
}
|
|
|
|
export async function promoteQuickSessionDirectory(
|
|
quickDir,
|
|
sessionDir,
|
|
{ renameFile = rename, makeSymlink = symlink } = {},
|
|
) {
|
|
const stagingDir = `${quickDir}.promoting-${randomUUID()}`;
|
|
const moved = [];
|
|
let quickStaged = false;
|
|
let quickLinked = false;
|
|
|
|
async function rollback() {
|
|
if (quickLinked) await unlink(quickDir).catch(() => {});
|
|
for (const entry of moved.reverse())
|
|
await renameFile(path.join(sessionDir, entry), path.join(stagingDir, entry));
|
|
if (quickStaged) await renameFile(stagingDir, quickDir);
|
|
}
|
|
|
|
try {
|
|
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
|
|
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
|
await renameFile(quickDir, stagingDir);
|
|
quickStaged = true;
|
|
await makeSymlink(sessionDir, quickDir, "dir");
|
|
quickLinked = true;
|
|
for (const entry of await readdir(stagingDir)) {
|
|
const destination = path.join(sessionDir, entry);
|
|
try {
|
|
await stat(destination);
|
|
throw new Error("quick session conflicts with an existing session file");
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
await renameFile(path.join(stagingDir, entry), destination);
|
|
moved.push(entry);
|
|
}
|
|
} catch (error) {
|
|
try {
|
|
await rollback();
|
|
} catch (rollbackError) {
|
|
throw new AggregateError(
|
|
[error, rollbackError],
|
|
"could not promote quick session directory; staging retained",
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
async rollback() {
|
|
try {
|
|
await rollback();
|
|
} catch (error) {
|
|
throw new Error(
|
|
`could not roll back quick session promotion; staging retained: ${error.message}`,
|
|
);
|
|
}
|
|
},
|
|
async finalize() {
|
|
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
|
},
|
|
};
|
|
}
|
|
|
|
function sessionPreview(content) {
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
.map((part) => part.text)
|
|
.join(" ");
|
|
}
|
|
|
|
async function readSessionSummary(sessionPath, currentPath) {
|
|
const [content, metadata] = await Promise.all([
|
|
readFile(sessionPath, "utf8"),
|
|
stat(sessionPath),
|
|
]);
|
|
let header;
|
|
let name;
|
|
let firstMessage;
|
|
let messageCount = 0;
|
|
for (const line of content.split("\n")) {
|
|
if (!line) continue;
|
|
let entry;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (entry.type === "session") header = entry;
|
|
if (entry.type === "session_info" && typeof entry.name === "string")
|
|
name = entry.name;
|
|
if (entry.type === "message") {
|
|
messageCount += 1;
|
|
if (!firstMessage && entry.message?.role === "user")
|
|
firstMessage = sessionPreview(entry.message.content);
|
|
}
|
|
}
|
|
if (!header || typeof header.id !== "string" || typeof header.cwd !== "string")
|
|
return undefined;
|
|
return {
|
|
path: sessionPath,
|
|
id: header.id,
|
|
cwd: header.cwd,
|
|
name,
|
|
parentSessionPath: header.parentSession,
|
|
created: header.timestamp,
|
|
modified: metadata.mtime.toISOString(),
|
|
messageCount,
|
|
firstMessage,
|
|
isCurrent: sessionPath === currentPath,
|
|
};
|
|
}
|
|
|
|
async function listSessionSummaries(sessionDir, currentPath) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(sessionDir, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return [];
|
|
throw error;
|
|
}
|
|
const summaries = await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
.map(async (entry) => {
|
|
try {
|
|
return await readSessionSummary(
|
|
path.join(sessionDir, entry.name),
|
|
currentPath,
|
|
);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}),
|
|
);
|
|
return summaries
|
|
.filter(Boolean)
|
|
.sort((left, right) => right.modified.localeCompare(left.modified));
|
|
}
|
|
|
|
function runtimeLabel(runtime) {
|
|
return runtime.sessionName || runtime.firstMessage || "New session";
|
|
}
|
|
|
|
function publicAgent(runtime) {
|
|
return {
|
|
id: runtime.agentId,
|
|
worktreePath: runtime.worktreePath,
|
|
sessionDir: runtime.sessionDir,
|
|
state: runtime.state,
|
|
runtimeId: runtime.runtimeId,
|
|
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
|
label: runtimeLabel(runtime),
|
|
attention: runtime.attention,
|
|
queueCount: runtime.queueCount,
|
|
lastActivity: runtime.lastActivity,
|
|
};
|
|
}
|
|
|
|
function publicRuntime(runtime) {
|
|
return {
|
|
runtimeId: runtime.runtimeId,
|
|
worktreePath: runtime.worktreePath,
|
|
state: runtime.state,
|
|
label: runtimeLabel(runtime),
|
|
attention: runtime.attention,
|
|
queueCount: runtime.queueCount,
|
|
activeTool: runtime.activeTool,
|
|
lastActivity: runtime.lastActivity,
|
|
openedAt: runtime.openedAt,
|
|
...(runtime.agentId ? { agentId: runtime.agentId } : {}),
|
|
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
|
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
|
...(runtime.error ? { error: runtime.error } : {}),
|
|
};
|
|
}
|
|
|
|
function routeCommand(adapter, operation, payload = {}, runtimeState) {
|
|
switch (operation) {
|
|
case "prompt":
|
|
case "steer":
|
|
case "follow_up":
|
|
return adapter.send({ type: operation, message: payload.message });
|
|
case "submit_prompt":
|
|
return adapter.send({
|
|
type: runtimeState === "streaming" ? "follow_up" : "prompt",
|
|
message: payload.message,
|
|
});
|
|
case "abort":
|
|
case "get_state":
|
|
case "get_session_stats":
|
|
case "get_available_models":
|
|
case "get_commands":
|
|
return adapter.send({ type: operation });
|
|
case "get_transcript":
|
|
return adapter.send({ type: "get_messages" });
|
|
case "compact":
|
|
return adapter.send({
|
|
type: "compact",
|
|
...(payload.customInstructions
|
|
? { customInstructions: payload.customInstructions }
|
|
: {}),
|
|
});
|
|
case "set_model":
|
|
return adapter.send({
|
|
type: "set_model",
|
|
provider: payload.provider,
|
|
modelId: payload.modelId,
|
|
});
|
|
case "set_thinking_level":
|
|
return adapter.send({ type: "set_thinking_level", level: payload.level });
|
|
case "set_session_name":
|
|
return adapter.send({ type: "set_session_name", name: payload.name });
|
|
case "pi_rpc_command":
|
|
return adapter.send({ type: payload.command, ...payload.input });
|
|
case "extension_response":
|
|
adapter.respondToExtension(payload.requestId, payload.response);
|
|
return Promise.resolve({
|
|
type: "response",
|
|
command: "extension_ui_response",
|
|
success: true,
|
|
});
|
|
default:
|
|
return Promise.reject(
|
|
new Error(`unsupported agent operation: ${operation}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function createAgentRegistry({
|
|
homeWorktree,
|
|
sessionRoot,
|
|
startAdapter = startPiRpcAdapter,
|
|
workspaceStoreFactory = createWorkspaceStore,
|
|
eventLimit = DEFAULT_EVENT_LIMIT,
|
|
maxRecoveryAttempts,
|
|
recoveryDelayForAttempt,
|
|
sleep,
|
|
closeTimeoutMs = DEFAULT_CLOSE_TIMEOUT_MS,
|
|
}) {
|
|
if (typeof homeWorktree !== "string" || !path.isAbsolute(homeWorktree))
|
|
throw new TypeError("homeWorktree must be an absolute path");
|
|
if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot))
|
|
throw new TypeError("sessionRoot must be an absolute path");
|
|
if (typeof startAdapter !== "function")
|
|
throw new TypeError("startAdapter must be a function");
|
|
if (typeof workspaceStoreFactory !== "function")
|
|
throw new TypeError("workspaceStoreFactory must be a function");
|
|
|
|
const bridgeInstanceId = randomUUID();
|
|
const workspaceStore = workspaceStoreFactory(sessionRoot);
|
|
const runtimesByRuntimeId = new Map();
|
|
const runtimesByAgentId = new Map();
|
|
const runtimesByWorktreePath = new Map();
|
|
const runtimesBySessionPath = new Map();
|
|
const managedWorktrees = new Set();
|
|
const pendingSessionLeases = new Map();
|
|
const creatingByKey = new Map();
|
|
const pendingOpenOperations = new Set();
|
|
const workspaceListeners = new Set();
|
|
const workspaceEvents = [];
|
|
let workspaceSequence = 0;
|
|
let manifestIssue;
|
|
let manifestWriteQueue = Promise.resolve();
|
|
let canonicalHomeWorktree;
|
|
let lastSuccessfulModelCatalog;
|
|
let stopping = false;
|
|
|
|
function publishWorkspace(type, runtime, data = {}) {
|
|
if (runtime?.ephemeral) return undefined;
|
|
const event = {
|
|
version: "v1",
|
|
bridgeInstanceId,
|
|
seq: ++workspaceSequence,
|
|
type,
|
|
...(runtime?.agentId ? { agentId: runtime.agentId } : {}),
|
|
data: {
|
|
...(runtime
|
|
? {
|
|
runtimeId: runtime.runtimeId,
|
|
worktreePath: runtime.worktreePath,
|
|
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
|
}
|
|
: {}),
|
|
...data,
|
|
},
|
|
};
|
|
workspaceEvents.push(event);
|
|
if (workspaceEvents.length > eventLimit) workspaceEvents.shift();
|
|
for (const listener of workspaceListeners) listener(event);
|
|
return event;
|
|
}
|
|
|
|
function manifest() {
|
|
return {
|
|
version: 2,
|
|
migrated: true,
|
|
directories: [...managedWorktrees].sort(),
|
|
runtimes: [...runtimesByRuntimeId.values()]
|
|
.filter((runtime) => !runtime.ephemeral)
|
|
.map((runtime) => ({
|
|
runtimeId: runtime.runtimeId,
|
|
worktreePath: runtime.worktreePath,
|
|
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
|
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
|
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
|
|
openedAt: runtime.openedAt,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function persistWorkspace() {
|
|
const operation = manifestWriteQueue
|
|
.catch(() => {})
|
|
.then(async () => {
|
|
await workspaceStore.save(manifest());
|
|
manifestIssue = undefined;
|
|
});
|
|
manifestWriteQueue = operation.catch(() => {});
|
|
return operation;
|
|
}
|
|
|
|
function addWorktreeRuntime(runtime) {
|
|
if (!runtime.ephemeral) managedWorktrees.add(runtime.worktreePath);
|
|
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
|
|
if (!runtimes) {
|
|
runtimes = new Set();
|
|
runtimesByWorktreePath.set(runtime.worktreePath, runtimes);
|
|
}
|
|
runtimes.add(runtime);
|
|
}
|
|
|
|
function indexRuntime(runtime, { leaseSession = true } = {}) {
|
|
if (runtimesByRuntimeId.has(runtime.runtimeId))
|
|
throw new Error(`runtime already exists: ${runtime.runtimeId}`);
|
|
if (
|
|
leaseSession &&
|
|
runtime.sessionPath &&
|
|
runtimesBySessionPath.has(runtime.sessionPath)
|
|
)
|
|
throw new Error("session is already open in another runtime");
|
|
runtimesByRuntimeId.set(runtime.runtimeId, runtime);
|
|
if (runtime.agentId) runtimesByAgentId.set(runtime.agentId, runtime);
|
|
if (leaseSession && runtime.sessionPath)
|
|
runtimesBySessionPath.set(runtime.sessionPath, runtime);
|
|
addWorktreeRuntime(runtime);
|
|
return runtime;
|
|
}
|
|
|
|
function unindexRuntime(runtime, { releaseSessionLease = true } = {}) {
|
|
runtimesByRuntimeId.delete(runtime.runtimeId);
|
|
if (runtime.agentId) runtimesByAgentId.delete(runtime.agentId);
|
|
if (
|
|
releaseSessionLease &&
|
|
runtime.sessionPath &&
|
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
|
const worktreeRuntimes = runtimesByWorktreePath.get(runtime.worktreePath);
|
|
worktreeRuntimes?.delete(runtime);
|
|
if (worktreeRuntimes?.size === 0)
|
|
runtimesByWorktreePath.delete(runtime.worktreePath);
|
|
}
|
|
|
|
function getRuntime(runtimeId) {
|
|
const runtime = runtimesByRuntimeId.get(runtimeId);
|
|
if (!runtime) throw new UnknownRuntimeError(runtimeId);
|
|
return runtime;
|
|
}
|
|
|
|
function getAgent(agentId) {
|
|
const runtime = runtimesByAgentId.get(agentId);
|
|
if (!runtime) throw new UnknownAgentError(agentId);
|
|
return runtime;
|
|
}
|
|
|
|
function enqueue(runtime, operation) {
|
|
if (stopping) return Promise.reject(new Error("agent registry is stopping"));
|
|
if (
|
|
runtime.state === "closing" ||
|
|
runtime.state === "stopped" ||
|
|
runtime.stopped
|
|
)
|
|
return Promise.reject(new Error("session runtime is closing"));
|
|
// Work admitted before a close request must drain; the close operation
|
|
// marks the runtime synchronously so later work is rejected here.
|
|
const result = runtime.operationQueue.then(operation);
|
|
runtime.operationQueue = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
function reserveSessionPath(sessionPath, runtime) {
|
|
const leased = runtimesBySessionPath.get(sessionPath);
|
|
const pending = pendingSessionLeases.get(sessionPath);
|
|
if ((leased && leased !== runtime) || (pending && pending !== runtime))
|
|
throw new Error("session is already open in another runtime");
|
|
pendingSessionLeases.set(sessionPath, runtime);
|
|
}
|
|
|
|
function releaseSessionReservation(sessionPath, runtime) {
|
|
if (pendingSessionLeases.get(sessionPath) === runtime)
|
|
pendingSessionLeases.delete(sessionPath);
|
|
}
|
|
|
|
async function ownedSessionPath(
|
|
sessionDir,
|
|
sessionPath,
|
|
{ allowMissing = false } = {},
|
|
) {
|
|
const resolvedSessionDir = await realpath(sessionDir);
|
|
let resolvedSessionPath;
|
|
try {
|
|
resolvedSessionPath = await realpath(sessionPath);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT" || !allowMissing) throw error;
|
|
const resolvedParent = await realpath(path.dirname(sessionPath));
|
|
resolvedSessionPath = path.join(resolvedParent, path.basename(sessionPath));
|
|
}
|
|
const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
|
|
if (
|
|
relative === "" ||
|
|
relative.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relative) ||
|
|
!resolvedSessionPath.endsWith(".jsonl")
|
|
)
|
|
throw new TypeError("sessionPath must belong to the selected directory");
|
|
return resolvedSessionPath;
|
|
}
|
|
|
|
async function projectQuickSessionPath(quickDir, sessionDir, sessionPath) {
|
|
const activePath = await ownedSessionPath(quickDir, sessionPath);
|
|
const relativePath = path.relative(await realpath(quickDir), activePath);
|
|
return ownedSessionPath(sessionDir, path.join(sessionDir, relativePath), {
|
|
allowMissing: true,
|
|
});
|
|
}
|
|
|
|
async function updateRuntimeIdentity(
|
|
runtime,
|
|
stateResponse,
|
|
{
|
|
expectedSessionPath,
|
|
adapter = runtime.adapter,
|
|
persist = true,
|
|
indexSession = true,
|
|
} = {},
|
|
) {
|
|
if (
|
|
runtime.stopped ||
|
|
runtime.state === "closing" ||
|
|
runtime.state === "stopped" ||
|
|
runtime.adapter !== adapter ||
|
|
runtimesByRuntimeId.get(runtime.runtimeId) !== runtime
|
|
)
|
|
throw new Error("session runtime is closing");
|
|
const state = stateResponse?.data ?? {};
|
|
const nextPath = state.sessionFile;
|
|
let resolved;
|
|
if (typeof nextPath === "string" && path.isAbsolute(nextPath)) {
|
|
try {
|
|
const candidate = await ownedSessionPath(runtime.sessionDir, nextPath, {
|
|
allowMissing: true,
|
|
});
|
|
await stat(candidate);
|
|
resolved = candidate;
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT")
|
|
throw new Error("Pi reported a session outside its managed directory");
|
|
}
|
|
}
|
|
if (expectedSessionPath && resolved !== expectedSessionPath)
|
|
throw new Error("Pi did not resume the requested session");
|
|
if (resolved) {
|
|
const leased = runtimesBySessionPath.get(resolved);
|
|
const pending = pendingSessionLeases.get(resolved);
|
|
if ((leased && leased !== runtime) || (pending && pending !== runtime))
|
|
throw new Error("session is already open in another runtime");
|
|
}
|
|
if (
|
|
runtime.stopped ||
|
|
runtime.state === "closing" ||
|
|
runtime.adapter !== adapter ||
|
|
runtimesByRuntimeId.get(runtime.runtimeId) !== runtime
|
|
)
|
|
throw new Error("session runtime is closing");
|
|
if (typeof state.sessionName === "string")
|
|
runtime.sessionName = state.sessionName;
|
|
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
|
if (resolved) {
|
|
if (indexSession) {
|
|
if (
|
|
runtime.sessionPath &&
|
|
runtime.sessionPath !== resolved &&
|
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
|
runtimesBySessionPath.set(resolved, runtime);
|
|
releaseSessionReservation(resolved, runtime);
|
|
}
|
|
runtime.sessionPath = resolved;
|
|
if (runtime.legacyDefault)
|
|
await persistSessionReference(
|
|
runtime.sessionDir,
|
|
resolved,
|
|
runtime.worktreePath,
|
|
);
|
|
}
|
|
if (persist) await persistWorkspace();
|
|
}
|
|
|
|
async function restoreSessionReference(sessionDir, previous) {
|
|
const referencePath = path.join(sessionDir, SESSION_REFERENCE_FILE);
|
|
if (previous === undefined) {
|
|
await rm(referencePath, { force: true });
|
|
return;
|
|
}
|
|
await writeFile(referencePath, previous, { encoding: "utf8", mode: 0o600 });
|
|
}
|
|
|
|
function publishAgent(runtime, type, data) {
|
|
const event = {
|
|
version: "v1",
|
|
seq: runtime.nextSequence++,
|
|
type,
|
|
agentId: runtime.agentId,
|
|
data,
|
|
};
|
|
runtime.events.push(event);
|
|
if (runtime.events.length > eventLimit) runtime.events.shift();
|
|
for (const listener of runtime.listeners) listener(event);
|
|
return event;
|
|
}
|
|
|
|
function cacheEvent(runtime, type, data) {
|
|
runtime.lastActivity = new Date().toISOString();
|
|
const event = data?.event ?? {};
|
|
if (type === "agent_state" && typeof data?.state === "string") {
|
|
if (runtime.state === "closing" || runtime.state === "stopped") return;
|
|
runtime.state = data.state;
|
|
runtime.stateVersion += 1;
|
|
if (data.state === "idle") {
|
|
runtime.activeTool = undefined;
|
|
runtime.supervisor?.markHealthy();
|
|
}
|
|
}
|
|
if (type === "queue") {
|
|
const followUp = Array.isArray(event.followUp) ? event.followUp.length : 0;
|
|
const steering = Array.isArray(event.steering) ? event.steering.length : 0;
|
|
runtime.queueCount =
|
|
typeof event.pendingMessageCount === "number"
|
|
? event.pendingMessageCount
|
|
: followUp + steering;
|
|
}
|
|
if (type === "tool") {
|
|
if (String(event.type ?? "").endsWith("_end"))
|
|
runtime.activeTool = undefined;
|
|
else if (typeof event.toolName === "string")
|
|
runtime.activeTool = event.toolName;
|
|
}
|
|
if (type === "extension_ui_request") {
|
|
const method = event.method;
|
|
if (["select", "confirm", "input", "editor"].includes(method)) {
|
|
runtime.attention = true;
|
|
if (typeof event.id === "string") runtime.extensions.set(event.id, event);
|
|
}
|
|
}
|
|
const message = event.message;
|
|
if (
|
|
!runtime.firstMessage &&
|
|
message?.role === "user" &&
|
|
message.content !== undefined
|
|
)
|
|
runtime.firstMessage = sessionPreview(message.content);
|
|
}
|
|
|
|
function createRuntimeRecord({
|
|
runtimeId = randomUUID(),
|
|
worktreePath,
|
|
sessionDir,
|
|
sessionPath,
|
|
sessionId,
|
|
legacyDefault = false,
|
|
ephemeral = false,
|
|
openedAt = new Date().toISOString(),
|
|
}) {
|
|
return {
|
|
runtimeId,
|
|
agentId: `agent-${randomUUID()}`,
|
|
worktreePath,
|
|
sessionDir,
|
|
sessionPath,
|
|
sessionId,
|
|
legacyDefault,
|
|
ephemeral,
|
|
openedAt,
|
|
state: "starting",
|
|
stateVersion: 0,
|
|
errorAttention: false,
|
|
attention: false,
|
|
queueCount: 0,
|
|
activeTool: undefined,
|
|
lastActivity: openedAt,
|
|
events: [],
|
|
nextSequence: 1,
|
|
listeners: new Set(),
|
|
extensions: new Map(),
|
|
operationQueue: Promise.resolve(),
|
|
adapter: undefined,
|
|
supervisor: undefined,
|
|
stopped: false,
|
|
promoting: false,
|
|
promotionPromise: undefined,
|
|
recoveryPromise: undefined,
|
|
};
|
|
}
|
|
|
|
async function launch(runtime) {
|
|
let adapter;
|
|
const expectedSessionPath = runtime.sessionPath;
|
|
adapter = startAdapter({
|
|
cwd: runtime.worktreePath,
|
|
sessionDir: runtime.sessionDir,
|
|
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
|
onEvent: (event) => {
|
|
if (runtime.adapter !== adapter || runtime.stopped) return;
|
|
cacheEvent(runtime, event.type, event.data);
|
|
publishAgent(runtime, event.type, event.data);
|
|
publishWorkspace("runtime_event", runtime, {
|
|
eventType: event.type,
|
|
eventData: event.data,
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
if (event.type === "agent_state" && event.data?.state === "idle") {
|
|
void enqueue(runtime, async () => {
|
|
const state = await adapter.send({ type: "get_state" });
|
|
await updateRuntimeIdentity(runtime, state, { adapter });
|
|
}).catch((error) => {
|
|
if (runtime.stopped || runtime.state === "closing" || stopping) return;
|
|
runtime.error = {
|
|
code: "identity_refresh",
|
|
message: error.message,
|
|
};
|
|
runtime.errorAttention = true;
|
|
runtime.attention = true;
|
|
});
|
|
}
|
|
},
|
|
onError: (error, { terminal = true } = {}) => {
|
|
if (runtime.adapter !== adapter || runtime.stopped) return;
|
|
if (!terminal) {
|
|
const diagnostic = {
|
|
code: error.code ?? "unknown",
|
|
message: error.message,
|
|
};
|
|
publishAgent(runtime, "diagnostic", { error: diagnostic });
|
|
publishWorkspace("runtime_diagnostic", runtime, {
|
|
error: diagnostic,
|
|
});
|
|
return;
|
|
}
|
|
runtime.state = "error";
|
|
runtime.stateVersion += 1;
|
|
runtime.error = {
|
|
code: error.code ?? "unknown",
|
|
message: error.message,
|
|
};
|
|
runtime.errorAttention = true;
|
|
runtime.attention = true;
|
|
publishAgent(runtime, "agent_state", {
|
|
state: "error",
|
|
error: runtime.error,
|
|
});
|
|
publishWorkspace("runtime_event", runtime, {
|
|
eventType: "agent_state",
|
|
eventData: { state: "error", error: runtime.error },
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
if (["child_exited", "child_error"].includes(error.code)) {
|
|
// Dialog request IDs belong to the dead child and cannot be answered
|
|
// after recovery creates a replacement process.
|
|
runtime.extensions.clear();
|
|
runtime.recoveryPromise = runtime.supervisor
|
|
.handleUnexpectedExit()
|
|
.then((nextAdapter) => {
|
|
if (!runtime.stopped) {
|
|
runtime.state = nextAdapter ? "idle" : "failed";
|
|
runtime.stateVersion += 1;
|
|
if (nextAdapter) {
|
|
runtime.error = undefined;
|
|
runtime.errorAttention = false;
|
|
runtime.attention = runtime.extensions.size > 0;
|
|
}
|
|
publishAgent(runtime, "agent_state", { state: runtime.state });
|
|
publishWorkspace("runtime_recovered", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
}
|
|
return nextAdapter;
|
|
});
|
|
}
|
|
},
|
|
});
|
|
runtime.adapter = adapter;
|
|
try {
|
|
const state = await adapter.send(
|
|
{ type: "get_state" },
|
|
{ timeoutMs: INITIAL_LAUNCH_GET_STATE_TIMEOUT_MS },
|
|
);
|
|
runtime.state = state?.data?.isStreaming ? "streaming" : "idle";
|
|
runtime.stateVersion += 1;
|
|
await updateRuntimeIdentity(runtime, state, {
|
|
...(expectedSessionPath ? { expectedSessionPath } : {}),
|
|
adapter,
|
|
});
|
|
return adapter;
|
|
} catch (error) {
|
|
runtime.stopped = true;
|
|
await adapter.stop().catch(() => {});
|
|
runtime.stopped = false;
|
|
if (runtime.adapter === adapter) runtime.adapter = undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function openRuntimeInternal(
|
|
{
|
|
runtimeId = randomUUID(),
|
|
worktreePath,
|
|
sessionPath,
|
|
sessionId,
|
|
legacyDefault = false,
|
|
ephemeral = false,
|
|
openedAt,
|
|
},
|
|
{ restore = false } = {},
|
|
) {
|
|
if (stopping) throw new Error("agent registry is stopping");
|
|
const canonicalPath = await realpath(worktreePath);
|
|
const sessionDir = ephemeral
|
|
? path.join(sessionRoot, "quick", runtimeId)
|
|
: sessionDirectoryFor(sessionRoot, canonicalPath);
|
|
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
|
|
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
|
let ownedPath;
|
|
if (sessionPath) {
|
|
ownedPath = await ownedSessionPath(sessionDir, sessionPath);
|
|
const existing = runtimesBySessionPath.get(ownedPath);
|
|
if (existing) {
|
|
if (existing.state === "closing" || existing.stopped)
|
|
throw new Error("session runtime is closing");
|
|
if (restore && existing.runtimeId !== runtimeId)
|
|
throw new Error("session is already open in another runtime");
|
|
return publicRuntime(existing);
|
|
}
|
|
}
|
|
const key = ownedPath ?? `${canonicalPath}:new:${runtimeId}`;
|
|
if (creatingByKey.has(key)) {
|
|
if (restore)
|
|
throw new Error("session is already opening in another runtime");
|
|
return creatingByKey.get(key);
|
|
}
|
|
if (stopping) throw new Error("agent registry is stopping");
|
|
if (ownedPath) reserveSessionPath(ownedPath, { runtimeId });
|
|
const creation = (async () => {
|
|
if (stopping) throw new Error("agent registry is stopping");
|
|
const runtime = createRuntimeRecord({
|
|
runtimeId,
|
|
worktreePath: canonicalPath,
|
|
sessionDir,
|
|
sessionPath: ownedPath,
|
|
sessionId,
|
|
legacyDefault,
|
|
ephemeral,
|
|
openedAt,
|
|
});
|
|
if (stopping) throw new Error("agent registry is stopping");
|
|
indexRuntime(runtime);
|
|
if (ownedPath) pendingSessionLeases.set(ownedPath, runtime);
|
|
runtime.supervisor = createRecoverySupervisor({
|
|
start: () => launch(runtime),
|
|
...(maxRecoveryAttempts ? { maxAttempts: maxRecoveryAttempts } : {}),
|
|
...(recoveryDelayForAttempt
|
|
? { delayForAttempt: recoveryDelayForAttempt }
|
|
: {}),
|
|
...(sleep ? { sleep } : {}),
|
|
onState: (recovery) => {
|
|
runtime.state =
|
|
recovery.state === "recovering" ? "recovering" : runtime.state;
|
|
publishAgent(runtime, "recovery", recovery);
|
|
publishWorkspace("runtime_recovery", runtime, { recovery });
|
|
},
|
|
});
|
|
try {
|
|
await launch(runtime);
|
|
if (!runtime.ephemeral) await persistWorkspace();
|
|
publishWorkspace("runtime_opened", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
return publicRuntime(runtime);
|
|
} catch (error) {
|
|
if (ownedPath) releaseSessionReservation(ownedPath, runtime);
|
|
await runtime.adapter?.stop().catch(() => {});
|
|
if (!restore) {
|
|
unindexRuntime(runtime);
|
|
throw error;
|
|
}
|
|
if (
|
|
runtime.sessionPath &&
|
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
|
// Preserve the manifest's requested path for dormant intent. The
|
|
// canonical lease is only meaningful while a child is live.
|
|
if (sessionPath) runtime.sessionPath = sessionPath;
|
|
if (runtime.agentId) runtimesByAgentId.delete(runtime.agentId);
|
|
runtime.agentId = undefined;
|
|
runtime.adapter = undefined;
|
|
runtime.state = "failed";
|
|
runtime.errorAttention = true;
|
|
runtime.attention = true;
|
|
runtime.error = {
|
|
code: "restore_failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
await persistWorkspace();
|
|
publishWorkspace("runtime_restore_failed", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
return publicRuntime(runtime);
|
|
}
|
|
})();
|
|
creatingByKey.set(key, creation);
|
|
try {
|
|
return await creation;
|
|
} finally {
|
|
creatingByKey.delete(key);
|
|
if (ownedPath) {
|
|
const pending = pendingSessionLeases.get(ownedPath);
|
|
if (pending?.runtimeId === runtimeId)
|
|
pendingSessionLeases.delete(ownedPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function openRuntime(record, options) {
|
|
if (stopping) return Promise.reject(new Error("agent registry is stopping"));
|
|
const operation = openRuntimeInternal(record, options);
|
|
pendingOpenOperations.add(operation);
|
|
void operation
|
|
.finally(() => pendingOpenOperations.delete(operation))
|
|
.catch(() => {});
|
|
return operation;
|
|
}
|
|
|
|
function retainFailedRestore(record, error) {
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, record.worktreePath);
|
|
const dormant = createRuntimeRecord({ ...record, sessionDir });
|
|
dormant.agentId = undefined;
|
|
dormant.state = "failed";
|
|
dormant.errorAttention = true;
|
|
dormant.attention = true;
|
|
dormant.error = {
|
|
code: "restore_failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
if (!runtimesByRuntimeId.has(dormant.runtimeId))
|
|
indexRuntime(dormant, { leaseSession: false });
|
|
publishWorkspace("runtime_restore_failed", dormant, {
|
|
runtime: publicRuntime(dormant),
|
|
});
|
|
}
|
|
|
|
async function restoreRecords(records) {
|
|
// Canonicalize saved-session identities before launching any children. This
|
|
// makes alias collisions deterministic and prevents a losing dormant record
|
|
// from racing the winner's lease acquisition or manifest persistence.
|
|
const prepared = [];
|
|
for (const record of records) {
|
|
try {
|
|
let canonicalSession;
|
|
if (record.sessionPath) {
|
|
const canonicalWorktree = await realpath(record.worktreePath);
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalWorktree);
|
|
canonicalSession = await ownedSessionPath(sessionDir, record.sessionPath);
|
|
}
|
|
prepared.push({ record, canonicalSession });
|
|
} catch (error) {
|
|
retainFailedRestore(record, error);
|
|
}
|
|
}
|
|
const preferredBySession = new Map();
|
|
for (const entry of prepared) {
|
|
if (!entry.canonicalSession) continue;
|
|
const current = preferredBySession.get(entry.canonicalSession);
|
|
if (
|
|
!current ||
|
|
(entry.record.sessionPath === entry.canonicalSession &&
|
|
current.record.sessionPath !== current.canonicalSession)
|
|
)
|
|
preferredBySession.set(entry.canonicalSession, entry);
|
|
}
|
|
const restorable = [];
|
|
for (const entry of prepared) {
|
|
if (
|
|
entry.canonicalSession &&
|
|
preferredBySession.get(entry.canonicalSession) !== entry
|
|
) {
|
|
retainFailedRestore(
|
|
entry.record,
|
|
new Error("session is already open in another runtime"),
|
|
);
|
|
} else restorable.push(entry.record);
|
|
}
|
|
|
|
let next = 0;
|
|
const workers = [];
|
|
const workerCount = Math.min(RESTORE_CONCURRENCY, restorable.length);
|
|
for (let workerIndex = 0; workerIndex < workerCount; workerIndex += 1) {
|
|
workers.push(
|
|
(async () => {
|
|
while (next < restorable.length) {
|
|
const record = restorable[next++];
|
|
try {
|
|
await openRuntime(record, { restore: true });
|
|
} catch (error) {
|
|
retainFailedRestore(record, error);
|
|
}
|
|
}
|
|
})(),
|
|
);
|
|
}
|
|
await Promise.all(workers);
|
|
await persistWorkspace();
|
|
}
|
|
|
|
async function setLegacyDefault(runtime) {
|
|
for (const candidate of runtimesByWorktreePath.get(runtime.worktreePath) ??
|
|
[])
|
|
candidate.legacyDefault = candidate === runtime;
|
|
runtime.legacyDefault = true;
|
|
await persistSessionReference(
|
|
runtime.sessionDir,
|
|
runtime.sessionPath,
|
|
runtime.worktreePath,
|
|
);
|
|
}
|
|
|
|
function releaseClosedSessionLease(runtime) {
|
|
if (
|
|
runtime.sessionPath &&
|
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
|
}
|
|
|
|
async function performClose(runtime, wasWorkingAtRequest) {
|
|
const wasWorking =
|
|
wasWorkingAtRequest ||
|
|
runtime.state === "streaming" ||
|
|
runtime.queueCount > 0;
|
|
if (runtime.legacyDefault) {
|
|
const replacementDefault = [
|
|
...(runtimesByWorktreePath.get(runtime.worktreePath) ?? []),
|
|
].find(
|
|
(candidate) =>
|
|
candidate !== runtime &&
|
|
candidate.agentId &&
|
|
!candidate.stopped &&
|
|
candidate.state !== "closing" &&
|
|
candidate.state !== "stopped",
|
|
);
|
|
runtime.legacyDefault = false;
|
|
if (replacementDefault) await setLegacyDefault(replacementDefault);
|
|
else
|
|
await persistSessionReference(
|
|
runtime.sessionDir,
|
|
undefined,
|
|
runtime.worktreePath,
|
|
);
|
|
}
|
|
// Remove desired-open/UI intent now, but retain the session lease until
|
|
// the old child can no longer write to its JSONL file.
|
|
unindexRuntime(runtime, { releaseSessionLease: false });
|
|
if (!runtime.ephemeral) await persistWorkspace();
|
|
runtime.stopped = true;
|
|
runtime.supervisor?.stop();
|
|
let stopConfirmed = true;
|
|
if (runtime.adapter) {
|
|
if (wasWorking)
|
|
await runtime.adapter.send({ type: "abort" }).catch(() => {});
|
|
const stopPromise = Promise.resolve().then(() => runtime.adapter.stop());
|
|
let timer;
|
|
const timeout = new Promise((resolve) => {
|
|
timer = setTimeout(() => resolve("timeout"), closeTimeoutMs);
|
|
});
|
|
const stopped = await Promise.race([
|
|
stopPromise.then(() => "stopped"),
|
|
timeout,
|
|
]);
|
|
clearTimeout(timer);
|
|
stopConfirmed = stopped === "stopped";
|
|
if (!stopConfirmed) {
|
|
runtime.adapter.child?.kill?.("SIGKILL");
|
|
// A kill request is not exit confirmation. Keep the lease until the
|
|
// adapter observes child exit; an unresolved adapter keeps it reserved.
|
|
void stopPromise
|
|
.then(() => releaseClosedSessionLease(runtime))
|
|
.catch(() => {});
|
|
}
|
|
}
|
|
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
|
if (runtime.ephemeral && stopConfirmed)
|
|
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(
|
|
() => {},
|
|
);
|
|
runtime.state = "stopped";
|
|
publishWorkspace("runtime_closed", runtime, {
|
|
runtimeId: runtime.runtimeId,
|
|
worktreePath: runtime.worktreePath,
|
|
});
|
|
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
|
|
}
|
|
|
|
function closeRuntime(runtimeId) {
|
|
const runtime = getRuntime(runtimeId);
|
|
if (runtime.promoting)
|
|
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
|
|
if (runtime.state === "closing" || runtime.state === "stopped")
|
|
return Promise.reject(new Error("session runtime is closing"));
|
|
const wasWorkingAtRequest =
|
|
runtime.state === "streaming" || runtime.queueCount > 0;
|
|
// Mark synchronously so concurrent default promotion cannot choose a
|
|
// runtime whose close has already been requested. Previously accepted work
|
|
// still drains before performClose.
|
|
runtime.state = "closing";
|
|
publishWorkspace("runtime_closing", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
const result = runtime.operationQueue.then(() =>
|
|
performClose(runtime, wasWorkingAtRequest),
|
|
);
|
|
runtime.operationQueue = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
function promoteQuickRuntime(runtimeId) {
|
|
const runtime = getRuntime(runtimeId);
|
|
if (!runtime.ephemeral) return Promise.resolve(publicRuntime(runtime));
|
|
if (runtime.state === "closing" || runtime.state === "stopped")
|
|
return Promise.reject(new Error("session runtime is closing"));
|
|
if (runtime.promotionPromise) return runtime.promotionPromise;
|
|
|
|
runtime.promoting = true;
|
|
const promotion = runtime.operationQueue.then(async () => {
|
|
if (!runtime.ephemeral) return publicRuntime(runtime);
|
|
if (runtime.state === "closing" || runtime.state === "stopped")
|
|
throw new Error("session runtime is closing");
|
|
const adapter = runtime.adapter;
|
|
if (!adapter) throw new Error("Pi is not currently running");
|
|
const quickDir = runtime.sessionDir;
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, runtime.worktreePath);
|
|
const previous = {
|
|
sessionDir: runtime.sessionDir,
|
|
sessionPath: runtime.sessionPath,
|
|
sessionId: runtime.sessionId,
|
|
sessionName: runtime.sessionName,
|
|
sessionLease: runtime.sessionPath
|
|
? runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
: false,
|
|
managed: managedWorktrees.has(runtime.worktreePath),
|
|
};
|
|
let directoryPromotion;
|
|
let previousReference;
|
|
let reservedSessionPath;
|
|
let manifestStateChanged = false;
|
|
try {
|
|
await mkdir(sessionDir, {
|
|
recursive: true,
|
|
mode: SESSION_DIRECTORY_MODE,
|
|
});
|
|
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
|
if (runtime.sessionPath) {
|
|
reservedSessionPath = await projectQuickSessionPath(
|
|
quickDir,
|
|
sessionDir,
|
|
runtime.sessionPath,
|
|
);
|
|
reserveSessionPath(reservedSessionPath, runtime);
|
|
}
|
|
directoryPromotion = await promoteQuickSessionDirectory(
|
|
quickDir,
|
|
sessionDir,
|
|
);
|
|
runtime.sessionDir = sessionDir;
|
|
if (reservedSessionPath) runtime.sessionPath = reservedSessionPath;
|
|
const state = await adapter.send({ type: "get_state" });
|
|
await updateRuntimeIdentity(runtime, state, {
|
|
adapter,
|
|
persist: false,
|
|
indexSession: false,
|
|
});
|
|
// A changed identity still needs a lease through manifest commit.
|
|
if (runtime.sessionPath && runtime.sessionPath !== reservedSessionPath)
|
|
reserveSessionPath(runtime.sessionPath, runtime);
|
|
if (
|
|
previous.sessionPath &&
|
|
previous.sessionPath !== runtime.sessionPath &&
|
|
runtimesBySessionPath.get(previous.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(previous.sessionPath);
|
|
runtime.ephemeral = false;
|
|
managedWorktrees.add(runtime.worktreePath);
|
|
manifestStateChanged = true;
|
|
try {
|
|
previousReference = await readFile(
|
|
path.join(sessionDir, SESSION_REFERENCE_FILE),
|
|
"utf8",
|
|
);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
await persistSessionReference(
|
|
sessionDir,
|
|
runtime.sessionPath,
|
|
runtime.worktreePath,
|
|
);
|
|
await persistWorkspace();
|
|
if (runtime.sessionPath)
|
|
runtimesBySessionPath.set(runtime.sessionPath, runtime);
|
|
if (reservedSessionPath)
|
|
releaseSessionReservation(reservedSessionPath, runtime);
|
|
if (runtime.sessionPath !== reservedSessionPath)
|
|
releaseSessionReservation(runtime.sessionPath, runtime);
|
|
await directoryPromotion.finalize();
|
|
publishWorkspace("runtime_promoted", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
return publicRuntime(runtime);
|
|
} catch (error) {
|
|
if (directoryPromotion)
|
|
await restoreSessionReference(sessionDir, previousReference).catch(
|
|
() => {},
|
|
);
|
|
if (reservedSessionPath)
|
|
releaseSessionReservation(reservedSessionPath, runtime);
|
|
if (runtime.sessionPath !== reservedSessionPath)
|
|
releaseSessionReservation(runtime.sessionPath, runtime);
|
|
if (
|
|
runtime.sessionPath &&
|
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
|
runtime.sessionDir = previous.sessionDir;
|
|
runtime.sessionPath = previous.sessionPath;
|
|
runtime.sessionId = previous.sessionId;
|
|
runtime.sessionName = previous.sessionName;
|
|
runtime.ephemeral = true;
|
|
if (previous.sessionLease && previous.sessionPath)
|
|
runtimesBySessionPath.set(previous.sessionPath, runtime);
|
|
if (previous.managed) managedWorktrees.add(runtime.worktreePath);
|
|
else managedWorktrees.delete(runtime.worktreePath);
|
|
if (manifestStateChanged) await persistWorkspace().catch(() => {});
|
|
if (directoryPromotion) await directoryPromotion.rollback();
|
|
throw error;
|
|
}
|
|
});
|
|
const tracked = promotion.finally(() => {
|
|
runtime.promoting = false;
|
|
runtime.promotionPromise = undefined;
|
|
});
|
|
runtime.operationQueue = tracked.catch(() => {});
|
|
runtime.promotionPromise = tracked;
|
|
return tracked;
|
|
}
|
|
|
|
async function refreshAfterLegacySessionChange(
|
|
runtime,
|
|
response,
|
|
{ expectedSessionPath } = {},
|
|
) {
|
|
if (!response?.data?.cancelled) {
|
|
const state = await runtime.adapter.send({ type: "get_state" });
|
|
await updateRuntimeIdentity(runtime, state, {
|
|
...(expectedSessionPath ? { expectedSessionPath } : {}),
|
|
adapter: runtime.adapter,
|
|
});
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function beginLegacyNewSession(runtime) {
|
|
const response = await runtime.adapter.send({ type: "new_session" });
|
|
if (response?.data?.cancelled) return response;
|
|
if (
|
|
runtime.sessionPath &&
|
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
|
)
|
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
|
runtime.sessionPath = undefined;
|
|
runtime.sessionId = undefined;
|
|
runtime.sessionName = undefined;
|
|
runtime.firstMessage = undefined;
|
|
runtime.extensions.clear();
|
|
runtime.queueCount = 0;
|
|
runtime.activeTool = undefined;
|
|
runtime.error = undefined;
|
|
runtime.errorAttention = false;
|
|
runtime.attention = false;
|
|
runtime.lastActivity = new Date().toISOString();
|
|
if (runtime.legacyDefault)
|
|
await persistSessionReference(
|
|
runtime.sessionDir,
|
|
undefined,
|
|
runtime.worktreePath,
|
|
);
|
|
await persistWorkspace();
|
|
const state = await runtime.adapter.send({ type: "get_state" });
|
|
await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter });
|
|
runtime.state = state?.data?.isStreaming ? "streaming" : "idle";
|
|
runtime.stateVersion += 1;
|
|
publishAgent(runtime, "agent_state", { state: runtime.state });
|
|
publishWorkspace("runtime_renewed", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
return response;
|
|
}
|
|
|
|
function workspaceReplay(cursor = 0) {
|
|
if (!Number.isSafeInteger(cursor) || cursor < 0)
|
|
throw new TypeError("cursor must be a non-negative integer");
|
|
const firstAvailableSeq = workspaceEvents[0]?.seq ?? workspaceSequence + 1;
|
|
return {
|
|
bridgeInstanceId,
|
|
firstAvailableSeq,
|
|
latestSeq: workspaceSequence,
|
|
truncated: cursor < firstAvailableSeq - 1,
|
|
events: workspaceEvents.filter((event) => event.seq > cursor),
|
|
};
|
|
}
|
|
|
|
function workspace() {
|
|
const grouped = new Map(
|
|
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
|
|
);
|
|
for (const runtime of runtimesByRuntimeId.values()) {
|
|
if (runtime.ephemeral) continue;
|
|
let group = grouped.get(runtime.worktreePath);
|
|
if (!group) {
|
|
group = [];
|
|
grouped.set(runtime.worktreePath, group);
|
|
}
|
|
group.push(publicRuntime(runtime));
|
|
}
|
|
return {
|
|
bridgeInstanceId,
|
|
latestSeq: workspaceSequence,
|
|
directories: [...grouped.entries()]
|
|
.map(([worktreePath, runtimes]) => ({
|
|
worktreePath,
|
|
isHome: worktreePath === canonicalHomeWorktree,
|
|
openCount: runtimes.length,
|
|
workingCount: runtimes.filter((entry) => entry.state === "streaming")
|
|
.length,
|
|
attentionCount: runtimes.filter((entry) => entry.attention).length,
|
|
recoveringCount: runtimes.filter((entry) => entry.state === "recovering")
|
|
.length,
|
|
errorCount: runtimes.filter((entry) =>
|
|
["error", "failed"].includes(entry.state),
|
|
).length,
|
|
runtimes,
|
|
}))
|
|
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)),
|
|
...(manifestIssue ? { issue: manifestIssue } : {}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
get bridgeInstanceId() {
|
|
return bridgeInstanceId;
|
|
},
|
|
async start() {
|
|
canonicalHomeWorktree = await realpath(homeWorktree);
|
|
managedWorktrees.add(canonicalHomeWorktree);
|
|
const loaded = await workspaceStore.load();
|
|
manifestIssue = loaded.issue;
|
|
if (loaded.migrating) {
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalHomeWorktree);
|
|
await mkdir(sessionDir, {
|
|
recursive: true,
|
|
mode: SESSION_DIRECTORY_MODE,
|
|
});
|
|
const reference = await readSessionReference(sessionDir);
|
|
const migrationRecord = {
|
|
runtimeId: randomUUID(),
|
|
worktreePath: canonicalHomeWorktree,
|
|
legacyDefault: true,
|
|
...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
|
|
};
|
|
if (reference.sessionPath) await restoreRecords([migrationRecord]);
|
|
else await openRuntime(migrationRecord);
|
|
await persistWorkspace();
|
|
} else if (!loaded.issue) {
|
|
for (const worktreePath of loaded.manifest.directories) {
|
|
try {
|
|
managedWorktrees.add(await realpath(worktreePath));
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
const defaultWorktrees = new Set(
|
|
loaded.manifest.runtimes
|
|
.filter((record) => record.legacyDefault)
|
|
.map((record) => record.worktreePath),
|
|
);
|
|
const records = loaded.manifest.runtimes.map((record) => {
|
|
if (defaultWorktrees.has(record.worktreePath)) return record;
|
|
defaultWorktrees.add(record.worktreePath);
|
|
return { ...record, legacyDefault: true };
|
|
});
|
|
await restoreRecords(records);
|
|
}
|
|
if (
|
|
!loaded.issue &&
|
|
![...runtimesByAgentId.values()].some((runtime) => runtime.adapter)
|
|
) {
|
|
await openRuntime({
|
|
worktreePath: canonicalHomeWorktree,
|
|
legacyDefault: true,
|
|
});
|
|
}
|
|
return this.listAgents()[0];
|
|
},
|
|
async selectWorktree(worktreePath) {
|
|
if (stopping) throw new Error("agent registry is stopping");
|
|
const canonicalPath = await realpath(worktreePath);
|
|
const liveRuntimes = [
|
|
...(runtimesByWorktreePath.get(canonicalPath) ?? []),
|
|
].filter((runtime) => runtime.agentId);
|
|
const existing =
|
|
liveRuntimes.find((runtime) => runtime.legacyDefault) ?? liveRuntimes[0];
|
|
if (existing) {
|
|
if (!existing.legacyDefault) {
|
|
await setLegacyDefault(existing);
|
|
await persistWorkspace();
|
|
}
|
|
return publicAgent(existing);
|
|
}
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
|
|
await mkdir(sessionDir, {
|
|
recursive: true,
|
|
mode: SESSION_DIRECTORY_MODE,
|
|
});
|
|
const reference = await readSessionReference(sessionDir);
|
|
const opened = await openRuntime({
|
|
worktreePath: canonicalPath,
|
|
legacyDefault: true,
|
|
...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
|
|
});
|
|
return publicAgent(getRuntime(opened.runtimeId));
|
|
},
|
|
async createSessionRuntime(worktreePath) {
|
|
return openRuntime({ worktreePath });
|
|
},
|
|
async createQuickRuntime(worktreePath) {
|
|
return openRuntime({ worktreePath, ephemeral: true });
|
|
},
|
|
promoteQuickRuntime,
|
|
async openSessionRuntime(worktreePath, sessionPath) {
|
|
return openRuntime({ worktreePath, sessionPath });
|
|
},
|
|
closeSessionRuntime: closeRuntime,
|
|
async closeQuickRuntime(runtimeId) {
|
|
const runtime = getRuntime(runtimeId);
|
|
if (runtime.promoting) {
|
|
await runtime.promotionPromise;
|
|
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
|
|
}
|
|
if (!runtime.ephemeral)
|
|
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
|
|
return closeRuntime(runtimeId);
|
|
},
|
|
async getModelCatalog() {
|
|
const candidates = [...runtimesByAgentId.values()].filter(
|
|
(runtime) =>
|
|
runtime.adapter &&
|
|
!runtime.stopped &&
|
|
!["closing", "stopped", "failed"].includes(runtime.state),
|
|
);
|
|
let lastError;
|
|
for (const runtime of candidates) {
|
|
const adapter = runtime.adapter;
|
|
try {
|
|
const response = await enqueue(runtime, () =>
|
|
adapter.send({ type: "get_available_models" }),
|
|
);
|
|
if (response?.success !== true || !Array.isArray(response?.data?.models)) {
|
|
continue;
|
|
}
|
|
lastSuccessfulModelCatalog = response.data.models;
|
|
return response.data.models;
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
if (lastSuccessfulModelCatalog) return lastSuccessfulModelCatalog;
|
|
if (lastError) throw lastError;
|
|
return [];
|
|
},
|
|
listAgents() {
|
|
return [...runtimesByAgentId.values()]
|
|
.map(publicAgent)
|
|
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
|
|
},
|
|
async listDirectories() {
|
|
let entries = [];
|
|
try {
|
|
entries = await readdir(sessionRoot, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
const references = await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map(async (entry) => {
|
|
const reference = await readSessionReference(
|
|
path.join(sessionRoot, entry.name),
|
|
);
|
|
return reference.worktreePath;
|
|
}),
|
|
);
|
|
const paths = new Set([
|
|
...runtimesByWorktreePath.keys(),
|
|
...references.filter(Boolean),
|
|
]);
|
|
return [...paths]
|
|
.map((worktreePath) => {
|
|
const runtime = [...(runtimesByWorktreePath.get(worktreePath) ?? [])].find(
|
|
(entry) => entry.agentId,
|
|
);
|
|
return {
|
|
worktreePath,
|
|
state: runtime?.state ?? "inactive",
|
|
isHome: worktreePath === canonicalHomeWorktree,
|
|
...(runtime ? { agentId: runtime.agentId } : {}),
|
|
};
|
|
})
|
|
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
|
|
},
|
|
async forgetDirectory(worktreePath) {
|
|
let canonicalPath = path.normalize(worktreePath);
|
|
try {
|
|
canonicalPath = await realpath(worktreePath);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
if (canonicalPath === canonicalHomeWorktree)
|
|
throw new Error("cannot forget the home directory");
|
|
if ((runtimesByWorktreePath.get(canonicalPath)?.size ?? 0) > 0)
|
|
throw new Error(
|
|
"cannot forget a directory while session runtimes are open",
|
|
);
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
|
|
await rm(path.join(sessionDir, SESSION_REFERENCE_FILE), { force: true });
|
|
managedWorktrees.delete(canonicalPath);
|
|
await persistWorkspace();
|
|
return { worktreePath: canonicalPath };
|
|
},
|
|
async listSessions(agentId) {
|
|
const runtime = getAgent(agentId);
|
|
return listSessionSummaries(runtime.sessionDir, runtime.sessionPath);
|
|
},
|
|
async listDirectorySessions(worktreePath) {
|
|
const canonicalPath = await realpath(worktreePath);
|
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
|
|
const sessions = await listSessionSummaries(sessionDir);
|
|
return sessions.map((session) => {
|
|
const openRuntime = runtimesBySessionPath.get(session.path);
|
|
return {
|
|
...session,
|
|
isCurrent: false,
|
|
...(openRuntime ? { runtimeId: openRuntime.runtimeId } : {}),
|
|
};
|
|
});
|
|
},
|
|
getWorkspace: workspace,
|
|
getWorkspaceSummary() {
|
|
const runtimes = [...runtimesByRuntimeId.values()].filter(
|
|
(runtime) => !runtime.ephemeral,
|
|
);
|
|
return {
|
|
bridgeInstanceId,
|
|
latestSeq: workspaceSequence,
|
|
openCount: runtimes.length,
|
|
workingCount: runtimes.filter((runtime) => runtime.state === "streaming")
|
|
.length,
|
|
attentionCount: runtimes.filter((runtime) => runtime.attention).length,
|
|
recoveringCount: runtimes.filter(
|
|
(runtime) => runtime.state === "recovering",
|
|
).length,
|
|
errorCount: runtimes.filter((runtime) =>
|
|
["error", "failed"].includes(runtime.state),
|
|
).length,
|
|
directoryCount: new Set(runtimes.map((runtime) => runtime.worktreePath))
|
|
.size,
|
|
resourceWarning: runtimes.length >= RESOURCE_WARNING_COUNT,
|
|
};
|
|
},
|
|
async getSessionRuntimeSnapshot(runtimeId) {
|
|
const runtime = getRuntime(runtimeId);
|
|
if (!runtime.adapter)
|
|
return {
|
|
bridgeInstanceId,
|
|
latestSeq: workspaceSequence,
|
|
runtime: publicRuntime(runtime),
|
|
extensions: [...runtime.extensions.values()],
|
|
};
|
|
return enqueue(runtime, async () => {
|
|
const adapter = runtime.adapter;
|
|
const safe = async (command, fallback, { includeError = false } = {}) => {
|
|
try {
|
|
return await adapter.send({ type: command });
|
|
} catch (error) {
|
|
return includeError ? { ...fallback, error: String(error) } : fallback;
|
|
}
|
|
};
|
|
const state = await safe("get_state", { data: {} });
|
|
const stats = await safe("get_session_stats", { data: {} });
|
|
const transcript = await safe("get_messages", {
|
|
data: { messages: [] },
|
|
});
|
|
const commands = await safe("get_commands", { data: { commands: [] } });
|
|
const models = await safe(
|
|
"get_available_models",
|
|
{ data: { models: [] } },
|
|
{ includeError: true },
|
|
);
|
|
await updateRuntimeIdentity(runtime, state, { adapter });
|
|
return {
|
|
bridgeInstanceId,
|
|
latestSeq: workspaceSequence,
|
|
runtime: publicRuntime(runtime),
|
|
state,
|
|
stats,
|
|
transcript,
|
|
commands,
|
|
models,
|
|
extensions: [...runtime.extensions.values()],
|
|
};
|
|
});
|
|
},
|
|
eventsAfter(agentId, cursor = 0) {
|
|
if (!Number.isSafeInteger(cursor) || cursor < 0)
|
|
throw new TypeError("cursor must be a non-negative integer");
|
|
return getAgent(agentId).events.filter((event) => event.seq > cursor);
|
|
},
|
|
subscribe(agentId, cursor, listener) {
|
|
if (typeof listener !== "function")
|
|
throw new TypeError("listener must be a function");
|
|
const runtime = getAgent(agentId);
|
|
const events = this.eventsAfter(agentId, cursor);
|
|
runtime.listeners.add(listener);
|
|
return {
|
|
events,
|
|
unsubscribe: () => runtime.listeners.delete(listener),
|
|
};
|
|
},
|
|
workspaceEventsAfter: workspaceReplay,
|
|
subscribeWorkspace(cursor, listener) {
|
|
if (typeof listener !== "function")
|
|
throw new TypeError("listener must be a function");
|
|
const replay = workspaceReplay(cursor);
|
|
workspaceListeners.add(listener);
|
|
return {
|
|
...replay,
|
|
unsubscribe: () => workspaceListeners.delete(listener),
|
|
};
|
|
},
|
|
async waitForRecovery(agentId) {
|
|
return getAgent(agentId).recoveryPromise;
|
|
},
|
|
async retry(agentId) {
|
|
const runtime = getAgent(agentId);
|
|
return enqueue(runtime, async () => {
|
|
runtime.supervisor.markHealthy();
|
|
runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit();
|
|
await runtime.recoveryPromise;
|
|
return publicAgent(runtime);
|
|
});
|
|
},
|
|
async renewSession(agentId) {
|
|
const runtime = getAgent(agentId);
|
|
return enqueue(runtime, async () => {
|
|
if (!runtime.adapter) throw new Error("Pi is not currently running");
|
|
if (runtime.state === "streaming" || runtime.queueCount > 0)
|
|
await runtime.adapter.send({ type: "abort" });
|
|
const response = await beginLegacyNewSession(runtime);
|
|
if (response?.data?.cancelled)
|
|
throw new Error("Pi cancelled the session renewal");
|
|
return publicAgent(runtime);
|
|
});
|
|
},
|
|
async restart(agentId) {
|
|
const runtime = getAgent(agentId);
|
|
return enqueue(runtime, async () => {
|
|
const replacedAdapter = runtime.adapter;
|
|
if (!replacedAdapter) throw new Error("Pi is not currently running");
|
|
// Detach the old child before stopping it so late events cannot mutate
|
|
// presentation state that belongs to the replacement generation.
|
|
runtime.adapter = undefined;
|
|
await replacedAdapter.stop();
|
|
// Dialog request IDs belong to the terminated child and cannot be
|
|
// answered by its replacement.
|
|
runtime.extensions.clear();
|
|
runtime.attention = runtime.errorAttention;
|
|
runtime.supervisor.markHealthy();
|
|
runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit();
|
|
const nextAdapter = await runtime.recoveryPromise;
|
|
if (!nextAdapter) {
|
|
runtime.state = "failed";
|
|
runtime.stateVersion += 1;
|
|
runtime.error = {
|
|
code: "restart_failed",
|
|
message: "Pi did not restart after the recovery attempts were exhausted",
|
|
};
|
|
runtime.errorAttention = true;
|
|
runtime.attention = true;
|
|
publishAgent(runtime, "agent_state", {
|
|
state: runtime.state,
|
|
error: runtime.error,
|
|
});
|
|
throw new Error(runtime.error.message);
|
|
}
|
|
runtime.error = undefined;
|
|
runtime.errorAttention = false;
|
|
runtime.attention = runtime.extensions.size > 0;
|
|
publishAgent(runtime, "agent_state", { state: runtime.state });
|
|
publishWorkspace("runtime_recovered", runtime, {
|
|
runtime: publicRuntime(runtime),
|
|
});
|
|
return publicAgent(runtime);
|
|
});
|
|
},
|
|
async route(agentId, operation, payload = {}) {
|
|
const runtime = getAgent(agentId);
|
|
if (operation === "abort" && !runtime.promoting)
|
|
return runtime.adapter.send({ type: "abort" });
|
|
return enqueue(runtime, async () => {
|
|
if (operation === "switch_session") {
|
|
if (runtime.state === "streaming")
|
|
throw new Error("cannot switch sessions while Pi is working");
|
|
const ownedPath = await ownedSessionPath(
|
|
runtime.sessionDir,
|
|
payload.sessionPath,
|
|
);
|
|
reserveSessionPath(ownedPath, runtime);
|
|
try {
|
|
const response = await runtime.adapter.send({
|
|
type: "switch_session",
|
|
sessionPath: ownedPath,
|
|
});
|
|
return await refreshAfterLegacySessionChange(runtime, response);
|
|
} finally {
|
|
releaseSessionReservation(ownedPath, runtime);
|
|
}
|
|
}
|
|
if (operation === "new_session") {
|
|
if (runtime.state === "streaming")
|
|
throw new Error("cannot start a session while Pi is working");
|
|
return beginLegacyNewSession(runtime);
|
|
}
|
|
const previousState = runtime.state;
|
|
const previousStateVersion = runtime.stateVersion;
|
|
const delivery =
|
|
operation === "submit_prompt"
|
|
? previousState === "streaming"
|
|
? "follow_up"
|
|
: "prompt"
|
|
: undefined;
|
|
const startsWork =
|
|
operation === "prompt" ||
|
|
(operation === "submit_prompt" && previousState !== "streaming");
|
|
if (startsWork) {
|
|
runtime.state = "streaming";
|
|
if (!runtime.firstMessage && typeof payload.message === "string")
|
|
runtime.firstMessage = payload.message;
|
|
}
|
|
try {
|
|
if (
|
|
operation === "extension_response" &&
|
|
!runtime.extensions.has(payload.requestId)
|
|
)
|
|
throw new Error("extension request is not actionable for this runtime");
|
|
const response = await routeCommand(
|
|
runtime.adapter,
|
|
operation,
|
|
payload,
|
|
previousState,
|
|
);
|
|
if (operation === "set_session_name") {
|
|
runtime.sessionName = payload.name;
|
|
await persistWorkspace();
|
|
}
|
|
if (operation === "extension_response") {
|
|
runtime.extensions.delete(payload.requestId);
|
|
runtime.attention = runtime.errorAttention || runtime.extensions.size > 0;
|
|
}
|
|
return delivery ? { ...response, delivery } : response;
|
|
} catch (error) {
|
|
if (
|
|
startsWork &&
|
|
runtime.state === "streaming" &&
|
|
runtime.stateVersion === previousStateVersion
|
|
)
|
|
runtime.state = previousState;
|
|
throw error;
|
|
}
|
|
});
|
|
},
|
|
async stop() {
|
|
stopping = true;
|
|
while (pendingOpenOperations.size > 0)
|
|
await Promise.allSettled(pendingOpenOperations);
|
|
const runtimes = [...runtimesByRuntimeId.values()];
|
|
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
|
|
await Promise.all(
|
|
runtimes.map(async (runtime) => {
|
|
runtime.stopped = true;
|
|
runtime.supervisor?.stop();
|
|
await runtime.adapter?.stop().catch(() => {});
|
|
runtime.state = "stopped";
|
|
return undefined;
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export const sessionDirectoryPath = sessionDirectoryFor;
|