From e70a093758d14fc1eeb2a08d86c9a94fb588eedd Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:37:11 -0400 Subject: [PATCH 1/3] Refactor web/server/index.ts into organized feature modules Break the 3,945-line single-file web daemon into focused modules with camelCase filenames, following the existing factory + dependency-injection pattern (createSessionQueueCoordinator, createSessionFileCatalog): - serverConfig / serverRuntimeState / serverStores: env config, shared mutable state, and durable sidecar stores - stateFileStore / discoveryState: state-file persistence and the webState/Tailscale Serve publishing pair - sessionRegistry / sessionHistory / recordSync / missingSessions: the live session catalog, bounded history, runtime state application, and missing-file predicates - clientBroadcast / gitMetadata / rpcSessions / compactionNotice: socket fan-out, git/gh metadata hydration, RPC session factory, and compaction completion notices - managedSessionCreate / managedSessionRefresh / sessionReplacement: managed RPC session lifecycle, transactional identity refresh with staged-deletion recovery, and external replacement commit - commandRouter / clientMessages / agentMessages: command routing with the slash command service, and the browser/agent WebSocket handlers - sessionDeletion / httpApi / webSocketGateway: durable deletion and reconciliation, the REST API, and the WebSocket endpoint - daemonOwnership / serverLifecycle / webServerApp: ownership arbitration, startup/shutdown, and the composition root wiring the two genuine dependency cycles (queue coordinator <-> router, registry <-> deletion) with late-bound references index.ts remains the entry point (15 lines) so `bun run web/server/index.ts` and daemon detection keep working. Pure structural move; all 353 tests pass unchanged. --- web/server/agentMessages.ts | 417 +++ web/server/clientBroadcast.ts | 129 + web/server/clientMessages.ts | 282 ++ web/server/commandRouter.ts | 646 +++++ web/server/compactionNotice.ts | 28 + web/server/daemonOwnership.ts | 99 + web/server/discoveryState.ts | 79 + web/server/gitMetadata.ts | 94 + web/server/httpApi.ts | 416 +++ web/server/index.ts | 3944 +-------------------------- web/server/managedSessionCreate.ts | 466 ++++ web/server/managedSessionRefresh.ts | 465 ++++ web/server/missingSessions.ts | 60 + web/server/recordSync.ts | 211 ++ web/server/rpcSessions.ts | 37 + web/server/server-types.ts | 7 + web/server/serverConfig.ts | 74 + web/server/serverLifecycle.ts | 317 +++ web/server/serverRuntimeState.ts | 46 + web/server/serverStores.ts | 16 + web/server/sessionDeletion.ts | 412 +++ web/server/sessionHistory.ts | 74 + web/server/sessionRegistry.ts | 212 ++ web/server/sessionReplacement.ts | 166 ++ web/server/stateFileStore.ts | 47 + web/server/tsconfig.json | 2 +- web/server/webServerApp.ts | 214 ++ web/server/webSocketGateway.ts | 176 ++ 28 files changed, 5197 insertions(+), 3939 deletions(-) create mode 100644 web/server/agentMessages.ts create mode 100644 web/server/clientBroadcast.ts create mode 100644 web/server/clientMessages.ts create mode 100644 web/server/commandRouter.ts create mode 100644 web/server/compactionNotice.ts create mode 100644 web/server/daemonOwnership.ts create mode 100644 web/server/discoveryState.ts create mode 100644 web/server/gitMetadata.ts create mode 100644 web/server/httpApi.ts create mode 100644 web/server/managedSessionCreate.ts create mode 100644 web/server/managedSessionRefresh.ts create mode 100644 web/server/missingSessions.ts create mode 100644 web/server/recordSync.ts create mode 100644 web/server/rpcSessions.ts create mode 100644 web/server/serverConfig.ts create mode 100644 web/server/serverLifecycle.ts create mode 100644 web/server/serverRuntimeState.ts create mode 100644 web/server/serverStores.ts create mode 100644 web/server/sessionDeletion.ts create mode 100644 web/server/sessionHistory.ts create mode 100644 web/server/sessionRegistry.ts create mode 100644 web/server/sessionReplacement.ts create mode 100644 web/server/stateFileStore.ts create mode 100644 web/server/webServerApp.ts create mode 100644 web/server/webSocketGateway.ts diff --git a/web/server/agentMessages.ts b/web/server/agentMessages.ts new file mode 100644 index 0000000..bfa2ea3 --- /dev/null +++ b/web/server/agentMessages.ts @@ -0,0 +1,417 @@ +import { randomUUID } from "node:crypto"; +import { + agentEndTerminalNotice, + assistantTerminalNotice, +} from "../assistant-message.js"; +import { boundedWebHistory } from "../history.js"; +import { + type AgentEventMessage, + type AgentHelloMessage, + type AgentHistoryMessage, + type AgentResponseMessage, + type AgentScopeMessage, + type AgentSessionReplacedMessage, + type AgentSubagentsMessage, + type AgentToServerMessage, + type AgentUpdateMessage, + hasActiveWebSubagents, + mergeWebSubagentUpdates, + type ServerEventMessage, + type ServerHistoryMessage, + type ServerSessionMessage, +} from "../protocol.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { CompactionNotice } from "./compactionNotice.js"; +import type { GitMetadata } from "./gitMetadata.js"; +import { CommandRejectedError } from "./managed-rpc-session.js"; +import type { RecordSync } from "./recordSync.js"; +import type { + AgentSocketData, + SessionFileCatalog, + SessionKind, + SessionQueueCoordinator, +} from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import { normalizeLegacySessionUpdate } from "./session-lifecycle.js"; +import type { SessionHistory } from "./sessionHistory.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; +import type { SessionReplacement } from "./sessionReplacement.js"; +import { managedWorktreeFromEntries } from "./worktrees.js"; + +/** Handles messages from Pi bridge WebSocket agents (native TUI sessions). */ +export function createAgentMessages(options: { + state: ServerRuntimeState; + catalog: SessionFileCatalog; + registry: SessionRegistry; + history: SessionHistory; + recordSync: RecordSync; + queue: SessionQueueCoordinator; + broadcast: ClientBroadcast; + git: GitMetadata; + compactionNotice: CompactionNotice; + replacement: SessionReplacement; +}) { + const { + state: runtime, + catalog, + registry, + history, + recordSync, + queue, + broadcast, + git, + compactionNotice, + replacement, + } = options; + const { + normalizePath, + extractPreviewFromHistory, + parseSessionHistoryFile, + isRecord, + zeroWebUsage, + addWebUsage, + extractTextContent, + } = catalog; + const { upsertSession, sessionToClientPayload } = registry; + const { replaceRecordHistory, appendRecordHistory } = history; + const { updateSubagentsFromToolEvent, catalogSessionChanged } = recordSync; + const { + markAgentActivity, + markAgentSettling, + isCurrentAgentSettlement, + cancelQueueSettleFallback, + scheduleQueueSettleFallback, + flushWebQueue, + broadcastQueueDelivery, + } = queue; + const { broadcast: broadcastToSessionClients, broadcastSessionToAll } = + broadcast; + const { hydrateGitMetadata } = git; + const { broadcastCompactionNotice } = compactionNotice; + const { completeExternalSessionReplacement } = replacement; + + async function handleAgentMessage( + socket: Bun.ServerWebSocket, + message: AgentToServerMessage, + ): Promise { + if (message.type === "agent.hello") { + socket.data.authed = true; + const hello = message as AgentHelloMessage; + const kind: SessionKind = + hello.session.source === "saved" + ? "saved" + : hello.session.source === "web" + ? "managed" + : "external"; + const helloManagedWorktree = + hello.session.managedWorktree ?? + managedWorktreeFromEntries(hello.entries ?? []); + const helloHistory = boundedWebHistory(hello.entries ?? []); + const authoritativeHistory = + hello.historyMode === "replace" || helloHistory.length > 0; + const record = upsertSession( + hello.session, + kind, + authoritativeHistory ? helloHistory : [], + ); + if (authoritativeHistory) replaceRecordHistory(record, helloHistory); + else if (!record.historyReady && record.file) { + // Bridges loaded before historyMode existed reconnect with an empty hello. + // Restore a bounded disk suffix without hydrating the full JSONL archive. + replaceRecordHistory(record, parseSessionHistoryFile(record.file)); + } + record.preview = + extractPreviewFromHistory(record.history) ?? record.preview; + record.managedWorktree = helloManagedWorktree ?? record.managedWorktree; + // Carry the agent's --models scope onto the record so the model picker + // mirrors what the TUI would show. Empty array means no scope. + record.scopedModels = Array.isArray(hello.scopedModels) + ? hello.scopedModels + : undefined; + record.agentSockets.add(socket); + record.active = true; + record.status = hello.session.status; + record.agentRunning = hello.session.status === "working"; + record.updatedAt = hello.session.updatedAt; + for (const uncertain of record.queue.filter( + (item) => item.deliveryState === "delivering", + )) { + broadcastQueueDelivery( + record, + uncertain, + "uncertain", + "Delivery was interrupted by a daemon restart; edit or remove this item explicitly before resubmitting.", + ); + } + if (record.file) + runtime.sessionsByFile.set(normalizePath(record.file), record); + broadcastSessionToAll(record); + void hydrateGitMetadata(record); + // Native sessions use the same bounded semantic history as managed sessions; + // no browser connection asks Pi to paint an additional TUI viewport. + broadcastToSessionClients(record.id, { + type: "server.history", + sessionId: record.id, + entries: record.history, + replace: true, + } satisfies ServerHistoryMessage); + void flushWebQueue(record); + return; + } + if (!socket.data.authed) + throw new Error("Agent must send agent.hello first"); + if (message.type === "agent.scope") { + const update = message as AgentScopeMessage; + const record = runtime.sessions.get(update.sessionId); + if (!record || !record.agentSockets.has(socket)) return; + record.scopedModels = update.scopedModels; + return; + } + if (message.type === "agent.history") { + const update = message as AgentHistoryMessage; + const record = runtime.sessions.get(update.sessionId); + if (!record || !record.agentSockets.has(socket)) return; + replaceRecordHistory(record, update.entries); + broadcastToSessionClients(record.id, { + type: "server.history", + sessionId: record.id, + entries: record.history, + replace: true, + } satisfies ServerHistoryMessage); + return; + } + if (message.type === "agent.session_replaced") { + await completeExternalSessionReplacement( + socket, + message as AgentSessionReplacedMessage, + ); + return; + } + if (message.type === "agent.event") { + const event = message as AgentEventMessage; + const record = runtime.sessions.get(event.sessionId); + if (record) { + record.updatedAt = Date.now(); + let lifecycleChanged = false; + if ( + event.event.type === "agent_start" || + event.event.type === "turn_start" + ) { + record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; + markAgentActivity(record); + } + if ( + event.event.type === "agent_start" || + event.event.type === "turn_start" + ) { + cancelQueueSettleFallback(record); + record.status = "working"; + record.agentRunning = true; + lifecycleChanged = true; + } + if (event.event.type === "agent_end" && !record.compaction) { + markAgentSettling(record); + record.status = + agentEndTerminalNotice(event.event)?.kind === "error" + ? "error" + : "idle"; + record.agentRunning = false; + scheduleQueueSettleFallback(record); + lifecycleChanged = true; + } + if (event.event.type === "compaction_start") { + markAgentSettling(record); + record.status = "working"; + record.agentRunning = true; + record.compaction = { + reason: + event.event.reason === "manual" || + event.event.reason === "overflow" + ? event.event.reason + : "threshold", + startedAt: + typeof event.event.startedAt === "number" + ? event.event.startedAt + : Date.now(), + }; + } + if (event.event.type === "compaction_end") { + record.compaction = undefined; + if (event.event.aborted === true || event.event.willRetry === false) { + record.status = "idle"; + record.agentRunning = false; + scheduleQueueSettleFallback(record); + lifecycleChanged = true; + } + if (event.event.aborted !== true) { + broadcastCompactionNotice(record); + } + } + if ( + event.event.type === "agent_settled" && + isCurrentAgentSettlement(record) + ) { + cancelQueueSettleFallback(record); + record.settlingGeneration = undefined; + lifecycleChanged = true; + // Pi emits agent_settled only when no retry, compaction, or internal + // follow-up remains. It is authoritative even when an interrupted + // overflow compaction last advertised willRetry=true. + if (record.status !== "error") record.status = "idle"; + record.agentRunning = false; + void flushWebQueue(record); + } + const subagentsChanged = updateSubagentsFromToolEvent( + record, + event.event, + ); + let sessionMetadataChanged = false; + if (event.event.type === "session_info_changed") { + record.name = + typeof event.event.name === "string" && event.event.name + ? event.event.name + : undefined; + sessionMetadataChanged = true; + } + if ( + event.event.type === "message_end" && + isRecord(event.event.message) + ) { + appendRecordHistory(record, { + type: "message", + id: randomUUID(), + parentId: null, + timestamp: new Date().toISOString(), + message: event.event.message, + }); + record.messageCount += 1; + if ( + event.event.message.role === "assistant" || + event.event.message.role === "toolResult" + ) { + record.usage ??= zeroWebUsage(); + addWebUsage(record.usage, event.event.message.usage); + } + if ( + event.event.message.role === "user" || + event.event.message.role === "assistant" + ) { + const preview = extractTextContent(event.event.message.content); + const terminalNotice = assistantTerminalNotice(event.event.message); + if (preview) record.preview = preview.slice(0, 180); + else if (terminalNotice) + record.preview = + `${terminalNotice.title}: ${terminalNotice.detail}`.slice( + 0, + 180, + ); + } + sessionMetadataChanged = true; + } + broadcastToSessionClients(event.sessionId, { + type: "server.event", + sessionId: event.sessionId, + event: event.event, + } satisfies ServerEventMessage); + if ( + sessionMetadataChanged || + lifecycleChanged || + event.event.type === "compaction_start" || + event.event.type === "compaction_end" + ) { + broadcastSessionToAll(record); + } else if (subagentsChanged) { + broadcastToSessionClients(record.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + } + // PRs are commonly opened during an agent run without changing branches. + // Refresh after completion so the catalog does not retain the pre-PR lookup. + if (event.event.type === "agent_end") void hydrateGitMetadata(record); + } + return; + } + if (message.type === "agent.subagents") { + const update = message as AgentSubagentsMessage; + const record = runtime.sessions.get(update.sessionId); + if (!record) return; + record.subagents = mergeWebSubagentUpdates( + record.subagents, + update.agents, + ); + record.subagentUsage = update.usage; + record.updatedAt = Date.now(); + broadcastToSessionClients(record.id, { + type: "server.event", + sessionId: record.id, + event: { + type: "subagents_update", + agents: update.agents, + usage: update.usage, + }, + } satisfies ServerEventMessage); + if ( + record.status === "idle" && + !hasActiveWebSubagents(record.subagents) && + record.queue.length > 0 + ) + void flushWebQueue(record); + return; + } + if (message.type === "agent.update") { + const update = message as AgentUpdateMessage; + const existing = runtime.sessions.get(update.session.id); + // Older bridge runtimes reported `working` again immediately after their + // authoritative agent_end event. Preserve the lifecycle event until a new + // agent_start arrives so completed runs cannot get stuck visually working. + const lifecycleSession = normalizeLegacySessionUpdate( + existing, + update.session, + ); + // Older native bridges keep reporting their initial preview. Preserve + // message_end/file-derived metadata after hello during rolling upgrades. + const session = existing + ? { ...lifecycleSession, preview: existing.preview } + : lifecycleSession; + const catalogChanged = catalogSessionChanged(existing, session); + const gitContextChanged = + existing?.cwd !== session.cwd || existing?.branch !== session.branch; + const record = upsertSession( + session, + "external", + existing?.history ?? [], + ); + record.agentSockets.add(socket); + record.updatedAt = update.session.updatedAt; + if (catalogChanged) broadcastSessionToAll(record); + else + broadcastToSessionClients(update.session.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + if (gitContextChanged) void hydrateGitMetadata(record); + return; + } + if (message.type === "agent.response") { + const response = message as AgentResponseMessage; + const record = Array.from(runtime.sessions.values()).find((candidate) => + candidate.externalPending.has(response.requestId), + ); + if (!record) return; + const pending = record.externalPending.get(response.requestId); + if (!pending) return; + record.externalPending.delete(response.requestId); + record.externalRequestTargets.delete(response.requestId); + if (response.success) pending.resolve(response.data); + else + pending.reject( + new CommandRejectedError(response.error ?? "Agent command failed"), + ); + } + } + + return { handleAgentMessage }; +} + +export type AgentMessages = ReturnType; diff --git a/web/server/clientBroadcast.ts b/web/server/clientBroadcast.ts new file mode 100644 index 0000000..48a8647 --- /dev/null +++ b/web/server/clientBroadcast.ts @@ -0,0 +1,129 @@ +import { boundedWebHistory } from "../history.js"; +import type { + ServerHistoryMessage, + ServerSessionMessage, + ServerSessionRemovedMessage, + ServerSnapshotMessage, + ServerToClientMessage, +} from "../protocol.js"; +import type { ClientSocketData, SessionRecord } from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { SessionHistory } from "./sessionHistory.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; + +/** Fan-out of server frames to browser sockets, per-session or catalog-wide. */ +export function createClientBroadcast(options: { + state: ServerRuntimeState; + registry: SessionRegistry; + history: SessionHistory; +}) { + const { state: runtime, registry, history } = options; + const { sessionToClientPayload, sessionSnapshot } = registry; + const { sessionHistoryForRecord } = history; + + function broadcast(sessionId: string, message: ServerToClientMessage): void { + const record = runtime.sessions.get(sessionId); + if (!record) return; + const payload = JSON.stringify(message); + for (const socket of record.clientSockets) { + try { + socket.send(payload); + } catch { + // ignore + } + } + } + + function broadcastToAll(message: ServerToClientMessage): void { + const payload = JSON.stringify(message); + for (const socket of runtime.connectedClientSockets) { + try { + socket.send(payload); + } catch { + // ignore + } + } + } + + function broadcastSessionToAll(record: SessionRecord): void { + if ( + record.catalogReady === false || + runtime.sessions.get(record.id) !== record + ) + return; + broadcastToAll({ + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + } + + function sendSessionSnapshot( + socket: Bun.ServerWebSocket, + ): void { + const payload: ServerSnapshotMessage = { + type: "server.snapshot", + sessions: sessionSnapshot(), + }; + socket.send(JSON.stringify(payload)); + } + + function sendSessionHistory( + socket: Bun.ServerWebSocket, + record: SessionRecord, + ): void { + // The web client is semantic for both managed and native sessions. Loading + // transcript entries here avoids any second TUI render in the Pi process. + const entries = sessionHistoryForRecord(record); + // Bound initial payload size for very long sessions. Live events append from + // this point; older history remains available in the native session file. + const payload: ServerHistoryMessage = { + type: "server.history", + sessionId: record.id, + entries: boundedWebHistory(entries), + replace: true, + }; + socket.send(JSON.stringify(payload)); + } + + function sendSessionRemoved( + sessionId: string, + replacementSessionId?: string, + additionalSockets: Iterable> = [], + ): void { + const payload: ServerSessionRemovedMessage = { + type: "server.session_removed", + sessionId, + replacementSessionId, + }; + const message = JSON.stringify(payload); + const notified = new Set>(); + for (const socket of runtime.connectedClientSockets) { + notified.add(socket); + try { + socket.send(message); + } catch { + /* ignore */ + } + } + for (const socket of additionalSockets) { + if (notified.has(socket)) continue; + notified.add(socket); + try { + socket.send(message); + } catch { + /* ignore */ + } + } + } + + return { + broadcast, + broadcastToAll, + broadcastSessionToAll, + sendSessionSnapshot, + sendSessionHistory, + sendSessionRemoved, + }; +} + +export type ClientBroadcast = ReturnType; diff --git a/web/server/clientMessages.ts b/web/server/clientMessages.ts new file mode 100644 index 0000000..37e8060 --- /dev/null +++ b/web/server/clientMessages.ts @@ -0,0 +1,282 @@ +import { parseWebCompactCommand } from "../compact-command.js"; +import type { + ClientToServerMessage, + ServerResponseMessage, +} from "../protocol.js"; +import { hasActiveWebSubagents } from "../protocol.js"; +import { isWebReloadCommand } from "../reload-command.js"; +import { + parseWorktreeInvocation, + WORKTREE_USAGE, +} from "../worktree-command.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { CommandRouter } from "./commandRouter.js"; +import type { ManagedSessionRefresh } from "./managedSessionRefresh.js"; +import type { + ClientSocketData, + SessionFileCatalog, + SessionQueueCoordinator, +} from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; +import { shouldRejectDuringShutdown } from "./shutdown-policy.js"; + +/** Handles messages from browser WebSocket clients. */ +export function createClientMessages(options: { + state: ServerRuntimeState; + sessionsDir: string; + catalog: SessionFileCatalog; + registry: SessionRegistry; + queue: SessionQueueCoordinator; + broadcast: ClientBroadcast; + router: CommandRouter; + refresh: ManagedSessionRefresh; +}) { + const { + state: runtime, + sessionsDir, + catalog, + registry, + queue, + broadcast, + router, + refresh, + } = options; + const { normalizePath, scanSavedSessions, isRecord } = catalog; + const { upsertSession } = registry; + const { + enqueueWebFollowUp, + webQueueEvent, + broadcastReloadComplete, + sendSessionState, + } = queue; + const { sendSessionHistory, sendSessionSnapshot } = broadcast; + const { routeCommand } = router; + const { refreshManagedSession } = refresh; + + async function handleClientMessage( + socket: Bun.ServerWebSocket, + message: ClientToServerMessage, + ): Promise { + if ( + message.type === "client.hello" || + message.type === "client.command_hello" + ) { + socket.data.authed = true; + if (message.type === "client.hello") { + runtime.connectedClientSockets.add(socket); + sendSessionSnapshot(socket); + } + return; + } + if (!socket.data.authed) + throw new Error("Client must send a hello message first"); + if (runtime.shutdownStarted && shouldRejectDuringShutdown(message)) { + throw new Error( + "Pi Web is waiting for active sessions to finish before restarting", + ); + } + if (message.type === "client.subscribe") { + const record = + runtime.sessions.get(message.sessionId) ?? + (() => { + const scan = scanSavedSessions(sessionsDir).find( + (item) => + item.session.id === message.sessionId || + (item.file && + normalizePath(item.file) === normalizePath(message.sessionId)), + ); + if (!scan) return undefined; + return upsertSession( + scan.session, + "saved", + scan.history, + scan.managedWorktreeScanned, + ); + })(); + if (!record) throw new Error(`Unknown session: ${message.sessionId}`); + const previousSessionId = socket.data.sessionId; + if (previousSessionId && previousSessionId !== record.id) { + runtime.sessions.get(previousSessionId)?.clientSockets.delete(socket); + } + record.clientSockets.add(socket); + socket.data.sessionId = record.id; + sendSessionState(socket, record); + sendSessionHistory(socket, record); + return; + } + if (message.type === "client.sync_queue") { + const record = runtime.sessions.get(message.sessionId); + if (!record) throw new Error(`Unknown session: ${message.sessionId}`); + const update = webQueueEvent(record); + socket.send( + JSON.stringify({ + ...update, + event: { ...update.event, syncRequestId: message.requestId }, + }), + ); + return; + } + if (message.type === "client.prompt") { + const record = runtime.sessions.get(message.sessionId); + try { + if (!record) throw new Error(`Unknown session: ${message.sessionId}`); + const normalizedPrompt = message.message.trim(); + const reload = isWebReloadCommand(normalizedPrompt); + const compact = parseWebCompactCommand(normalizedPrompt); + const worktree = parseWorktreeInvocation(message.message); + let responseData: unknown; + if (reload) { + if (message.images?.length) + throw new Error("/reload does not accept image attachments"); + if ( + message.streamingBehavior === "followUp" && + (record.status === "working" || + hasActiveWebSubagents(record.subagents)) + ) { + await enqueueWebFollowUp(record, { + id: message.requestId, + message: message.message, + }); + } else { + if (message.streamingBehavior === "steer") + throw new Error("/reload must be queued or run while Pi is idle"); + responseData = await routeCommand(record, { type: "reload" }); + broadcastReloadComplete(record); + } + } else if (compact) { + if (message.images?.length) + throw new Error("/compact does not accept image attachments"); + if ( + message.streamingBehavior === "followUp" && + (record.status === "working" || + hasActiveWebSubagents(record.subagents)) + ) { + await enqueueWebFollowUp(record, { + id: message.requestId, + message: message.message, + }); + } else { + if (message.streamingBehavior === "steer") + throw new Error( + "/compact must be queued or run while Pi is idle", + ); + responseData = await routeCommand(record, { + type: "compact", + customInstructions: compact.customInstructions, + }); + } + } else if (worktree) { + if (message.images?.length) + throw new Error("/worktree does not accept image attachments"); + if (message.streamingBehavior) + throw new Error("/worktree must be run while Pi is idle"); + if (!worktree.name && !worktree.existing) { + throw new Error(WORKTREE_USAGE); + } + responseData = await routeCommand( + record, + worktree.existing + ? { type: "create_worktree", existing: worktree.existing } + : { + type: "create_worktree", + name: worktree.name ?? "", + repository: worktree.repository ?? record.cwd, + branch: worktree.branch, + startPoint: worktree.startPoint, + }, + ); + } else if ( + message.streamingBehavior === "followUp" && + record.status === "working" + ) { + await enqueueWebFollowUp(record, { + id: message.requestId, + message: message.message, + images: message.images, + }); + } else { + await routeCommand(record, { + type: "prompt", + message: message.message, + images: message.images, + streamingBehavior: message.streamingBehavior, + }); + } + socket.send( + JSON.stringify({ + type: "server.response", + requestId: message.requestId, + success: true, + data: responseData, + } satisfies ServerResponseMessage), + ); + } catch (error) { + socket.send( + JSON.stringify({ + type: "server.response", + requestId: message.requestId, + success: false, + error: error instanceof Error ? error.message : String(error), + } satisfies ServerResponseMessage), + ); + } + return; + } + if (message.type === "client.command") { + const record = + runtime.sessions.get(message.sessionId) ?? + runtime.sessionsByFile.get(normalizePath(message.sessionId)); + if (!record) { + socket.send( + JSON.stringify({ + type: "server.response", + requestId: message.requestId, + success: false, + error: `Unknown session: ${message.sessionId}`, + } satisfies ServerResponseMessage), + ); + return; + } + try { + const previousSessionId = record.id; + const data = await routeCommand(record, message.command); + if (message.command.type !== "abort") + await refreshManagedSession(record); + const responseData = + record.id !== previousSessionId && + (message.command.type === "clone" || + message.command.type === "fork" || + message.command.type === "create_worktree" || + message.command.type === "create_worktree_v2") + ? { + ...(isRecord(data) ? data : {}), + cancelled: isRecord(data) && data.cancelled === true, + sessionId: record.id, + } + : data; + socket.send( + JSON.stringify({ + type: "server.response", + requestId: message.requestId, + success: true, + data: responseData, + } satisfies ServerResponseMessage), + ); + } catch (error) { + socket.send( + JSON.stringify({ + type: "server.response", + requestId: message.requestId, + success: false, + error: error instanceof Error ? error.message : String(error), + } satisfies ServerResponseMessage), + ); + } + } + } + + return { handleClientMessage }; +} + +export type ClientMessages = ReturnType; diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts new file mode 100644 index 0000000..fbcd9a0 --- /dev/null +++ b/web/server/commandRouter.ts @@ -0,0 +1,646 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import type { + ClientCommandMessage, + RpcSessionCommand, + ServerEventMessage, + ServerSessionMessage, +} from "../protocol.js"; +import { hasActiveWebSubagents } from "../protocol.js"; +import { expandSlashCommand } from "../slash-commands.js"; +import { formatWorktreeCreateCommandArgs } from "../worktree-command.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { GitMetadata } from "./gitMetadata.js"; +import { + CommandDeliveryUncertainError, + isUncertainRpcDeliveryCommand, +} from "./managed-rpc-session.js"; +import type { ManagedSessionRefresh } from "./managedSessionRefresh.js"; +import type { RpcSessionFactory } from "./rpcSessions.js"; +import type { + ExternalPendingRequest, + SessionFileCatalog, + SessionQueueCoordinator, + SessionRecord, +} from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import { LONG_RUNNING_COMMAND_TIMEOUT_MS } from "./serverConfig.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; +import { SlashCommandService } from "./slash-command-service.js"; +import { + hasOtherSessionInWorktree, + managedWorktreeFromEntries, + removeManagedWorktree, +} from "./worktrees.js"; + +/** + * Routes client commands to the right transport: the managed RPC runtime, + * the external agent socket, or an ad-hoc RPC session for saved sessions. + * Also owns the daemon-wide slash command discovery service. + */ +export function createCommandRouter(options: { + config: WebServerConfig; + catalog: SessionFileCatalog; + queue: SessionQueueCoordinator; + registry: SessionRegistry; + broadcast: ClientBroadcast; + refresh: ManagedSessionRefresh; + git: GitMetadata; + rpcSessions: RpcSessionFactory; +}) { + const { catalog, queue, registry, broadcast, refresh, git, rpcSessions } = + options; + const { + normalizePath, + canonicalSessionFile, + isWithinDir, + parseSessionMetadataFile, + parseSessionFile, + deriveForkMessages, + isRecord, + } = catalog; + const { sessionsDir } = options.config; + const { + routeQueueCommand, + markAgentActivity, + cancelQueueSettleFallback, + scheduleQueueSettleFallback, + } = queue; + const { sessionToClientPayload } = registry; + const { broadcast: broadcastToSessionClients } = broadcast; + const { refreshManagedSession } = refresh; + const { hydrateGitMetadata } = git; + const { createRpcSession } = rpcSessions; + + const slashCommands = new SlashCommandService(normalizePath, (cwd) => + createRpcSession({ + cwd, + noSession: true, + onEvent: () => undefined, + onExit: () => undefined, + }), + ); + + async function runRpcSessionCommand( + record: SessionRecord, + command: RpcSessionCommand, + ): Promise { + if (record.managed) { + switch (command.type) { + case "clone": + return await record.managed.clone(); + case "fork": + return await record.managed.fork(command.entryId); + case "get_fork_messages": + return await record.managed.getForkMessages(); + case "set_session_name": + await record.managed.setSessionName(command.name); + return undefined; + case "compact": + return await record.managed.compact(command.customInstructions); + case "bash": + return await record.managed.bash(command.command); + case "extension_ui_response": + return await record.managed.respondToExtensionUi(command); + } + } + if (record.file && isWithinDir(record.file, sessionsDir)) { + const scan = parseSessionMetadataFile(record.file); + if (scan) { + const temp = createRpcSession({ + cwd: scan.session.cwd, + name: scan.session.name, + sessionFile: record.file, + onEvent: (event) => { + if (typeof event === "object" && event && "type" in event) { + broadcastToSessionClients(record.id, { + type: "server.event", + sessionId: record.id, + event: event as Record, + } satisfies ServerEventMessage); + } + }, + onExit: () => undefined, + }); + await temp.start(); + try { + switch (command.type) { + case "clone": + return await temp.clone(); + case "fork": + return await temp.fork(command.entryId); + case "get_fork_messages": { + const response = await temp.getForkMessages(); + return response; + } + case "set_session_name": + await temp.setSessionName(command.name); + return undefined; + case "compact": + return await temp.compact(command.customInstructions); + case "bash": + return await temp.bash(command.command); + case "extension_ui_response": + throw new Error( + "No extension UI request is active for this saved session", + ); + } + } finally { + await temp.shutdown(); + } + } + } + if (command.type === "get_fork_messages") { + return { messages: deriveForkMessages(record.history) }; + } + throw new Error(`Session ${record.id} is not managed`); + } + + async function routeCommand( + record: SessionRecord, + command: ClientCommandMessage["command"], + ): Promise { + const changesManagedIdentity = + Boolean(record.managed) && + (command.type === "clone" || + command.type === "fork" || + command.type === "create_worktree" || + command.type === "create_worktree_v2"); + if (changesManagedIdentity) { + if (record.managedIdentityOperation) + throw new Error( + `Another session replacement is already in progress (${record.managedIdentityOperation})`, + ); + record.managedIdentityOperation = command.type; + try { + return await routeCommandCore(record, command); + } finally { + record.managedIdentityOperation = undefined; + } + } + if (command.type !== "prompt") + return await routeCommandCore(record, command); + + const shouldMarkWorking = + record.status !== "working" || record.agentRunning !== true; + const previousStatus = record.status; + const previousAgentRunning = record.agentRunning; + const previousActivityGeneration = record.activityGeneration; + const agentStartGeneration = record.agentStartGeneration ?? 0; + if (shouldMarkWorking) { + markAgentActivity(record); + cancelQueueSettleFallback(record); + record.status = "working"; + record.agentRunning = true; + record.updatedAt = Date.now(); + broadcastToSessionClients(record.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + } + try { + return await routeCommandCore(record, command); + } catch (error) { + if ( + shouldMarkWorking && + (record.agentStartGeneration ?? 0) === agentStartGeneration + ) { + record.status = previousStatus; + record.agentRunning = previousAgentRunning; + record.activityGeneration = previousActivityGeneration; + broadcastToSessionClients(record.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + if ( + record.status === "idle" && + record.agentRunning !== true && + record.queue.length > 0 + ) { + scheduleQueueSettleFallback(record); + } + } + throw error; + } + } + + async function routeCommandCore( + record: SessionRecord, + command: ClientCommandMessage["command"], + ): Promise { + if ( + command.type === "create_worktree" || + command.type === "create_worktree_v2" + ) { + const value = command as unknown as Record; + if ("existing" in value) { + if (typeof value.existing !== "string" || !value.existing.trim()) + throw new Error("create_worktree existing path is required"); + if ( + ["name", "repository", "branch", "startPoint"].some( + (key) => value[key] !== undefined, + ) + ) { + throw new Error( + "create_worktree existing mode cannot include create-mode fields", + ); + } + } else { + if (typeof value.name !== "string" || !value.name.trim()) + throw new Error("create_worktree name is required"); + if (typeof value.repository !== "string" || !value.repository.trim()) + throw new Error("create_worktree repository is required"); + if ( + value.branch !== undefined && + (typeof value.branch !== "string" || !value.branch.trim()) + ) + throw new Error("create_worktree branch must be a non-empty string"); + if ( + value.startPoint !== undefined && + (typeof value.startPoint !== "string" || !value.startPoint.trim()) + ) + throw new Error( + "create_worktree startPoint must be a non-empty string", + ); + } + } + if ( + command.type === "steer_queue_item" || + command.type === "replace_queue" || + command.type === "reconcile_queue" + ) { + return await routeQueueCommand(record, command); + } + if (command.type === "reload" && record.managed) { + const settled = + (record.status === "idle" || record.status === "error") && + record.agentRunning !== true; + if (!settled || hasActiveWebSubagents(record.subagents)) + throw new Error( + "Wait for Pi and its subagents to become idle before reloading", + ); + await record.managed.reload(); + await refreshManagedSession(record); + record.status = "idle"; + broadcastToSessionClients(record.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + slashCommands.invalidate(record.cwd); + return { reloaded: true }; + } + if (command.type === "reload" && hasActiveWebSubagents(record.subagents)) { + throw new Error( + "Wait for Pi and its subagents to become idle before reloading", + ); + } + if (command.type === "reload" && record.agentSockets.size === 0) { + throw new Error(`Session ${record.id} is not active`); + } + if (command.type === "get_commands") { + if (record.managed) { + const { commands } = await record.managed.getCommands(); + return { + commands: slashCommands.toWeb(slashCommands.parse(commands), true), + }; + } + return { + commands: slashCommands.toWeb(await slashCommands.discover(record.cwd)), + }; + } + if ( + (command.type === "create_worktree" || + command.type === "create_worktree_v2") && + hasActiveWebSubagents(record.subagents) + ) { + throw new Error( + "Wait for Pi and its subagents to become idle before creating a worktree", + ); + } + if ( + (command.type === "create_worktree" || + command.type === "create_worktree_v2") && + record.managed + ) { + if (record.status !== "idle") + throw new Error( + "Wait for Pi to become idle before creating a worktree", + ); + const previousId = record.id; + const previousFile = record.file + ? canonicalSessionFile(record.file) + : undefined; + if (!previousFile) + throw new Error("The current conversation is not persisted yet"); + const previousManagedWorktree = + record.managedWorktree ?? + managedWorktreeFromEntries( + parseSessionFile(previousFile)?.entries ?? [], + ); + record.pendingWorktreeSourceDeletion = { + sessionId: previousId, + sessionFile: previousFile, + }; + const invocation = + "existing" in command + ? `/worktree --existing ${JSON.stringify(command.existing)}` + : `/worktree ${formatWorktreeCreateCommandArgs(command)}`; + try { + await record.managed.worktree(invocation); + await refreshManagedSession(record); + if (record.id === previousId) + throw new Error("Pi did not switch to the worktree session"); + if (existsSync(previousFile)) + throw new Error( + `Source session was not deleted after replacement: ${previousFile}`, + ); + } catch (error) { + if (record.id === previousId) + record.pendingWorktreeSourceDeletion = undefined; + throw error; + } + if ( + previousManagedWorktree && + !hasOtherSessionInWorktree( + sessionsDir, + previousFile, + previousManagedWorktree.path, + ) + ) { + try { + const cleanup = removeManagedWorktree(previousManagedWorktree); + if (cleanup.branchWarning) + console.warn( + `Removed previous worktree ${previousManagedWorktree.path}, but could not delete branch ${previousManagedWorktree.branch}: ${cleanup.branchWarning}`, + ); + } catch (error) { + console.warn( + `Source session was deleted, but previous managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + await hydrateGitMetadata(record); + return { + cancelled: false, + sessionId: record.id, + path: record.cwd, + branch: record.branch, + }; + } + if ( + (command.type === "create_worktree" || + command.type === "create_worktree_v2") && + record.agentSockets.size === 0 + ) { + throw new Error(`Session ${record.id} is not active`); + } + if (record.managed) { + switch (command.type) { + case "get_session_options": { + const [{ models }, { levels }, { commands }] = await Promise.all([ + record.managed.getAvailableModels(), + record.managed.getAvailableThinkingLevels(), + record.managed.getCommands(), + ]); + const webCommands = commands.flatMap((command) => { + const sourceInfo = isRecord(command.sourceInfo) + ? command.sourceInfo + : undefined; + if ( + typeof command.name !== "string" || + command.name === "web-reload" || + (command.source !== "extension" && + command.source !== "prompt" && + command.source !== "skill") + ) + return []; + return [ + { + name: command.name, + description: + typeof command.description === "string" + ? command.description + : undefined, + source: command.source, + location: + sourceInfo && + (sourceInfo.scope === "user" || + sourceInfo.scope === "project" || + sourceInfo.scope === "temporary") + ? sourceInfo.scope + : undefined, + }, + ]; + }); + if (!webCommands.some((command) => command.name === "reload")) { + webCommands.unshift({ + name: "reload", + description: + "Reload extensions, skills, prompts, themes, and context files", + source: "extension", + location: "temporary", + }); + } + const scoped = record.scopedModels ?? []; + const scopedByKey = new Map( + scoped.map((s) => [`${s.provider}/${s.id}`, s]), + ); + const filterByScope = scopedByKey.size > 0; + return { + models: models + .filter((model) => + filterByScope + ? scopedByKey.has( + `${String(model.provider ?? "")}/${String(model.id ?? "")}`, + ) + : true, + ) + .map((model) => ({ + provider: String(model.provider ?? ""), + id: String(model.id ?? ""), + name: String(model.name ?? model.id ?? ""), + reasoning: model.reasoning === true, + thinkingLevels: levels, + })), + thinkingLevels: levels, + commands: webCommands, + }; + } + case "set_model": + await record.managed.setModel(command.provider, command.modelId); + await refreshManagedSession(record); + return; + case "set_thinking_level": + await record.managed.setThinkingLevel(command.level); + await refreshManagedSession(record); + return; + case "prompt": + return await record.managed.prompt( + command.message, + command.streamingBehavior, + command.images, + ); + case "abort": + // Stop is accepted once its RPC request is written. Do not hold the web + // response open while compaction and subagent teardown finish. + await record.managed.abort(); + return { accepted: true }; + case "bash": + return await record.managed.bash(command.command); + case "clone": { + const result = await record.managed.clone(); + await refreshManagedSession(record); + return result; + } + case "fork": { + const result = await record.managed.fork(command.entryId); + await refreshManagedSession(record); + return result; + } + case "get_fork_messages": + return await record.managed.getForkMessages(); + case "set_session_name": + await record.managed.setSessionName(command.name); + return undefined; + case "compact": + return await record.managed.compact(command.customInstructions); + case "extension_ui_response": + return await record.managed.respondToExtensionUi(command); + } + } + if (record.agentSockets.size > 0) { + let externalCommand: ClientCommandMessage["command"] = command; + if ( + command.type === "create_worktree" && + !("existing" in command) && + (command.branch || command.startPoint) + ) { + externalCommand = { ...command, type: "create_worktree_v2" }; + } + if (command.type === "prompt" && command.message.startsWith("/")) { + const commands = await slashCommands.discover(record.cwd); + externalCommand = { + ...command, + message: await expandSlashCommand(commands, command.message, { + rejectExtensionCommands: true, + }), + }; + } + const target = Array.from(record.agentSockets)[0]; + const requestId = randomUUID(); + if (command.type === "abort") { + // Socket delivery is the acknowledgement boundary. New bridges also reply + // before teardown, but this keeps Stop responsive with older bridges. + target.send( + JSON.stringify({ + type: "agent.command", + requestId, + command: externalCommand, + } satisfies { + type: "agent.command"; + requestId: string; + command: ClientCommandMessage["command"]; + }), + ); + return { accepted: true }; + } + const data = await new Promise((resolve, reject) => { + const timeoutMs = + command.type === "compact" || + command.type === "bash" || + command.type === "create_worktree" || + command.type === "create_worktree_v2" || + command.type === "reload" + ? LONG_RUNNING_COMMAND_TIMEOUT_MS + : 30_000; + let pendingRequest: ExternalPendingRequest; + const timeout = setTimeout(() => { + const owner = pendingRequest.owner ?? record; + owner.externalPending.delete(requestId); + owner.externalRequestTargets.delete(requestId); + const message = "Pi session command timed out"; + reject( + isUncertainRpcDeliveryCommand(command.type) + ? new CommandDeliveryUncertainError(message) + : new Error(message), + ); + }, timeoutMs); + pendingRequest = { + owner: record, + surviveDisconnect: + command.type === "reload" || + command.type === "create_worktree" || + command.type === "create_worktree_v2", + commandType: command.type, + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }; + record.externalPending.set(requestId, pendingRequest); + record.externalRequestTargets.set(requestId, target); + try { + target.send( + JSON.stringify({ + type: "agent.command", + requestId, + command: externalCommand, + } satisfies { + type: "agent.command"; + requestId: string; + command: ClientCommandMessage["command"]; + }), + ); + } catch (error) { + record.externalPending.delete(requestId); + record.externalRequestTargets.delete(requestId); + clearTimeout(timeout); + const cause = + error instanceof Error ? error : new Error(String(error)); + reject( + isUncertainRpcDeliveryCommand(command.type) + ? new CommandDeliveryUncertainError(cause.message) + : cause, + ); + } + }); + if (command.type === "get_session_options") { + const options = isRecord(data) ? data : {}; + if (Array.isArray(options.commands)) return options; + return { + ...options, + commands: slashCommands.toWeb( + await slashCommands.discover(record.cwd), + ), + }; + } + if (command.type === "reload") slashCommands.invalidate(record.cwd); + return data; + } + if (command.type === "get_fork_messages") + return await runRpcSessionCommand(record, command); + if ( + command.type === "clone" || + command.type === "fork" || + command.type === "set_session_name" || + command.type === "compact" + ) { + if (!record.file) throw new Error(`Session ${record.id} is not active`); + return await runRpcSessionCommand(record, command as RpcSessionCommand); + } + throw new Error( + `Session ${record.id} does not support command ${command.type}`, + ); + } + + return { + routeCommand, + slashCommands, + }; +} + +export type CommandRouter = ReturnType; diff --git a/web/server/compactionNotice.ts b/web/server/compactionNotice.ts new file mode 100644 index 0000000..7426509 --- /dev/null +++ b/web/server/compactionNotice.ts @@ -0,0 +1,28 @@ +import type { SessionRecord } from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; + +/** + * A managed bridge can replace compacted history after the compaction_end + * event settles. Wait for that trailing snapshot so the completion notice is + * not immediately wiped by the authoritative history replacement. + */ +export function createCompactionNotice(options: { + state: ServerRuntimeState; + broadcastCompactionComplete: (record: SessionRecord) => void; +}) { + const { state: runtime, broadcastCompactionComplete } = options; + + function broadcastCompactionNotice(record: SessionRecord): void { + const refresh = record.compactionHistoryRefresh; + const deliver = () => { + if (runtime.sessions.get(record.id) !== record) return; + broadcastCompactionComplete(record); + }; + if (refresh) void refresh.finally(deliver); + else deliver(); + } + + return { broadcastCompactionNotice }; +} + +export type CompactionNotice = ReturnType; diff --git a/web/server/daemonOwnership.ts b/web/server/daemonOwnership.ts new file mode 100644 index 0000000..ae7e5b6 --- /dev/null +++ b/web/server/daemonOwnership.ts @@ -0,0 +1,99 @@ +import type { DaemonHealth } from "./daemon-process.js"; +import { + incumbentDisposition, + isPidAlive, + isPiWebDaemonPid, + terminatePiWebDaemon, + waitForDaemonHealth, +} from "./daemon-process.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import { readStateFile } from "./stateFileStore.js"; + +/** + * Decide whether this process may own machine-wide discovery (state file and + * Tailscale Serve route). A healthy daemon that already exists is deferred to; + * a daemon whose checkout no longer exists is replaced. Without this, a + * side-port daemon steals the shared Serve route and a daemon from a deleted + * checkout serves 404s forever while still passing /api/health. + */ +export type DaemonOwnership = + | { action: "own" } + | { action: "abort"; reason: string } + | { action: "defer"; owner: DaemonHealth; republished: boolean }; + +export function createDaemonOwnership(options: { + config: WebServerConfig; + state: ServerRuntimeState; +}) { + const { config, state: runtime } = options; + + async function ownershipForIncumbent( + health: DaemonHealth, + republished: boolean, + ): Promise { + switch (incumbentDisposition(health)) { + case "serve": + case "keep": + // Defer to the incumbent (keep = failed build over an existing + // checkout; respawning from the same checkout would fail identically). + return { action: "defer", owner: health, republished }; + case "evict": { + const stopped = await terminatePiWebDaemon(health.pid); + if (stopped) return { action: "own" }; + // The incumbent still holds the port; never race it for the listener. + return { + action: "abort", + reason: `pid ${health.pid} still holds port ${health.port} and could not be stopped`, + }; + } + } + } + + async function resolveDaemonOwnership(): Promise { + const existing = readStateFile(config.stateFilePath); + if (existing && existing.pid !== process.pid && isPidAlive(existing.pid)) { + // Only a verified Pi web daemon may be probed or stopped; a recycled pid + // naming an unrelated process must never be signaled. + if (await isPiWebDaemonPid(existing.pid)) { + const health = await waitForDaemonHealth( + existing.port, + existing.pid, + 3_000, + ); + if (!health) { + // Verified daemon that never answers: wedged. Replace it, but only + // claim ownership when it actually stopped; otherwise another process + // still holds the port and Bun.serve would fail to listen. + if (await terminatePiWebDaemon(existing.pid)) + return { action: "own" }; + return { + action: "abort", + reason: `pid ${existing.pid} still holds port ${existing.port} and could not be stopped`, + }; + } + // The state file already names the incumbent, so it needs no rewrite. + return ownershipForIncumbent(health, false); + } + return { action: "own" }; + } + // Discovery state is missing or points at a dead pid. When the intended + // port already has a healthy daemon, adopt it by restoring its state file + // instead of racing it for the port; evict it only when provably broken. + if (runtime.port !== 0) { + const health = await waitForDaemonHealth(runtime.port, undefined, 1_500); + if ( + health && + isPidAlive(health.pid) && + (await isPiWebDaemonPid(health.pid)) + ) { + return ownershipForIncumbent(health, true); + } + } + return { action: "own" }; + } + + return { resolveDaemonOwnership }; +} + +export type DaemonOwnershipResolver = ReturnType; diff --git a/web/server/discoveryState.ts b/web/server/discoveryState.ts new file mode 100644 index 0000000..4348548 --- /dev/null +++ b/web/server/discoveryState.ts @@ -0,0 +1,79 @@ +import type { ServerStateFile } from "../protocol.js"; +import { + disableTailscaleServe, + ensureTailscaleServe, + readTailscaleWebSettings, + replaceTailscaleServe, + type TailscaleStatus, + type TailscaleWebSettings, +} from "../tailscale.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import { writeStateFileAtomic } from "./stateFileStore.js"; + +/** + * Owns the published discovery state (`webState`) and the Tailscale Serve + * route mirrored into it. `webState` is initialized by startup once this + * process owns machine-wide discovery, exactly like the original server. + */ +export function createDiscoveryState(options: { + config: WebServerConfig; + state: ServerRuntimeState; +}) { + const { config, state: runtime } = options; + const { stateFilePath, settingsPath } = config; + + function publishTailscaleStatus(status: TailscaleStatus): void { + runtime.tailscaleStatus = status; + runtime.webState = { ...runtime.webState, tailscale: status }; + writeStateFileAtomic(stateFilePath, runtime.webState); + } + + async function configureTailscaleServe( + settings?: TailscaleWebSettings, + currentSettings?: TailscaleWebSettings, + ): Promise { + const resolvedSettings = + settings ?? (await readTailscaleWebSettings(settingsPath)); + const status = currentSettings + ? await replaceTailscaleServe({ + currentSettings, + nextSettings: resolvedSettings, + localPort: runtime.port, + }) + : await ensureTailscaleServe({ + settings: resolvedSettings, + localPort: runtime.port, + }); + publishTailscaleStatus(status); + return status; + } + + async function removeTailscaleServe( + settings: TailscaleWebSettings, + ): Promise { + const status = await disableTailscaleServe({ + settings, + localPort: runtime.port, + }); + publishTailscaleStatus(status); + return status; + } + + return { + configureTailscaleServe, + removeTailscaleServe, + publishTailscaleStatus, + getWebState(): ServerStateFile { + return runtime.webState; + }, + setWebState(next: ServerStateFile): void { + runtime.webState = next; + }, + getTailscaleStatus(): TailscaleStatus { + return runtime.tailscaleStatus; + }, + }; +} + +export type DiscoveryState = ReturnType; diff --git a/web/server/gitMetadata.ts b/web/server/gitMetadata.ts new file mode 100644 index 0000000..073a844 --- /dev/null +++ b/web/server/gitMetadata.ts @@ -0,0 +1,94 @@ +import type { WebSession } from "../protocol.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; + +/** Best-effort branch and PR metadata hydration via `git`/`gh`. */ +export function createGitMetadata(options: { + state: ServerRuntimeState; + catalog: SessionFileCatalog; + broadcast: ClientBroadcast; +}) { + const { state: runtime, catalog, broadcast } = options; + const { isRecord } = catalog; + + async function commandOutput( + command: string[], + cwd: string, + timeoutMs = 10_000, + ): Promise { + let process: Bun.Subprocess | undefined; + try { + process = Bun.spawn({ + cmd: command, + cwd, + stdout: "pipe", + stderr: "ignore", + }); + const stdout = process.stdout; + if (!stdout || typeof stdout === "number") return undefined; + const timeout = setTimeout(() => process?.kill(), timeoutMs); + try { + const [output, code] = await Promise.all([ + new Response(stdout as ReadableStream).text(), + process.exited, + ]); + return code === 0 ? output.trim() : undefined; + } finally { + clearTimeout(timeout); + } + } catch { + return undefined; + } + } + + async function hydrateGitMetadata(record: SessionRecord): Promise { + const generation = (record.gitMetadataGeneration ?? 0) + 1; + record.gitMetadataGeneration = generation; + const cwd = record.cwd; + const branch = await commandOutput( + ["git", "branch", "--show-current"], + cwd, + ); + const raw = await commandOutput( + ["gh", "pr", "view", "--json", "number,url"], + cwd, + ); + let pullRequest: WebSession["pullRequest"]; + if (raw) { + try { + const value: unknown = JSON.parse(raw); + if ( + isRecord(value) && + Number.isInteger(value.number) && + typeof value.url === "string" + ) { + const url = new URL(value.url); + if (url.protocol === "https:" || url.protocol === "http:") { + pullRequest = { + number: value.number as number, + url: url.toString(), + }; + } + } + } catch { + // A branch without an open PR is expected. + } + } + if ( + record.gitMetadataGeneration !== generation || + record.cwd !== cwd || + runtime.sessions.get(record.id) !== record + ) + return; + if (branch) record.branch = branch; + // A failed/no-match `gh pr view` is authoritative for the current branch and + // must clear a PR cached before the TUI changed branches. + record.pullRequest = pullRequest; + broadcast.broadcastSessionToAll(record); + } + + return { hydrateGitMetadata }; +} + +export type GitMetadata = ReturnType; diff --git a/web/server/httpApi.ts b/web/server/httpApi.ts new file mode 100644 index 0000000..90230c4 --- /dev/null +++ b/web/server/httpApi.ts @@ -0,0 +1,416 @@ +import { statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import type { + CreateSessionRequest, + ResumeSessionRequest, + WebSession, +} from "../protocol.js"; +import type { TailscaleWebSettings } from "../tailscale.js"; +import { readTailscaleWebSettings } from "../tailscale.js"; +import type { DiscoveryState } from "./discoveryState.js"; +import { + badRequest, + isTrustedBrowserOrigin, + jsonResponse, + notFound, + textResponse, +} from "./http-utils.js"; +import type { ManagedSessionLauncher } from "./managedSessionCreate.js"; +import type { MissingSessions } from "./missingSessions.js"; +import { resolveWebCwd } from "./paths.js"; +import { resolveSessionProject } from "./projects.js"; +import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { SessionDeletion } from "./sessionDeletion.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; +import { listDirectorySuggestions } from "./suggestions.js"; +import { + createWebWorktree, + inheritManagedBranchOwnership, + listRepositoryBranches, + type RepositoryBranches, + WORKTREE_SESSION_ENTRY, +} from "./worktrees.js"; + +/** REST API surface under `/api/*`. */ +export function createHttpApi(options: { + config: WebServerConfig; + state: ServerRuntimeState; + discovery: DiscoveryState; + catalog: SessionFileCatalog; + registry: SessionRegistry; + missingSessions: MissingSessions; + launcher: ManagedSessionLauncher; + deletion: SessionDeletion; +}) { + const { + config, + state: runtime, + discovery, + catalog, + registry, + missingSessions, + launcher, + deletion, + } = options; + const { rootDir, distDir, settingsPath } = config; + const { sessionsDir } = config; + const { + normalizePath, + canonicalSessionFile, + persistInitialSession, + parseSessionMetadataFile, + scanSavedSessions, + isRecord, + } = catalog; + const { + sessionToClientPayload, + sortSessions, + upsertSession, + activeSessionFiles, + } = registry; + const { isMissingInactiveSession } = missingSessions; + const { configureTailscaleServe, removeTailscaleServe, getTailscaleStatus } = + discovery; + const { createManagedSession } = launcher; + const { + deleteSession, + reconcileMissingSessionFiles, + cleanupInitialSessionFile, + } = deletion; + + function webAssetsServable(): boolean { + try { + return statSync(join(distDir, "index.html")).isFile(); + } catch { + return false; + } + } + + async function handleApi(request: Request): Promise { + const url = new URL(request.url); + const tailscaleStatus = getTailscaleStatus(); + if ( + url.pathname.startsWith("/api/") && + request.method !== "GET" && + request.method !== "HEAD" && + request.method !== "OPTIONS" && + !isTrustedBrowserOrigin( + request, + tailscaleStatus.published ? tailscaleStatus.url : undefined, + ) + ) { + return textResponse("Forbidden origin", { status: 403 }); + } + if (request.method === "GET" && url.pathname === "/api/health") { + return jsonResponse({ + ok: true, + pid: process.pid, + port: runtime.port, + stateFile: config.stateFilePath, + capabilities: { + commandHello: true, + queueSteer: true, + worktreeRefs: true, + branchSuggestions: true, + }, + assets: webAssetsServable(), + root: rootDir, + tailscale: tailscaleStatus, + }); + } + if (request.method === "GET" && url.pathname === "/api/directories") { + return jsonResponse({ + directories: listDirectorySuggestions(url.searchParams.get("q") ?? "", { + baseDir: rootDir, + }), + }); + } + if (request.method === "GET" && url.pathname === "/api/branches") { + const requestedCwd = (url.searchParams.get("cwd") ?? "").trim(); + if (!requestedCwd) return badRequest("Missing cwd"); + let cwd: string; + try { + cwd = resolveWebCwd(requestedCwd, { baseDir: rootDir }); + } catch (error) { + return badRequest( + error instanceof Error ? error.message : String(error), + ); + } + try { + if (!statSync(cwd).isDirectory()) + return badRequest(`cwd is not a directory: ${cwd}`); + } catch { + return badRequest(`cwd does not exist: ${cwd}`); + } + let branches: RepositoryBranches; + try { + branches = listRepositoryBranches(cwd); + } catch (error) { + // Not a Git repository (or Git is unavailable): the browser just shows + // no suggestions instead of surfacing an error mid-typing. + return jsonResponse({ + local: [], + remote: [], + error: error instanceof Error ? error.message : String(error), + }); + } + return jsonResponse(branches); + } + if (request.method === "POST" && url.pathname === "/api/tailscale") { + const body = (await request.json().catch(() => undefined)) as + | { + enabled?: unknown; + httpsPort?: unknown; + serviceName?: unknown; + current?: unknown; + } + | undefined; + if (!body || typeof body.enabled !== "boolean") + return badRequest("Missing enabled boolean"); + const persisted = await readTailscaleWebSettings(settingsPath); + const suppliedCurrent = isRecord(body.current) ? body.current : undefined; + const current: TailscaleWebSettings = + suppliedCurrent && typeof suppliedCurrent.enabled === "boolean" + ? { + enabled: suppliedCurrent.enabled, + httpsPort: + typeof suppliedCurrent.httpsPort === "number" && + Number.isInteger(suppliedCurrent.httpsPort) && + suppliedCurrent.httpsPort >= 1 && + suppliedCurrent.httpsPort <= 65_535 + ? suppliedCurrent.httpsPort + : persisted.httpsPort, + // The persisted route identity is authoritative; a browser may report + // the previously applied port, but cannot select another Service to remove. + serviceName: persisted.serviceName, + } + : persisted; + const settings: TailscaleWebSettings = { + enabled: body.enabled, + httpsPort: + typeof body.httpsPort === "number" && + Number.isInteger(body.httpsPort) && + body.httpsPort >= 1 && + body.httpsPort <= 65_535 + ? body.httpsPort + : persisted.httpsPort, + serviceName: + typeof body.serviceName === "string" + ? body.serviceName.trim().replace(/^svc:/, "") || undefined + : persisted.serviceName, + }; + const status = settings.enabled + ? await configureTailscaleServe(settings, current) + : await removeTailscaleServe(current); + return jsonResponse({ tailscale: status }); + } + if (request.method === "GET" && url.pathname === "/api/sessions") { + await reconcileMissingSessionFiles(); + const scans = scanSavedSessions(sessionsDir, activeSessionFiles()); + for (const scan of scans) { + const existing = + runtime.sessions.get(scan.session.id) ?? + runtime.sessionsByFile.get(normalizePath(scan.file)); + if (!existing || !existing.active || existing.status === "offline") { + upsertSession( + scan.session, + "saved", + scan.history, + scan.managedWorktreeScanned, + ); + } + } + const merged = new Map(); + for (const scan of scans) { + const live = + runtime.sessions.get(scan.session.id) ?? + runtime.sessionsByFile.get(normalizePath(scan.file)); + if (live?.catalogReady === false) continue; + merged.set( + scan.session.file + ? normalizePath(scan.session.file) + : scan.session.id, + sessionToClientPayload(scan.session), + ); + } + for (const item of runtime.sessions.values()) { + if (item.catalogReady === false || isMissingInactiveSession(item)) + continue; + merged.set( + item.file ? normalizePath(item.file) : item.id, + sessionToClientPayload(item), + ); + } + return jsonResponse({ + sessions: sortSessions(Array.from(merged.values())), + }); + } + if (request.method === "POST" && url.pathname === "/api/sessions") { + const body = (await request.json().catch(() => undefined)) as + | CreateSessionRequest + | undefined; + if (!body) return badRequest("Missing session request"); + const requestedCwd = typeof body.cwd === "string" ? body.cwd.trim() : ""; + const worktreeName = + typeof body.worktreeName === "string" ? body.worktreeName.trim() : ""; + const worktreeBranch = + typeof body.worktreeBranch === "string" + ? body.worktreeBranch.trim() + : ""; + const worktreeStartPoint = + typeof body.worktreeStartPoint === "string" + ? body.worktreeStartPoint.trim() + : ""; + if (!requestedCwd) return badRequest("Specify a repository or directory"); + if (!worktreeName && (worktreeBranch || worktreeStartPoint)) + return badRequest( + "worktreeBranch and worktreeStartPoint require worktreeName", + ); + + let cwd: string; + try { + cwd = resolveWebCwd(requestedCwd, { baseDir: rootDir }); + } catch (error) { + return badRequest( + error instanceof Error ? error.message : String(error), + ); + } + try { + if (!statSync(cwd).isDirectory()) + return badRequest(`cwd is not a directory: ${cwd}`); + } catch { + return badRequest(`cwd does not exist: ${cwd}`); + } + + let worktree: Awaited> | undefined; + if (worktreeName) { + if (!resolveSessionProject(cwd).id.startsWith("git:")) + return badRequest("Worktree repository is not a Git repository"); + try { + worktree = await createWebWorktree(cwd, worktreeName, { + branch: worktreeBranch || undefined, + startPoint: worktreeStartPoint || undefined, + }); + if (!worktree.existingCheckout) { + worktree = inheritManagedBranchOwnership( + worktree, + [...runtime.sessions.values()].map( + (candidate) => candidate.managedWorktree, + ), + ); + } + cwd = worktree.path; + } catch (error) { + return badRequest( + error instanceof Error ? error.message : String(error), + ); + } + } + let session: SessionRecord; + let initialSessionFile: string | undefined; + try { + const manager = SessionManager.create(cwd); + if (worktree) { + manager.appendCustomEntry( + WORKTREE_SESSION_ENTRY, + worktree.existingCheckout + ? { managed: false } + : { + path: worktree.path, + repoRoot: worktree.repoRoot, + name: worktree.name, + branch: worktree.branch, + branchCreated: worktree.branchCreated, + }, + ); + } + if (body.name?.trim()) manager.appendSessionInfo(body.name.trim()); + initialSessionFile = persistInitialSession(manager); + session = await createManagedSession( + cwd, + body.name, + initialSessionFile, + ); + if (worktree && !worktree.existingCheckout) + session.managedWorktree = worktree; + } catch (error) { + const startupMessage = + error instanceof Error ? error.message : String(error); + if (worktree) { + // An entered pre-existing checkout was not created here; clean up the + // stale initial session instead of retaining it for inspection. + if (worktree.existingCheckout) { + cleanupInitialSessionFile(initialSessionFile); + throw new Error(startupMessage); + } + throw new Error( + `${startupMessage}; initialized worktree retained at ${worktree.path} for inspection`, + ); + } + cleanupInitialSessionFile(initialSessionFile); + throw error; + } + return jsonResponse( + { session: sessionToClientPayload(session), worktree }, + { status: 201 }, + ); + } + if (request.method === "POST" && url.pathname === "/api/sessions/resume") { + const body = (await request.json().catch(() => undefined)) as + | ResumeSessionRequest + | undefined; + if (!body || typeof body.file !== "string" || !body.file.trim()) + return badRequest("Missing file"); + let file: string; + try { + file = canonicalSessionFile(resolve(rootDir, body.file)); + } catch (error) { + return badRequest( + error instanceof Error + ? error.message + : "Session file does not exist", + ); + } + const scan = parseSessionMetadataFile(file); + if (!scan) return badRequest("Invalid session file"); + const existing = runtime.sessionsByFile.get(normalizePath(file)); + if (existing?.active && existing.status !== "offline") { + return jsonResponse( + { error: "Session is already active" }, + { status: 409 }, + ); + } + const session = await createManagedSession( + scan.session.cwd, + scan.session.name, + file, + ); + return jsonResponse( + { session: sessionToClientPayload(session) }, + { status: 201 }, + ); + } + if (request.method === "DELETE") { + const pathname = url.pathname; + const match = /^\/api\/sessions\/([^/]+)$/.exec(pathname); + if (!match) return notFound(); + try { + await deleteSession(decodeURIComponent(match[1])); + return jsonResponse({ ok: true }); + } catch (error) { + return jsonResponse( + { error: error instanceof Error ? error.message : String(error) }, + { status: 400 }, + ); + } + } + return notFound(); + } + + return { handleApi, webAssetsServable }; +} + +export type HttpApi = ReturnType; diff --git a/web/server/index.ts b/web/server/index.ts index b4f9625..5e54336 100644 --- a/web/server/index.ts +++ b/web/server/index.ts @@ -1,3945 +1,13 @@ -import { randomUUID } from "node:crypto"; -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - renameSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { basename, dirname, isAbsolute, join, resolve } from "node:path"; -import { - buildContextEntries, - type SessionEntry, - SessionManager, -} from "@earendil-works/pi-coding-agent"; -import { - agentEndTerminalNotice, - assistantTerminalNotice, -} from "../assistant-message.js"; -import { parseWebCompactCommand } from "../compact-command.js"; -import { - boundedWebHistory, - messagesToWebHistory, - WEB_HISTORY_MAX_BYTES, - WEB_HISTORY_MAX_ENTRIES, - webHistoryByteLength, -} from "../history.js"; -import type { - AgentEventMessage, - AgentHelloMessage, - AgentHistoryMessage, - AgentResponseMessage, - AgentScopeMessage, - AgentSessionReplacedMessage, - AgentSubagentsMessage, - AgentToServerMessage, - AgentUpdateMessage, - ClientCommandMessage, - ClientToServerMessage, - CreateSessionRequest, - ResumeSessionRequest, - RpcSessionCommand, - ServerEventMessage, - ServerHistoryMessage, - ServerResponseMessage, - ServerSessionMessage, - ServerSessionRemovedMessage, - ServerSnapshotMessage, - ServerStateFile, - ServerToClientMessage, - WebSession, -} from "../protocol.js"; -import { - compareWebSessions, - DEFAULT_WEB_PORT, - hasActiveWebSubagents, - mergeWebSubagentUpdates, - WEB_STATE_VERSION, -} from "../protocol.js"; -import { isWebReloadCommand } from "../reload-command.js"; -import { expandSlashCommand } from "../slash-commands.js"; -import { - disableTailscaleServe, - ensureTailscaleServe, - readTailscaleWebSettings, - replaceTailscaleServe, - type TailscaleStatus, - type TailscaleWebSettings, -} from "../tailscale.js"; -import { - formatWorktreeCreateCommandArgs, - parseWorktreeInvocation, - WORKTREE_USAGE, -} from "../worktree-command.js"; -import { replacementFromEntries } from "../worktree-replacement.js"; -import { - type DaemonHealth, - incumbentDisposition, - isPidAlive, - isPiWebDaemonPid, - terminatePiWebDaemon, - waitForDaemonHealth, -} from "./daemon-process.js"; -import { isConfirmedMissingPath } from "./file-presence.js"; -import { - badRequest, - internalError, - isTrustedBrowserOrigin, - jsonResponse, - notFound, - textResponse, -} from "./http-utils.js"; -import { - CommandDeliveryUncertainError, - CommandRejectedError, - isUncertainRpcDeliveryCommand, - ManagedRpcSession, - type ManagedRpcSessionOptions, -} from "./managed-rpc-session.js"; -import { ManagedSessionStore } from "./managed-session-store.js"; -import { resolveWebCwd } from "./paths.js"; -import { resolveSessionProject } from "./projects.js"; -import { - preserveRetryAroundQuiescence, - quiesceQueueMutations, -} from "./queue-mutation.js"; -import { CoalescedQueueStoreWriter, readQueueStore } from "./queue-store.js"; -import { - runManagedRefresh, - serializeManagedRefresh, -} from "./refresh-policy.js"; -import type { - AgentSocketData, - ClientSocketData, - ExternalPendingRequest, - SessionKind, - SessionRecord, - SocketData, -} from "./server-types.js"; -import { createSessionFileCatalog } from "./session-file-catalog.js"; -import { normalizeLegacySessionUpdate } from "./session-lifecycle.js"; -import { createSessionQueueCoordinator } from "./session-queue-coordinator.js"; -import { - shouldContinueManagedShutdownWait, - shouldRejectDuringShutdown, - shouldWaitForManagedShutdown, -} from "./shutdown-policy.js"; -import { SlashCommandService } from "./slash-command-service.js"; -import { createStaticAssetResponder } from "./static-assets.js"; -import { listDirectorySuggestions } from "./suggestions.js"; -import { - createWebWorktree, - hasOtherSessionInWorktree, - inheritManagedBranchOwnership, - listRepositoryBranches, - managedWorktreeFromEntries, - type RepositoryBranches, - removeManagedWorktree, - removeManagedWorktreeAsync, - WORKTREE_SESSION_ENTRY, -} from "./worktrees.js"; - -const decoder = new TextDecoder(); -const rootDir = resolve( - process.env.PI_WEB_ROOT - ? isAbsolute(process.env.PI_WEB_ROOT) - ? process.env.PI_WEB_ROOT - : join(process.cwd(), process.env.PI_WEB_ROOT) - : process.cwd(), -); -const distDir = join(rootDir, "web", "dist"); -const staticAssetResponse = createStaticAssetResponder(distDir); -const stateFilePath = resolve( - process.env.PI_WEB_STATE_FILE - ? isAbsolute(process.env.PI_WEB_STATE_FILE) - ? process.env.PI_WEB_STATE_FILE - : join(process.cwd(), process.env.PI_WEB_STATE_FILE) - : join(homedir(), ".pi", "agent", "web", "server.json"), -); -const agentDir = resolve( - process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), -); -const settingsPath = join(agentDir, "settings.json"); -const sessionsDir = join(agentDir, "sessions"); -const queueStorePath = join(dirname(stateFilePath), "queues.json"); -const managedSessionStorePath = join( - dirname(stateFilePath), - "managed-sessions.json", -); -const persistedQueues = readQueueStore(queueStorePath); -const queueStoreWriter = new CoalescedQueueStoreWriter(queueStorePath); -const managedSessionStore = new ManagedSessionStore(managedSessionStorePath); -const configuredPort = Number(process.env.PI_WEB_PORT ?? `${DEFAULT_WEB_PORT}`); -let port = - Number.isInteger(configuredPort) && - configuredPort >= 0 && - configuredPort <= 65_535 - ? configuredPort - : DEFAULT_WEB_PORT; -const host = "127.0.0.1"; -const LONG_RUNNING_COMMAND_TIMEOUT_MS = 10 * 60_000; -const MISSING_SESSION_RECONCILE_INTERVAL_MS = 1_000; -// Accept one legacy agent.hello containing a large session until running Pi -// processes reload the bridge that sends metadata-only hello frames. -const MAX_WEBSOCKET_PAYLOAD_BYTES = 32 * 1024 * 1024; -// Preserve at least one complete addon-sized image in reconnect snapshots. - -const sessions = new Map(); -const sessionsByFile = new Map(); -const connectedClientSockets = new Set>(); -const managedSessionStarts = new Map>(); -const missingSessionReconciliations = new Set(); -let server: Bun.Server | undefined; -let missingSessionReconcileTimer: ReturnType | undefined; -let shutdownStarted = false; -let webState: ServerStateFile; -let tailscaleStatus: TailscaleStatus = { - installed: false, - enabled: false, - available: false, - published: false, -}; - -function ensureDir(path: string): void { - mkdirSync(path, { recursive: true }); -} - -function readStateFile(path: string): ServerStateFile | undefined { - try { - const raw = readFileSync(path, "utf8"); - const parsed = JSON.parse(raw) as Partial; - if ( - parsed && - parsed.version === WEB_STATE_VERSION && - typeof parsed.pid === "number" && - typeof parsed.port === "number" && - typeof parsed.startedAt === "number" - ) { - return parsed as ServerStateFile; - } - } catch { - // ignore - } - return undefined; -} - -function writeStateFileAtomic(path: string, state: ServerStateFile): void { - ensureDir(dirname(path)); - const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { - mode: 0o600, - }); - renameSync(tempPath, path); -} - -function publishTailscaleStatus(status: TailscaleStatus): void { - tailscaleStatus = status; - webState = { ...webState, tailscale: status }; - writeStateFileAtomic(stateFilePath, webState); -} - -async function configureTailscaleServe( - settings?: TailscaleWebSettings, - currentSettings?: TailscaleWebSettings, -): Promise { - const resolvedSettings = - settings ?? (await readTailscaleWebSettings(settingsPath)); - const status = currentSettings - ? await replaceTailscaleServe({ - currentSettings, - nextSettings: resolvedSettings, - localPort: port, - }) - : await ensureTailscaleServe({ - settings: resolvedSettings, - localPort: port, - }); - publishTailscaleStatus(status); - return status; -} - -async function removeTailscaleServe( - settings: TailscaleWebSettings, -): Promise { - const status = await disableTailscaleServe({ settings, localPort: port }); - publishTailscaleStatus(status); - return status; -} - -function getOrCreateWebState(): ServerStateFile { - return { - pid: process.pid, - port, - startedAt: Date.now(), - version: WEB_STATE_VERSION, - }; -} - -const { - normalizePath, - sessionFileKey, - isManagedSessionFile, - replaceManagedSessionFile, - deleteManagedSessionFile, - isWithinDir, - canonicalSessionFile, - isRecord, - persistInitialSession, - toNumber, - zeroWebUsage, - addWebUsage, - extractTextContent, - compactionEntryFromEvent, - extractPreviewFromHistory, - readManagedWorktreePrefix, - parseSessionFile, - parseSessionMetadataFile, - parseSessionHistoryFile, - listSavedSessionFiles, - scanSavedSessions, - deriveForkMessages, -} = createSessionFileCatalog({ sessionsDir, managedSessionStore }); - -function createRpcSession( - options: Omit< - ManagedRpcSessionOptions, - "runtimeDirectory" | "replacementForSessionFile" - >, -): ManagedRpcSession { - return new ManagedRpcSession({ - ...options, - runtimeDirectory: dirname(stateFilePath), - replacementForSessionFile: (file) => - parseSessionMetadataFile(file)?.replacement, - }); -} - -const slashCommands = new SlashCommandService(normalizePath, (cwd) => - createRpcSession({ - cwd, - noSession: true, - onEvent: () => undefined, - onExit: () => undefined, - }), -); - -function sessionToClientPayload( - session: WebSession, - includeSubagentTranscripts = false, -): WebSession { - const project = resolveSessionProject(session.cwd); - const subagents = includeSubagentTranscripts - ? session.subagents - : session.subagents?.map( - ({ - transcript: _transcript, - streamingText: _streamingText, - ...agent - }) => agent, - ); - return { - id: session.id, - file: session.file, - cwd: session.cwd, - name: session.name, - branch: session.branch, - model: session.model, - thinkingLevel: session.thinkingLevel, - status: session.status, - source: session.source, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - messageCount: session.messageCount, - preview: session.preview, - parentSession: session.parentSession, - projectId: project.id, - projectName: project.name, - repositoryRoot: project.root, - managedWorktree: - "managedWorktree" in session ? session.managedWorktree : undefined, - pullRequest: session.pullRequest, - subagents, - subagentUsage: session.subagentUsage, - usage: session.usage, - contextUsage: session.contextUsage, - compaction: session.compaction, - }; -} - -function sortSessions(list: WebSession[]): WebSession[] { - return list.sort(compareWebSessions); -} - -function makeSessionRecord( - session: WebSession, - kind: SessionKind, - history: unknown[] = [], - managedWorktreeScanned = false, -): SessionRecord { - const displayHistory = boundedWebHistory( - kind === "saved" ? buildContextEntries(history as SessionEntry[]) : history, - ); - const historyManagedWorktree = managedWorktreeFromEntries(history); - const record = sessions.get(session.id) ?? { - ...session, - kind, - history: displayHistory, - historyReady: history.length > 0, - historyBytes: webHistoryByteLength(displayHistory), - active: kind !== "saved", - agentRunning: session.status === "working", - managedWorktreeScanned, - agentSockets: new Set>(), - clientSockets: new Set>(), - externalRequestTargets: new Map< - string, - Bun.ServerWebSocket - >(), - externalPending: new Map(), - queue: (persistedQueues.get(session.id) ?? []).map((item) => ({ - ...item, - images: item.images?.map((image) => ({ ...image })), - })), - }; - Object.assign(record, session); - record.kind = kind; - if (history.length > 0) { - record.history = displayHistory; - record.historyReady = true; - record.historyBytes = webHistoryByteLength(record.history); - } - record.managedWorktree = managedWorktreeScanned - ? session.managedWorktree - : (historyManagedWorktree ?? - session.managedWorktree ?? - record.managedWorktree); - if (managedWorktreeScanned) record.managedWorktreeScanned = true; - record.active = kind !== "saved" || record.active; - if (kind === "saved") record.active = false; - return record; -} - -function upsertSession( - session: WebSession, - kind: SessionKind, - history: unknown[] = [], - managedWorktreeScanned = false, -): SessionRecord { - const existing = sessions.get(session.id); - const record = - existing ?? - makeSessionRecord(session, kind, history, managedWorktreeScanned); - const historyManagedWorktree = - history.length > 0 ? managedWorktreeFromEntries(history) : undefined; - Object.assign(record, session); - record.kind = kind; - if (history.length > 0) - replaceRecordHistory( - record, - kind === "saved" - ? buildContextEntries(history as SessionEntry[]) - : history, - ); - record.managedWorktree = managedWorktreeScanned - ? session.managedWorktree - : (historyManagedWorktree ?? - session.managedWorktree ?? - record.managedWorktree); - if (managedWorktreeScanned) record.managedWorktreeScanned = true; - if (kind !== "saved") record.active = true; - sessions.set(record.id, record); - if (record.file) sessionsByFile.set(normalizePath(record.file), record); - return record; -} - -function broadcast(sessionId: string, message: ServerToClientMessage): void { - const record = sessions.get(sessionId); - if (!record) return; - const payload = JSON.stringify(message); - for (const socket of record.clientSockets) { - try { - socket.send(payload); - } catch { - // ignore - } - } -} - -function broadcastToAll(message: ServerToClientMessage): void { - const payload = JSON.stringify(message); - for (const socket of connectedClientSockets) { - try { - socket.send(payload); - } catch { - // ignore - } - } -} - -function broadcastSessionToAll(record: SessionRecord): void { - if (record.catalogReady === false || sessions.get(record.id) !== record) - return; - broadcastToAll({ - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); -} - -function catalogSessionChanged( - previous: WebSession | undefined, - next: WebSession, -): boolean { - if (!previous) return true; - return ( - previous.file !== next.file || - previous.cwd !== next.cwd || - previous.name !== next.name || - previous.branch !== next.branch || - previous.model !== next.model || - previous.thinkingLevel !== next.thinkingLevel || - previous.status !== next.status || - previous.source !== next.source || - previous.messageCount !== next.messageCount || - previous.preview !== next.preview || - previous.parentSession !== next.parentSession || - previous.pullRequest?.number !== next.pullRequest?.number || - previous.pullRequest?.url !== next.pullRequest?.url || - previous.compaction?.reason !== next.compaction?.reason || - previous.compaction?.startedAt !== next.compaction?.startedAt - ); -} - -function activeSessionFiles(): Set { - return new Set( - [...sessions.values()].flatMap((record) => - record.active && record.file ? [normalizePath(record.file)] : [], - ), - ); -} - -function sessionSnapshot(): WebSession[] { - void reconcileMissingSessionFiles(); - const merged = new Map(); - for (const record of sessions.values()) { - if (record.catalogReady === false || isMissingInactiveSession(record)) - continue; - const key = record.file ? normalizePath(record.file) : record.id; - merged.set(key, sessionToClientPayload(record)); - } - for (const scan of scanSavedSessions(sessionsDir, activeSessionFiles())) { - const key = scan.session.file - ? normalizePath(scan.session.file) - : scan.session.id; - const live = sessionsByFile.get(key) ?? sessions.get(scan.session.id); - if (!live?.active || live.status === "offline") - merged.set(key, sessionToClientPayload(scan.session)); - } - return sortSessions(Array.from(merged.values())); -} - -function replaceRecordHistory( - record: SessionRecord, - entries: readonly unknown[], -): void { - record.history = boundedWebHistory(entries); - record.historyReady = true; - record.historyBytes = webHistoryByteLength(record.history); -} - -function appendRecordHistory(record: SessionRecord, entry: unknown): void { - const appended = boundedWebHistory([entry], { maxEntries: 1 }); - if (appended.length === 0) return; - record.history.push(appended[0]); - record.historyReady = true; - record.historyBytes = - (record.historyBytes ?? webHistoryByteLength(record.history.slice(0, -1))) + - webHistoryByteLength(appended); - while ( - record.history.length > WEB_HISTORY_MAX_ENTRIES || - record.historyBytes > WEB_HISTORY_MAX_BYTES - ) { - const first = record.history[0]; - const preserveSummary = - isRecord(first) && - typeof first.id === "string" && - first.id.startsWith("web-compaction-"); - const removeIndex = preserveSummary && record.history.length > 1 ? 1 : 0; - const [removed] = record.history.splice(removeIndex, 1); - record.historyBytes = Math.max( - 2, - record.historyBytes - webHistoryByteLength([removed]), - ); - } -} - -function sessionHistoryForRecord(record: SessionRecord): unknown[] { - if (record.active || record.historyReady) return [...record.history]; - if (record.file) { - const scan = parseSessionFile(record.file); - if (scan) { - replaceRecordHistory( - record, - buildContextEntries(scan.history as SessionEntry[]), - ); - return [...record.history]; - } - } - return [...record.history]; -} - -function updateSubagentsFromToolEvent( - record: SessionRecord, - event: Record, -): boolean { - if ( - event.type !== "tool_execution_end" || - typeof event.toolName !== "string" || - !event.toolName.startsWith("subagent_") - ) - return false; - const result = isRecord(event.result) ? event.result : undefined; - const details = - result && isRecord(result.details) ? result.details : undefined; - if (!details || !Array.isArray(details.agents)) return false; - const previous = new Map( - (record.subagents ?? []).map((agent) => [agent.id, agent]), - ); - const now = Date.now(); - record.subagents = details.agents.flatMap((value) => { - if ( - !isRecord(value) || - typeof value.id !== "string" || - typeof value.status !== "string" - ) - return []; - const prior = previous.get(value.id); - return [ - { - id: value.id, - status: value.status as NonNullable< - WebSession["subagents"] - >[number]["status"], - model: - typeof value.model === "string" - ? value.model - : (prior?.model ?? "unknown model"), - effort: - typeof value.effort === "string" - ? value.effort - : (prior?.effort ?? "off"), - turns: - typeof value.turns === "number" ? value.turns : (prior?.turns ?? 0), - currentTool: - typeof value.currentTool === "string" ? value.currentTool : undefined, - queued: - typeof value.queued === "number" - ? value.queued - : (prior?.queued ?? 0), - createdAt: prior?.createdAt ?? now, - updatedAt: now, - completedAt: - value.status === "completed" || - value.status === "failed" || - value.status === "terminated" - ? now - : prior?.completedAt, - error: typeof value.error === "string" ? value.error : prior?.error, - usage: isRecord(value.usage) - ? (value.usage as NonNullable< - WebSession["subagents"] - >[number]["usage"]) - : prior?.usage, - transcript: Array.isArray(value.transcript) - ? value.transcript.flatMap((entry) => { - if ( - !isRecord(entry) || - typeof entry.timestamp !== "number" || - typeof entry.role !== "string" || - typeof entry.text !== "string" - ) - return []; - return [ - { - timestamp: entry.timestamp, - role: entry.role, - text: entry.text, - }, - ]; - }) - : prior?.transcript, - streamingText: - typeof value.streamingText === "string" - ? value.streamingText - : prior?.streamingText, - }, - ]; - }); - return true; -} - -function updateRecordFromState(record: SessionRecord, state: unknown): void { - const s = state as Record | undefined; - if (!s) return; - const model = s.model as Record | null | undefined; - if (model && typeof model.id === "string") { - record.model = - typeof model.provider === "string" && model.provider - ? `${model.provider}/${model.id}` - : model.id; - } - if (typeof s.thinkingLevel === "string") - record.thinkingLevel = s.thinkingLevel; - if (typeof s.sessionFile === "string") { - record.file = s.sessionFile; - sessionsByFile.set(normalizePath(s.sessionFile), record); - const scan = parseSessionMetadataFile(s.sessionFile); - if (scan?.session.cwd) record.cwd = scan.session.cwd; - if (scan?.managedWorktreeScanned) - record.managedWorktree = scan.session.managedWorktree; - } - if (typeof s.sessionId === "string") record.id = s.sessionId; - record.name = - typeof s.sessionName === "string" && s.sessionName - ? s.sessionName - : undefined; - if (typeof s.messageCount === "number") record.messageCount = s.messageCount; - if (s.isCompacting === true) { - record.compaction ??= { reason: "threshold", startedAt: Date.now() }; - record.status = "working"; - } else if (s.isCompacting === false) { - record.compaction = undefined; - if (s.isStreaming === false && record.status !== "error") - record.status = "idle"; - } - record.updatedAt = Date.now(); -} - -async function commandOutput( - command: string[], - cwd: string, - timeoutMs = 10_000, -): Promise { - let process: Bun.Subprocess | undefined; - try { - process = Bun.spawn({ - cmd: command, - cwd, - stdout: "pipe", - stderr: "ignore", - }); - const stdout = process.stdout; - if (!stdout || typeof stdout === "number") return undefined; - const timeout = setTimeout(() => process?.kill(), timeoutMs); - try { - const [output, code] = await Promise.all([ - new Response(stdout as ReadableStream).text(), - process.exited, - ]); - return code === 0 ? output.trim() : undefined; - } finally { - clearTimeout(timeout); - } - } catch { - return undefined; - } -} - -async function hydrateGitMetadata(record: SessionRecord): Promise { - const generation = (record.gitMetadataGeneration ?? 0) + 1; - record.gitMetadataGeneration = generation; - const cwd = record.cwd; - const branch = await commandOutput(["git", "branch", "--show-current"], cwd); - const raw = await commandOutput( - ["gh", "pr", "view", "--json", "number,url"], - cwd, - ); - let pullRequest: WebSession["pullRequest"]; - if (raw) { - try { - const value: unknown = JSON.parse(raw); - if ( - isRecord(value) && - Number.isInteger(value.number) && - typeof value.url === "string" - ) { - const url = new URL(value.url); - if (url.protocol === "https:" || url.protocol === "http:") { - pullRequest = { number: value.number as number, url: url.toString() }; - } - } - } catch { - // A branch without an open PR is expected. - } - } - if ( - record.gitMetadataGeneration !== generation || - record.cwd !== cwd || - sessions.get(record.id) !== record - ) - return; - if (branch) record.branch = branch; - // A failed/no-match `gh pr view` is authoritative for the current branch and - // must clear a PR cached before the TUI changed branches. - record.pullRequest = pullRequest; - broadcastSessionToAll(record); -} - -function broadcastCompactionNotice(record: SessionRecord): void { - // A managed bridge can replace compacted history after the compaction_end - // event settles. Wait for that trailing snapshot so the completion notice is - // not immediately wiped by the authoritative history replacement. - const refresh = record.compactionHistoryRefresh; - const deliver = () => { - if (sessions.get(record.id) !== record) return; - broadcastCompactionComplete(record); - }; - if (refresh) void refresh.finally(deliver); - else deliver(); -} - -async function runRpcSessionCommand( - record: SessionRecord, - command: RpcSessionCommand, -): Promise { - if (record.managed) { - switch (command.type) { - case "clone": - return await record.managed.clone(); - case "fork": - return await record.managed.fork(command.entryId); - case "get_fork_messages": - return await record.managed.getForkMessages(); - case "set_session_name": - await record.managed.setSessionName(command.name); - return undefined; - case "compact": - return await record.managed.compact(command.customInstructions); - case "bash": - return await record.managed.bash(command.command); - case "extension_ui_response": - return await record.managed.respondToExtensionUi(command); - } - } - if (record.file && isWithinDir(record.file, sessionsDir)) { - const scan = parseSessionMetadataFile(record.file); - if (scan) { - const temp = createRpcSession({ - cwd: scan.session.cwd, - name: scan.session.name, - sessionFile: record.file, - onEvent: (event) => { - if (typeof event === "object" && event && "type" in event) { - broadcast(record.id, { - type: "server.event", - sessionId: record.id, - event: event as Record, - } satisfies ServerEventMessage); - } - }, - onExit: () => undefined, - }); - await temp.start(); - try { - switch (command.type) { - case "clone": - return await temp.clone(); - case "fork": - return await temp.fork(command.entryId); - case "get_fork_messages": { - const response = await temp.getForkMessages(); - return response; - } - case "set_session_name": - await temp.setSessionName(command.name); - return undefined; - case "compact": - return await temp.compact(command.customInstructions); - case "bash": - return await temp.bash(command.command); - case "extension_ui_response": - throw new Error( - "No extension UI request is active for this saved session", - ); - } - } finally { - await temp.shutdown(); - } - } - } - if (command.type === "get_fork_messages") { - return { messages: deriveForkMessages(record.history) }; - } - throw new Error(`Session ${record.id} is not managed`); -} - -function sendSessionSnapshot( - socket: Bun.ServerWebSocket, -): void { - const payload: ServerSnapshotMessage = { - type: "server.snapshot", - sessions: sessionSnapshot(), - }; - socket.send(JSON.stringify(payload)); -} - -function sendSessionHistory( - socket: Bun.ServerWebSocket, - record: SessionRecord, -): void { - // The web client is semantic for both managed and native sessions. Loading - // transcript entries here avoids any second TUI render in the Pi process. - const entries = sessionHistoryForRecord(record); - // Bound initial payload size for very long sessions. Live events append from - // this point; older history remains available in the native session file. - const payload: ServerHistoryMessage = { - type: "server.history", - sessionId: record.id, - entries: boundedWebHistory(entries), - replace: true, - }; - socket.send(JSON.stringify(payload)); -} - -const { - webQueueEvent, - cloneWebQueue, - persistWebQueue, - enqueueWebFollowUp, - migratePersistedQueue, - scheduleWebQueueRetry, - markWebQueueSnapshotDirty, - markAgentActivity, - markAgentSettling, - isCurrentAgentSettlement, - cancelQueueSettleFallback, - scheduleQueueSettleFallback, - cancelWebQueueWork, - broadcastQueueDelivery, - broadcastReloadComplete, - broadcastCompactionComplete, - sendSessionState, - flushWebQueue, - routeQueueCommand, -} = createSessionQueueCoordinator({ - persistedQueues, - queueStoreWriter, - currentRecord: (id) => sessions.get(id), - isShutdownStarted: () => shutdownStarted, - broadcast, - deliverCommand: (record, command) => routeCommand(record, command), - projectSession: sessionToClientPayload, -}); - -function sendSessionRemoved( - sessionId: string, - replacementSessionId?: string, - additionalSockets: Iterable> = [], -): void { - const payload: ServerSessionRemovedMessage = { - type: "server.session_removed", - sessionId, - replacementSessionId, - }; - const message = JSON.stringify(payload); - const notified = new Set>(); - for (const socket of connectedClientSockets) { - notified.add(socket); - try { - socket.send(message); - } catch { - /* ignore */ - } - } - for (const socket of additionalSockets) { - if (notified.has(socket)) continue; - notified.add(socket); - try { - socket.send(message); - } catch { - /* ignore */ - } - } -} - -async function completeExternalSessionReplacement( - socket: Bun.ServerWebSocket, - replacement: AgentSessionReplacedMessage, -): Promise { - if (replacement.previousSessionId === replacement.replacementSessionId) - throw new Error("Replacement session must differ from its source"); - const next = sessions.get(replacement.replacementSessionId); - if (!next || !next.agentSockets.has(socket)) - throw new Error("Replacement session is not bound to this agent socket"); - const durableReplacement = next.file - ? parseSessionMetadataFile(next.file)?.replacement - : replacementFromEntries(next.history); - if ( - !durableReplacement || - durableReplacement.previousSessionId !== replacement.previousSessionId || - normalizePath(durableReplacement.previousSessionFile) !== - normalizePath(replacement.previousSessionFile) || - durableReplacement.replacementSessionId !== replacement.replacementSessionId - ) - throw new Error( - "Replacement activation does not match the durable session marker", - ); - const previous = sessions.get(replacement.previousSessionId); - if ( - previous && - (!previous.file || - normalizePath(previous.file) !== - normalizePath(replacement.previousSessionFile)) - ) { - throw new Error( - "Replacement source file does not match the registered source session", - ); - } - - if (!previous) { - if (existsSync(replacement.previousSessionFile)) return; - const orphaned = persistedQueues.get(replacement.previousSessionId); - if (orphaned?.length) { - const ids = new Set(); - const queue = [ - ...cloneWebQueue(orphaned), - ...cloneWebQueue(next.queue), - ].filter((item) => { - if (ids.has(item.id)) return false; - ids.add(item.id); - return true; - }); - await queueStoreWriter.mutate(persistedQueues, (queues) => { - queues.delete(replacement.previousSessionId); - if (queue.length > 0) queues.set(next.id, queue); - }); - next.queue = queue; - broadcast(next.id, webQueueEvent(next)); - if (next.status === "idle" && next.agentRunning !== true) - scheduleQueueSettleFallback(next); - } - sendSessionRemoved(replacement.previousSessionId, next.id); - return; - } - - // A durable marker can be written immediately before unlink. If Pi crashes in - // that narrow window, retain both sessions rather than treating it as committed. - if (existsSync(replacement.previousSessionFile)) return; - await quiesceQueueMutations(previous); - if (previous.queueDirtyWorker) { - await previous.queueDirtyWorker.cancelAndDrain(); - previous.queueDirtyWorker = undefined; - } - const ids = new Set(); - const queue = [ - ...cloneWebQueue(previous.queue), - ...cloneWebQueue(next.queue), - ].filter((item) => { - if (ids.has(item.id)) return false; - ids.add(item.id); - return true; - }); - try { - await queueStoreWriter.mutate(persistedQueues, (queues) => { - queues.delete(previous.id); - if (queue.length > 0) queues.set(next.id, queue); - else queues.delete(next.id); - }); - } catch (error) { - previous.queueMutationsQuiesced = false; - throw error; - } - next.queue = queue; - next.queueDeliveryAttempts ??= previous.queueDeliveryAttempts; - next.queueTransitionAttempts ??= previous.queueTransitionAttempts; - - for (const client of previous.clientSockets) { - next.clientSockets.add(client); - client.data.sessionId = next.id; - } - for (const [requestId, pending] of previous.externalPending) { - pending.owner = next; - next.externalPending.set(requestId, pending); - next.externalRequestTargets.set(requestId, socket); - } - previous.externalPending.clear(); - previous.externalRequestTargets.clear(); - cancelWebQueueWork(previous); - sessions.delete(previous.id); - if (previous.file) sessionsByFile.delete(normalizePath(previous.file)); - for (const agent of previous.agentSockets) { - if (agent === socket) continue; - try { - agent.close(); - } catch { - /* ignore */ - } - } - broadcast(next.id, webQueueEvent(next)); - sendSessionRemoved(previous.id, next.id); - if ( - next.status === "idle" && - next.agentRunning !== true && - next.queue.length > 0 - ) - scheduleQueueSettleFallback(next); -} - -function updateRecordFromStats(record: SessionRecord, value: unknown): void { - if (!isRecord(value)) return; - if (isRecord(value.tokens)) { - const usage = record.usage ?? zeroWebUsage(); - usage.input = toNumber(value.tokens.input); - usage.output = toNumber(value.tokens.output); - usage.cacheRead = toNumber(value.tokens.cacheRead); - usage.cacheWrite = toNumber(value.tokens.cacheWrite); - usage.totalTokens = toNumber(value.tokens.total); - usage.cost.total = toNumber(value.cost, usage.cost.total); - record.usage = usage; - } - if (isRecord(value.contextUsage)) { - record.contextUsage = { - tokens: - value.contextUsage.tokens === null - ? null - : toNumber(value.contextUsage.tokens), - contextWindow: toNumber(value.contextUsage.contextWindow), - percent: - value.contextUsage.percent === null - ? null - : toNumber(value.contextUsage.percent), - }; - } -} - -function stageSourceSessionDeletion( - previousFile: string, - nextFile: string, -): ManagedIdentityTransition { - const source = canonicalSessionFile(previousFile); - if (sessionFileKey(source) === sessionFileKey(nextFile)) - throw new Error("Replacement session file must differ from its source"); - const tombstone = `${source}.replaced-${randomUUID()}.tmp`; - renameSync(source, tombstone); - return { - rollback: () => { - if (existsSync(source)) - throw new Error( - `Refusing to overwrite a recreated source session: ${source}`, - ); - if (existsSync(tombstone)) renameSync(tombstone, source); - else throw new Error(`Missing staged source session ${tombstone}`); - }, - commit: () => { - try { - rmSync(tombstone, { force: true }); - } catch (error) { - console.warn( - `Source session was removed, but its staged tombstone could not be cleaned: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - }; -} - -type ManagedIdentityTransition = { - rollback(): void; - commit(): void; -}; - -async function refreshManagedSession( - record: SessionRecord, - suppressErrors = false, - stageIdentityTransition?: ( - previousFile: string, - nextFile: string, - ) => ManagedIdentityTransition, -): Promise { - await runManagedRefresh( - () => - serializeManagedRefresh(record, async () => { - const managed = record.managed; - if (!managed) return; - let finishQueueMigration: (() => void) | undefined; - let identityTransition: ManagedIdentityTransition | undefined; - try { - const oldId = record.id; - const oldFile = record.file; - const state = await managed.getState(); - const nextState = isRecord(state) ? { ...state } : state; - const newId = - isRecord(nextState) && typeof nextState.sessionId === "string" - ? nextState.sessionId - : oldId; - const newFile = - isRecord(nextState) && typeof nextState.sessionFile === "string" - ? nextState.sessionFile - : oldFile; - const fileChanged = Boolean( - newFile && - (!oldFile || sessionFileKey(newFile) !== sessionFileKey(oldFile)), - ); - let ownershipMigrated = false; - try { - const pendingDeletion = record.pendingWorktreeSourceDeletion; - let verifiedPendingDeletion = false; - if ( - pendingDeletion && - pendingDeletion.sessionId === oldId && - oldFile && - newFile && - newId !== oldId && - sessionFileKey(pendingDeletion.sessionFile) === - sessionFileKey(oldFile) - ) { - const marker = parseSessionMetadataFile(newFile)?.replacement; - verifiedPendingDeletion = Boolean( - marker && - marker.previousSessionId === oldId && - sessionFileKey(marker.previousSessionFile) === - sessionFileKey(oldFile) && - marker.replacementSessionId === newId, - ); - } - if ( - pendingDeletion && - pendingDeletion.sessionId === oldId && - newId !== oldId && - !verifiedPendingDeletion - ) { - // session_start can be observed before the verified activation marker is - // appended. Leave the old identity intact until the worktree callback commits. - return; - } - const transitionFactory = - stageIdentityTransition ?? - (verifiedPendingDeletion - ? stageSourceSessionDeletion - : undefined); - if (oldFile && newFile && newId !== oldId && transitionFactory) { - identityTransition = transitionFactory(oldFile, newFile); - } - if (newFile) { - ownershipMigrated = fileChanged || !isManagedSessionFile(newFile); - replaceManagedSessionFile(oldFile, newFile); - } - if (newId !== oldId) { - finishQueueMigration = preserveRetryAroundQuiescence({ - isArmed: () => record.queueRetryTimer !== undefined, - cancel: () => { - if (record.queueRetryTimer) - clearTimeout(record.queueRetryTimer); - record.queueRetryTimer = undefined; - }, - reopen: () => { - record.queueMutationsQuiesced = false; - }, - resume: () => scheduleWebQueueRetry(record), - }); - await migratePersistedQueue(record, oldId, newId); - if (isRecord(nextState)) delete nextState.sessionId; - } - } catch (transitionError) { - const rollbackErrors: string[] = []; - if (identityTransition) { - try { - identityTransition.rollback(); - } catch (error) { - rollbackErrors.push( - `source-session rollback failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - identityTransition = undefined; - } - if (oldFile && (fileChanged || newId !== oldId)) { - try { - await managed.switchSession(oldFile); - const restored = await managed.getState(); - if ( - !isRecord(restored) || - restored.sessionId !== oldId || - typeof restored.sessionFile !== "string" || - sessionFileKey(restored.sessionFile) !== - sessionFileKey(oldFile) - ) { - throw new Error( - "Pi did not restore the original session identity", - ); - } - } catch (error) { - rollbackErrors.push( - `runtime rollback failed: ${error instanceof Error ? error.message : String(error)}`, - ); - await managed.shutdown().catch(() => undefined); - record.managed = undefined; - record.active = false; - record.status = "offline"; - } - } else if (!oldFile && fileChanged) { - await managed.shutdown().catch(() => undefined); - record.managed = undefined; - record.active = false; - record.status = "offline"; - } - if (ownershipMigrated && oldFile && newFile) { - try { - replaceManagedSessionFile(newFile, oldFile); - } catch (error) { - rollbackErrors.push( - `ownership rollback failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - finishQueueMigration?.(); - finishQueueMigration = undefined; - const message = - transitionError instanceof Error - ? transitionError.message - : String(transitionError); - throw new Error( - rollbackErrors.length > 0 - ? `${message}; ${rollbackErrors.join("; ")}` - : message, - ); - } - - updateRecordFromState(record, nextState); - try { - replaceRecordHistory( - record, - messagesToWebHistory((await managed.getMessages()).messages), - ); - } catch { - // Keep the last complete bounded history snapshot. - } - try { - updateRecordFromStats(record, await managed.getSessionStats()); - } catch { - // Stats are supplementary; keep history-derived usage. - } - if (oldId !== newId) { - record.id = newId; - sessions.delete(oldId); - sessions.set(newId, record); - for (const socket of record.clientSockets) - socket.data.sessionId = newId; - finishQueueMigration?.(); - finishQueueMigration = undefined; - sendSessionRemoved(oldId, newId); - } - if (oldFile && oldFile !== record.file) - sessionsByFile.delete(normalizePath(oldFile)); - if (record.file) - sessionsByFile.set(normalizePath(record.file), record); - identityTransition?.commit(); - if ( - record.pendingWorktreeSourceDeletion?.sessionId === oldId && - oldId !== newId - ) { - record.pendingWorktreeSourceDeletion = undefined; - } - identityTransition = undefined; - broadcastSessionToAll(record); - } catch (error) { - finishQueueMigration?.(); - if (identityTransition) { - try { - identityTransition.rollback(); - } catch (rollbackError) { - throw new Error( - `${error instanceof Error ? error.message : String(error)}; source-session rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); - } - } - throw error; - } - }), - { - suppressErrors, - onBackgroundError: (error) => - console.error(`Could not refresh managed session ${record.id}:`, error), - }, - ); -} - -async function recoverStagedSourceSessionDeletions(): Promise { - const tombstones: Array<{ tombstone: string; source: string }> = []; - const stack = [sessionsDir]; - while (stack.length > 0) { - const directory = stack.pop(); - if (!directory) break; - let entries: Array<{ - name: string; - isDirectory(): boolean; - isFile(): boolean; - }>; - try { - entries = readdirSync(directory, { withFileTypes: true }) as Array<{ - name: string; - isDirectory(): boolean; - isFile(): boolean; - }>; - } catch { - continue; - } - for (const entry of entries) { - const path = join(directory, entry.name); - if (entry.isDirectory()) stack.push(path); - else if (entry.isFile()) { - const match = path.match(/^(.*\.jsonl)\.replaced-[0-9a-f-]+\.tmp$/i); - if (match) tombstones.push({ tombstone: path, source: match[1] }); - } - } - } - if (tombstones.length === 0) return; - const saved = listSavedSessionFiles(sessionsDir).flatMap((file) => { - const scan = parseSessionFile(file); - return scan ? [scan] : []; - }); - for (const staged of tombstones) { - if (existsSync(staged.source)) { - console.warn( - `Retaining staged source session because its original path was recreated: ${staged.tombstone}`, - ); - continue; - } - try { - const sourceWasManaged = managedSessionStore.has(staged.source); - const sourceScan = parseSessionFile(staged.tombstone); - const sourceId = sourceScan?.session.id; - const replacement = sourceId - ? saved.find((candidate) => { - const marker = replacementFromEntries(candidate.entries); - return ( - marker?.previousSessionId === sourceId && - normalizePath(marker.previousSessionFile) === - normalizePath(staged.source) && - marker.replacementSessionId === candidate.session.id - ); - }) - : undefined; - if (!sourceId || !replacement) { - if (!existsSync(staged.source)) - renameSync(staged.tombstone, staged.source); - continue; - } - // replacement is only defined when sourceId is, but the find callback - // above loses that narrowing across the closure boundary. - const sourceQueue = sourceId ? persistedQueues.get(sourceId) : undefined; - const replacementQueue = persistedQueues.get(replacement.session.id); - if (sourceQueue?.length) { - const ids = new Set(); - const queue = [ - ...cloneWebQueue(sourceQueue), - ...cloneWebQueue(replacementQueue ?? []), - ].filter((item) => { - if (ids.has(item.id)) return false; - ids.add(item.id); - return true; - }); - await queueStoreWriter.mutate(persistedQueues, (queues) => { - if (sourceId) queues.delete(sourceId); - queues.set(replacement.session.id, queue); - }); - } - const previousManagedWorktree = managedWorktreeFromEntries( - sourceScan?.entries ?? [], - ); - if (sourceWasManaged) - replaceManagedSessionFile(staged.source, replacement.file); - let worktreeCleanupFailed = false; - if ( - previousManagedWorktree && - !hasOtherSessionInWorktree( - sessionsDir, - staged.source, - previousManagedWorktree.path, - ) - ) { - try { - const cleanup = removeManagedWorktree(previousManagedWorktree); - if (cleanup.branchWarning) - console.warn( - `Recovered source deletion removed worktree ${previousManagedWorktree.path}, but branch cleanup failed: ${cleanup.branchWarning}`, - ); - } catch (error) { - worktreeCleanupFailed = true; - console.warn( - `Recovered source session deletion, but previous managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - if (!worktreeCleanupFailed) rmSync(staged.tombstone, { force: true }); - } catch (error) { - console.warn( - `Could not recover staged source session deletion ${staged.tombstone}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - try { - managedSessionStore.recanonicalize(); - } catch (error) { - console.warn( - `Could not recanonicalize managed session ownership after source recovery: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -async function createManagedSessionUnlocked( - cwd: string, - name?: string, - sessionFile?: string, -): Promise { - const resumed = sessionFile - ? parseSessionMetadataFile(sessionFile) - : undefined; - const existingRecord = resumed - ? (sessions.get(resumed.session.id) ?? - sessionsByFile.get(normalizePath(resumed.file))) - : undefined; - let restoreExistingQueueIntake: (() => void) | undefined; - if (existingRecord) { - restoreExistingQueueIntake = preserveRetryAroundQuiescence({ - isArmed: () => existingRecord.queueRetryTimer !== undefined, - cancel: () => { - if (existingRecord.queueRetryTimer) - clearTimeout(existingRecord.queueRetryTimer); - existingRecord.queueRetryTimer = undefined; - }, - reopen: () => { - existingRecord.queueMutationsQuiesced = false; - }, - resume: () => scheduleWebQueueRetry(existingRecord), - }); - await quiesceQueueMutations(existingRecord); - if (existingRecord.queueDirtyWorker) { - await existingRecord.queueDirtyWorker.cancelAndDrain(); - existingRecord.queueDirtyWorker = undefined; - } - } - const record: SessionRecord = { - id: resumed?.session.id ?? randomUUID(), - file: sessionFile, - cwd, - name: name ?? resumed?.session.name, - model: resumed?.session.model, - thinkingLevel: resumed?.session.thinkingLevel, - status: "starting", - source: "web", - createdAt: resumed?.session.createdAt ?? Date.now(), - updatedAt: Date.now(), - messageCount: resumed?.session.messageCount ?? 0, - preview: resumed?.session.preview, - parentSession: resumed?.session.parentSession, - usage: resumed?.session.usage, - contextUsage: resumed?.session.contextUsage, - kind: "managed", - history: existingRecord?.history ?? resumed?.history ?? [], - historyReady: existingRecord?.historyReady ?? false, - active: true, - agentSockets: new Set(), - clientSockets: existingRecord?.clientSockets ?? new Set(), - externalRequestTargets: new Map(), - externalPending: new Map(), - queue: cloneWebQueue( - existingRecord?.queue ?? - persistedQueues.get(resumed?.session.id ?? "") ?? - [], - ), - // A newly started RPC runtime has no surviving subagents. An explicit empty - // snapshot clears stale browser telemetry retained across daemon reconnects. - subagents: [], - managedWorktree: resumed?.session.managedWorktree, - catalogReady: false, - }; - sessions.set(record.id, record); - if (record.file) sessionsByFile.set(normalizePath(record.file), record); - - const managed = createRpcSession({ - cwd, - name, - sessionFile, - onEvent: (event) => { - record.updatedAt = Date.now(); - updateSubagentsFromToolEvent(record, event); - if (event.type === "agent_start" || event.type === "turn_start") { - record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; - markAgentActivity(record); - } - if (event.type === "agent_start" || event.type === "turn_start") { - cancelQueueSettleFallback(record); - record.status = "working"; - record.agentRunning = true; - } - if (event.type === "agent_end" && !record.compaction) { - markAgentSettling(record); - record.status = - agentEndTerminalNotice(event)?.kind === "error" ? "error" : "idle"; - record.agentRunning = false; - scheduleQueueSettleFallback(record); - } - if (event.type === "compaction_start") { - markAgentSettling(record); - record.status = "working"; - record.agentRunning = true; - record.compaction = { - reason: - event.reason === "manual" || event.reason === "overflow" - ? event.reason - : "threshold", - startedAt: - typeof event.startedAt === "number" ? event.startedAt : Date.now(), - }; - } - if (event.type === "compaction_end") { - record.compaction = undefined; - if (event.aborted === true || event.willRetry === false) { - record.status = "idle"; - record.agentRunning = false; - scheduleQueueSettleFallback(record); - } - } - if (event.type === "agent_settled" && isCurrentAgentSettlement(record)) { - cancelQueueSettleFallback(record); - record.settlingGeneration = undefined; - // Pi emits agent_settled only when no retry, compaction, or internal - // follow-up remains. It is authoritative even when an interrupted - // overflow compaction last advertised willRetry=true. - if (record.status !== "error") record.status = "idle"; - record.agentRunning = false; - void refreshManagedSession(record, true); - void flushWebQueue(record); - } - - if (event.type === "message_end" && isRecord(event.message)) { - if ( - event.message.role === "assistant" || - event.message.role === "toolResult" - ) { - record.usage ??= zeroWebUsage(); - addWebUsage(record.usage, event.message.usage); - } - appendRecordHistory(record, { - type: "message", - id: randomUUID(), - parentId: null, - timestamp: new Date().toISOString(), - message: event.message, - }); - record.messageCount += 1; - const terminalNotice = assistantTerminalNotice(event.message); - if (terminalNotice && !extractTextContent(event.message.content)) { - record.preview = - `${terminalNotice.title}: ${terminalNotice.detail}`.slice(0, 180); - } - } - const compactionEntry = compactionEntryFromEvent(event); - if (compactionEntry) { - replaceRecordHistory(record, [compactionEntry]); - broadcast(record.id, { - type: "server.history", - sessionId: record.id, - entries: record.history, - replace: true, - } satisfies ServerHistoryMessage); - const runtime = record.managed; - if (runtime) { - // Track the trailing authoritative snapshot so any compaction_end - // notice can wait for it before broadcasting; otherwise the notice - // would be wiped by this history replacement on subscribed clients. - const refresh: Promise = runtime - .getMessages() - .then(({ messages }) => { - if ( - record.managed !== runtime || - sessions.get(record.id) !== record - ) - return; - replaceRecordHistory(record, messagesToWebHistory(messages)); - broadcast(record.id, { - type: "server.history", - sessionId: record.id, - entries: record.history, - replace: true, - } satisfies ServerHistoryMessage); - }) - .catch((error) => - console.error( - `Could not refresh compacted history for ${record.id}: ${error instanceof Error ? error.message : String(error)}`, - ), - ) - .finally(() => { - if (record.compactionHistoryRefresh === refresh) - record.compactionHistoryRefresh = undefined; - }); - record.compactionHistoryRefresh = refresh; - } - } - if (event.type === "compaction_end" && event.aborted !== true) { - broadcastCompactionNotice(record); - } - broadcast(record.id, { - type: "server.event", - sessionId: record.id, - event, - } satisfies ServerEventMessage); - const catalogChanged = - event.type === "agent_start" || - event.type === "turn_start" || - event.type === "agent_end" || - event.type === "agent_settled" || - event.type === "compaction_start" || - event.type === "compaction_end" || - event.type === "message_end"; - if (catalogChanged) broadcastSessionToAll(record); - else - broadcast(record.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); - if (event.type === "agent_end") void hydrateGitMetadata(record); - }, - onExit: () => { - record.status = "offline"; - record.active = false; - record.managed = undefined; - broadcastSessionToAll(record); - }, - }); - record.managed = managed; - const provisionalId = record.id; - try { - await managed.start(); - const state = await managed.getState(); - updateRecordFromState(record, state); - if (record.file) replaceManagedSessionFile(sessionFile, record.file); - if (record.id !== provisionalId) { - sessions.delete(provisionalId); - sessions.set(record.id, record); - } - try { - replaceRecordHistory( - record, - messagesToWebHistory((await managed.getMessages()).messages), - ); - } catch { - // A failed context request must not publish a blank active resume. Read only - // a bounded suffix instead of hydrating the append-only session archive. - if (!record.historyReady && record.file) - replaceRecordHistory(record, parseSessionHistoryFile(record.file)); - } - try { - updateRecordFromStats(record, await managed.getSessionStats()); - } catch { - // Keep history-derived usage when stats are unavailable. - } - record.status = "idle"; - record.catalogReady = true; - broadcastSessionToAll(record); - void hydrateGitMetadata(record); - for (const socket of record.clientSockets) { - socket.data.sessionId = record.id; - sendSessionState(socket, record); - sendSessionHistory(socket, record); - } - void flushWebQueue(record); - return record; - } catch (error) { - await managed.shutdown(); - if (sessions.get(provisionalId) === record) sessions.delete(provisionalId); - if (sessions.get(record.id) === record) sessions.delete(record.id); - for (const file of [sessionFile, record.file]) { - if (file && sessionsByFile.get(normalizePath(file)) === record) - sessionsByFile.delete(normalizePath(file)); - } - if (existingRecord) { - sessions.set(existingRecord.id, existingRecord); - if (existingRecord.file) - sessionsByFile.set(normalizePath(existingRecord.file), existingRecord); - restoreExistingQueueIntake?.(); - broadcastSessionToAll(existingRecord); - } - throw error; - } -} - -async function createManagedSession( - cwd: string, - name?: string, - sessionFile?: string, -): Promise { - if (!sessionFile) return await createManagedSessionUnlocked(cwd, name); - const key = normalizePath(sessionFile); - const existing = managedSessionStarts.get(key); - if (existing) return await existing; - const start = createManagedSessionUnlocked(cwd, name, sessionFile); - managedSessionStarts.set(key, start); - try { - return await start; - } finally { - if (managedSessionStarts.get(key) === start) - managedSessionStarts.delete(key); - } -} - -async function restoreManagedSessions(): Promise { - for (const storedFile of managedSessionStore.list()) { - let file: string; - try { - file = canonicalSessionFile(storedFile); - } catch { - try { - deleteManagedSessionFile(storedFile); - } catch (error) { - console.error( - `Could not remove missing managed session ${storedFile}:`, - error, - ); - } - continue; - } - const existing = sessionsByFile.get(normalizePath(file)); - if (existing?.active && existing.status !== "offline") continue; - if (!isManagedSessionFile(file)) continue; - const scan = parseSessionMetadataFile(file); - if (!scan) { - try { - deleteManagedSessionFile(file); - } catch (error) { - console.error( - `Could not remove invalid managed session ${file}:`, - error, - ); - } - continue; - } - try { - await createManagedSession(scan.session.cwd, scan.session.name, file); - } catch (error) { - console.error( - `Could not restore managed web session ${scan.session.id}:`, - error, - ); - } - } -} - -function attachClientSocket( - socket: Bun.ServerWebSocket, -): void { - socket.data = { kind: "client", id: randomUUID(), authed: false }; -} - -function attachAgentSocket(socket: Bun.ServerWebSocket): void { - socket.data = { kind: "agent", id: randomUUID(), authed: false }; -} - -function parseSocketMessage(data: string | Uint8Array): T | undefined { - const text = typeof data === "string" ? data : decoder.decode(data); - try { - return JSON.parse(text) as T; - } catch { - return undefined; - } -} - -async function handleClientMessage( - socket: Bun.ServerWebSocket, - message: ClientToServerMessage, -): Promise { - if ( - message.type === "client.hello" || - message.type === "client.command_hello" - ) { - socket.data.authed = true; - if (message.type === "client.hello") { - connectedClientSockets.add(socket); - sendSessionSnapshot(socket); - } - return; - } - if (!socket.data.authed) - throw new Error("Client must send a hello message first"); - if (shutdownStarted && shouldRejectDuringShutdown(message)) { - throw new Error( - "Pi Web is waiting for active sessions to finish before restarting", - ); - } - if (message.type === "client.subscribe") { - const record = - sessions.get(message.sessionId) ?? - (() => { - const scan = scanSavedSessions(sessionsDir).find( - (item) => - item.session.id === message.sessionId || - (item.file && - normalizePath(item.file) === normalizePath(message.sessionId)), - ); - if (!scan) return undefined; - return upsertSession( - scan.session, - "saved", - scan.history, - scan.managedWorktreeScanned, - ); - })(); - if (!record) throw new Error(`Unknown session: ${message.sessionId}`); - const previousSessionId = socket.data.sessionId; - if (previousSessionId && previousSessionId !== record.id) { - sessions.get(previousSessionId)?.clientSockets.delete(socket); - } - record.clientSockets.add(socket); - socket.data.sessionId = record.id; - sendSessionState(socket, record); - sendSessionHistory(socket, record); - return; - } - if (message.type === "client.sync_queue") { - const record = sessions.get(message.sessionId); - if (!record) throw new Error(`Unknown session: ${message.sessionId}`); - const update = webQueueEvent(record); - socket.send( - JSON.stringify({ - ...update, - event: { ...update.event, syncRequestId: message.requestId }, - }), - ); - return; - } - if (message.type === "client.prompt") { - const record = sessions.get(message.sessionId); - try { - if (!record) throw new Error(`Unknown session: ${message.sessionId}`); - const normalizedPrompt = message.message.trim(); - const reload = isWebReloadCommand(normalizedPrompt); - const compact = parseWebCompactCommand(normalizedPrompt); - const worktree = parseWorktreeInvocation(message.message); - let responseData: unknown; - if (reload) { - if (message.images?.length) - throw new Error("/reload does not accept image attachments"); - if ( - message.streamingBehavior === "followUp" && - (record.status === "working" || - hasActiveWebSubagents(record.subagents)) - ) { - await enqueueWebFollowUp(record, { - id: message.requestId, - message: message.message, - }); - } else { - if (message.streamingBehavior === "steer") - throw new Error("/reload must be queued or run while Pi is idle"); - responseData = await routeCommand(record, { type: "reload" }); - broadcastReloadComplete(record); - } - } else if (compact) { - if (message.images?.length) - throw new Error("/compact does not accept image attachments"); - if ( - message.streamingBehavior === "followUp" && - (record.status === "working" || - hasActiveWebSubagents(record.subagents)) - ) { - await enqueueWebFollowUp(record, { - id: message.requestId, - message: message.message, - }); - } else { - if (message.streamingBehavior === "steer") - throw new Error("/compact must be queued or run while Pi is idle"); - responseData = await routeCommand(record, { - type: "compact", - customInstructions: compact.customInstructions, - }); - } - } else if (worktree) { - if (message.images?.length) - throw new Error("/worktree does not accept image attachments"); - if (message.streamingBehavior) - throw new Error("/worktree must be run while Pi is idle"); - if (!worktree.name && !worktree.existing) { - throw new Error(WORKTREE_USAGE); - } - responseData = await routeCommand( - record, - worktree.existing - ? { type: "create_worktree", existing: worktree.existing } - : { - type: "create_worktree", - name: worktree.name ?? "", - repository: worktree.repository ?? record.cwd, - branch: worktree.branch, - startPoint: worktree.startPoint, - }, - ); - } else if ( - message.streamingBehavior === "followUp" && - record.status === "working" - ) { - await enqueueWebFollowUp(record, { - id: message.requestId, - message: message.message, - images: message.images, - }); - } else { - await routeCommand(record, { - type: "prompt", - message: message.message, - images: message.images, - streamingBehavior: message.streamingBehavior, - }); - } - socket.send( - JSON.stringify({ - type: "server.response", - requestId: message.requestId, - success: true, - data: responseData, - } satisfies ServerResponseMessage), - ); - } catch (error) { - socket.send( - JSON.stringify({ - type: "server.response", - requestId: message.requestId, - success: false, - error: error instanceof Error ? error.message : String(error), - } satisfies ServerResponseMessage), - ); - } - return; - } - if (message.type === "client.command") { - const record = - sessions.get(message.sessionId) ?? - sessionsByFile.get(normalizePath(message.sessionId)); - if (!record) { - socket.send( - JSON.stringify({ - type: "server.response", - requestId: message.requestId, - success: false, - error: `Unknown session: ${message.sessionId}`, - } satisfies ServerResponseMessage), - ); - return; - } - try { - const previousSessionId = record.id; - const data = await routeCommand(record, message.command); - if (message.command.type !== "abort") await refreshManagedSession(record); - const responseData = - record.id !== previousSessionId && - (message.command.type === "clone" || - message.command.type === "fork" || - message.command.type === "create_worktree" || - message.command.type === "create_worktree_v2") - ? { - ...(isRecord(data) ? data : {}), - cancelled: isRecord(data) && data.cancelled === true, - sessionId: record.id, - } - : data; - socket.send( - JSON.stringify({ - type: "server.response", - requestId: message.requestId, - success: true, - data: responseData, - } satisfies ServerResponseMessage), - ); - } catch (error) { - socket.send( - JSON.stringify({ - type: "server.response", - requestId: message.requestId, - success: false, - error: error instanceof Error ? error.message : String(error), - } satisfies ServerResponseMessage), - ); - } - } -} - -async function routeCommand( - record: SessionRecord, - command: ClientCommandMessage["command"], -): Promise { - const changesManagedIdentity = - Boolean(record.managed) && - (command.type === "clone" || - command.type === "fork" || - command.type === "create_worktree" || - command.type === "create_worktree_v2"); - if (changesManagedIdentity) { - if (record.managedIdentityOperation) - throw new Error( - `Another session replacement is already in progress (${record.managedIdentityOperation})`, - ); - record.managedIdentityOperation = command.type; - try { - return await routeCommandCore(record, command); - } finally { - record.managedIdentityOperation = undefined; - } - } - if (command.type !== "prompt") return await routeCommandCore(record, command); - - const shouldMarkWorking = - record.status !== "working" || record.agentRunning !== true; - const previousStatus = record.status; - const previousAgentRunning = record.agentRunning; - const previousActivityGeneration = record.activityGeneration; - const agentStartGeneration = record.agentStartGeneration ?? 0; - if (shouldMarkWorking) { - markAgentActivity(record); - cancelQueueSettleFallback(record); - record.status = "working"; - record.agentRunning = true; - record.updatedAt = Date.now(); - broadcast(record.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); - } - try { - return await routeCommandCore(record, command); - } catch (error) { - if ( - shouldMarkWorking && - (record.agentStartGeneration ?? 0) === agentStartGeneration - ) { - record.status = previousStatus; - record.agentRunning = previousAgentRunning; - record.activityGeneration = previousActivityGeneration; - broadcast(record.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); - if ( - record.status === "idle" && - record.agentRunning !== true && - record.queue.length > 0 - ) { - scheduleQueueSettleFallback(record); - } - } - throw error; - } -} - -async function routeCommandCore( - record: SessionRecord, - command: ClientCommandMessage["command"], -): Promise { - if ( - command.type === "create_worktree" || - command.type === "create_worktree_v2" - ) { - const value = command as unknown as Record; - if ("existing" in value) { - if (typeof value.existing !== "string" || !value.existing.trim()) - throw new Error("create_worktree existing path is required"); - if ( - ["name", "repository", "branch", "startPoint"].some( - (key) => value[key] !== undefined, - ) - ) { - throw new Error( - "create_worktree existing mode cannot include create-mode fields", - ); - } - } else { - if (typeof value.name !== "string" || !value.name.trim()) - throw new Error("create_worktree name is required"); - if (typeof value.repository !== "string" || !value.repository.trim()) - throw new Error("create_worktree repository is required"); - if ( - value.branch !== undefined && - (typeof value.branch !== "string" || !value.branch.trim()) - ) - throw new Error("create_worktree branch must be a non-empty string"); - if ( - value.startPoint !== undefined && - (typeof value.startPoint !== "string" || !value.startPoint.trim()) - ) - throw new Error( - "create_worktree startPoint must be a non-empty string", - ); - } - } - if ( - command.type === "steer_queue_item" || - command.type === "replace_queue" || - command.type === "reconcile_queue" - ) { - return await routeQueueCommand(record, command); - } - if (command.type === "reload" && record.managed) { - const settled = - (record.status === "idle" || record.status === "error") && - record.agentRunning !== true; - if (!settled || hasActiveWebSubagents(record.subagents)) - throw new Error( - "Wait for Pi and its subagents to become idle before reloading", - ); - await record.managed.reload(); - await refreshManagedSession(record); - record.status = "idle"; - broadcast(record.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); - slashCommands.invalidate(record.cwd); - return { reloaded: true }; - } - if (command.type === "reload" && hasActiveWebSubagents(record.subagents)) { - throw new Error( - "Wait for Pi and its subagents to become idle before reloading", - ); - } - if (command.type === "reload" && record.agentSockets.size === 0) { - throw new Error(`Session ${record.id} is not active`); - } - if (command.type === "get_commands") { - if (record.managed) { - const { commands } = await record.managed.getCommands(); - return { - commands: slashCommands.toWeb(slashCommands.parse(commands), true), - }; - } - return { - commands: slashCommands.toWeb(await slashCommands.discover(record.cwd)), - }; - } - if ( - (command.type === "create_worktree" || - command.type === "create_worktree_v2") && - hasActiveWebSubagents(record.subagents) - ) { - throw new Error( - "Wait for Pi and its subagents to become idle before creating a worktree", - ); - } - if ( - (command.type === "create_worktree" || - command.type === "create_worktree_v2") && - record.managed - ) { - if (record.status !== "idle") - throw new Error("Wait for Pi to become idle before creating a worktree"); - const previousId = record.id; - const previousFile = record.file - ? canonicalSessionFile(record.file) - : undefined; - if (!previousFile) - throw new Error("The current conversation is not persisted yet"); - const previousManagedWorktree = - record.managedWorktree ?? - managedWorktreeFromEntries(parseSessionFile(previousFile)?.entries ?? []); - record.pendingWorktreeSourceDeletion = { - sessionId: previousId, - sessionFile: previousFile, - }; - const invocation = - "existing" in command - ? `/worktree --existing ${JSON.stringify(command.existing)}` - : `/worktree ${formatWorktreeCreateCommandArgs(command)}`; - try { - await record.managed.worktree(invocation); - await refreshManagedSession(record); - if (record.id === previousId) - throw new Error("Pi did not switch to the worktree session"); - if (existsSync(previousFile)) - throw new Error( - `Source session was not deleted after replacement: ${previousFile}`, - ); - } catch (error) { - if (record.id === previousId) - record.pendingWorktreeSourceDeletion = undefined; - throw error; - } - if ( - previousManagedWorktree && - !hasOtherSessionInWorktree( - sessionsDir, - previousFile, - previousManagedWorktree.path, - ) - ) { - try { - const cleanup = removeManagedWorktree(previousManagedWorktree); - if (cleanup.branchWarning) - console.warn( - `Removed previous worktree ${previousManagedWorktree.path}, but could not delete branch ${previousManagedWorktree.branch}: ${cleanup.branchWarning}`, - ); - } catch (error) { - console.warn( - `Source session was deleted, but previous managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - await hydrateGitMetadata(record); - return { - cancelled: false, - sessionId: record.id, - path: record.cwd, - branch: record.branch, - }; - } - if ( - (command.type === "create_worktree" || - command.type === "create_worktree_v2") && - record.agentSockets.size === 0 - ) { - throw new Error(`Session ${record.id} is not active`); - } - if (record.managed) { - switch (command.type) { - case "get_session_options": { - const [{ models }, { levels }, { commands }] = await Promise.all([ - record.managed.getAvailableModels(), - record.managed.getAvailableThinkingLevels(), - record.managed.getCommands(), - ]); - const webCommands = commands.flatMap((command) => { - const sourceInfo = isRecord(command.sourceInfo) - ? command.sourceInfo - : undefined; - if ( - typeof command.name !== "string" || - command.name === "web-reload" || - (command.source !== "extension" && - command.source !== "prompt" && - command.source !== "skill") - ) - return []; - return [ - { - name: command.name, - description: - typeof command.description === "string" - ? command.description - : undefined, - source: command.source, - location: - sourceInfo && - (sourceInfo.scope === "user" || - sourceInfo.scope === "project" || - sourceInfo.scope === "temporary") - ? sourceInfo.scope - : undefined, - }, - ]; - }); - if (!webCommands.some((command) => command.name === "reload")) { - webCommands.unshift({ - name: "reload", - description: - "Reload extensions, skills, prompts, themes, and context files", - source: "extension", - location: "temporary", - }); - } - const scoped = record.scopedModels ?? []; - const scopedByKey = new Map( - scoped.map((s) => [`${s.provider}/${s.id}`, s]), - ); - const filterByScope = scopedByKey.size > 0; - return { - models: models - .filter((model) => - filterByScope - ? scopedByKey.has( - `${String(model.provider ?? "")}/${String(model.id ?? "")}`, - ) - : true, - ) - .map((model) => ({ - provider: String(model.provider ?? ""), - id: String(model.id ?? ""), - name: String(model.name ?? model.id ?? ""), - reasoning: model.reasoning === true, - thinkingLevels: levels, - })), - thinkingLevels: levels, - commands: webCommands, - }; - } - case "set_model": - await record.managed.setModel(command.provider, command.modelId); - await refreshManagedSession(record); - return; - case "set_thinking_level": - await record.managed.setThinkingLevel(command.level); - await refreshManagedSession(record); - return; - case "prompt": - return await record.managed.prompt( - command.message, - command.streamingBehavior, - command.images, - ); - case "abort": - // Stop is accepted once its RPC request is written. Do not hold the web - // response open while compaction and subagent teardown finish. - await record.managed.abort(); - return { accepted: true }; - case "bash": - return await record.managed.bash(command.command); - case "clone": { - const result = await record.managed.clone(); - await refreshManagedSession(record); - return result; - } - case "fork": { - const result = await record.managed.fork(command.entryId); - await refreshManagedSession(record); - return result; - } - case "get_fork_messages": - return await record.managed.getForkMessages(); - case "set_session_name": - await record.managed.setSessionName(command.name); - return undefined; - case "compact": - return await record.managed.compact(command.customInstructions); - case "extension_ui_response": - return await record.managed.respondToExtensionUi(command); - } - } - if (record.agentSockets.size > 0) { - let externalCommand: ClientCommandMessage["command"] = command; - if ( - command.type === "create_worktree" && - !("existing" in command) && - (command.branch || command.startPoint) - ) { - externalCommand = { ...command, type: "create_worktree_v2" }; - } - if (command.type === "prompt" && command.message.startsWith("/")) { - const commands = await slashCommands.discover(record.cwd); - externalCommand = { - ...command, - message: await expandSlashCommand(commands, command.message, { - rejectExtensionCommands: true, - }), - }; - } - const target = Array.from(record.agentSockets)[0]; - const requestId = randomUUID(); - if (command.type === "abort") { - // Socket delivery is the acknowledgement boundary. New bridges also reply - // before teardown, but this keeps Stop responsive with older bridges. - target.send( - JSON.stringify({ - type: "agent.command", - requestId, - command: externalCommand, - } satisfies { - type: "agent.command"; - requestId: string; - command: ClientCommandMessage["command"]; - }), - ); - return { accepted: true }; - } - const data = await new Promise((resolve, reject) => { - const timeoutMs = - command.type === "compact" || - command.type === "bash" || - command.type === "create_worktree" || - command.type === "create_worktree_v2" || - command.type === "reload" - ? LONG_RUNNING_COMMAND_TIMEOUT_MS - : 30_000; - let pendingRequest: ExternalPendingRequest; - const timeout = setTimeout(() => { - const owner = pendingRequest.owner ?? record; - owner.externalPending.delete(requestId); - owner.externalRequestTargets.delete(requestId); - const message = "Pi session command timed out"; - reject( - isUncertainRpcDeliveryCommand(command.type) - ? new CommandDeliveryUncertainError(message) - : new Error(message), - ); - }, timeoutMs); - pendingRequest = { - owner: record, - surviveDisconnect: - command.type === "reload" || - command.type === "create_worktree" || - command.type === "create_worktree_v2", - commandType: command.type, - resolve: (value) => { - clearTimeout(timeout); - resolve(value); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - }, - }; - record.externalPending.set(requestId, pendingRequest); - record.externalRequestTargets.set(requestId, target); - try { - target.send( - JSON.stringify({ - type: "agent.command", - requestId, - command: externalCommand, - } satisfies { - type: "agent.command"; - requestId: string; - command: ClientCommandMessage["command"]; - }), - ); - } catch (error) { - record.externalPending.delete(requestId); - record.externalRequestTargets.delete(requestId); - clearTimeout(timeout); - const cause = error instanceof Error ? error : new Error(String(error)); - reject( - isUncertainRpcDeliveryCommand(command.type) - ? new CommandDeliveryUncertainError(cause.message) - : cause, - ); - } - }); - if (command.type === "get_session_options") { - const options = isRecord(data) ? data : {}; - if (Array.isArray(options.commands)) return options; - return { - ...options, - commands: slashCommands.toWeb(await slashCommands.discover(record.cwd)), - }; - } - if (command.type === "reload") slashCommands.invalidate(record.cwd); - return data; - } - if (command.type === "get_fork_messages") - return await runRpcSessionCommand(record, command); - if ( - command.type === "clone" || - command.type === "fork" || - command.type === "set_session_name" || - command.type === "compact" - ) { - if (!record.file) throw new Error(`Session ${record.id} is not active`); - return await runRpcSessionCommand(record, command as RpcSessionCommand); - } - throw new Error( - `Session ${record.id} does not support command ${command.type}`, - ); -} - -async function handleAgentMessage( - socket: Bun.ServerWebSocket, - message: AgentToServerMessage, -): Promise { - if (message.type === "agent.hello") { - socket.data.authed = true; - const hello = message as AgentHelloMessage; - const kind: SessionKind = - hello.session.source === "saved" - ? "saved" - : hello.session.source === "web" - ? "managed" - : "external"; - const helloManagedWorktree = - hello.session.managedWorktree ?? - managedWorktreeFromEntries(hello.entries ?? []); - const helloHistory = boundedWebHistory(hello.entries ?? []); - const authoritativeHistory = - hello.historyMode === "replace" || helloHistory.length > 0; - const record = upsertSession( - hello.session, - kind, - authoritativeHistory ? helloHistory : [], - ); - if (authoritativeHistory) replaceRecordHistory(record, helloHistory); - else if (!record.historyReady && record.file) { - // Bridges loaded before historyMode existed reconnect with an empty hello. - // Restore a bounded disk suffix without hydrating the full JSONL archive. - replaceRecordHistory(record, parseSessionHistoryFile(record.file)); - } - record.preview = - extractPreviewFromHistory(record.history) ?? record.preview; - record.managedWorktree = helloManagedWorktree ?? record.managedWorktree; - // Carry the agent's --models scope onto the record so the model picker - // mirrors what the TUI would show. Empty array means no scope. - record.scopedModels = Array.isArray(hello.scopedModels) - ? hello.scopedModels - : undefined; - record.agentSockets.add(socket); - record.active = true; - record.status = hello.session.status; - record.agentRunning = hello.session.status === "working"; - record.updatedAt = hello.session.updatedAt; - for (const uncertain of record.queue.filter( - (item) => item.deliveryState === "delivering", - )) { - broadcastQueueDelivery( - record, - uncertain, - "uncertain", - "Delivery was interrupted by a daemon restart; edit or remove this item explicitly before resubmitting.", - ); - } - if (record.file) sessionsByFile.set(normalizePath(record.file), record); - broadcastSessionToAll(record); - void hydrateGitMetadata(record); - // Native sessions use the same bounded semantic history as managed sessions; - // no browser connection asks Pi to paint an additional TUI viewport. - broadcast(record.id, { - type: "server.history", - sessionId: record.id, - entries: record.history, - replace: true, - } satisfies ServerHistoryMessage); - void flushWebQueue(record); - return; - } - if (!socket.data.authed) throw new Error("Agent must send agent.hello first"); - if (message.type === "agent.scope") { - const update = message as AgentScopeMessage; - const record = sessions.get(update.sessionId); - if (!record || !record.agentSockets.has(socket)) return; - record.scopedModels = update.scopedModels; - return; - } - if (message.type === "agent.history") { - const update = message as AgentHistoryMessage; - const record = sessions.get(update.sessionId); - if (!record || !record.agentSockets.has(socket)) return; - replaceRecordHistory(record, update.entries); - broadcast(record.id, { - type: "server.history", - sessionId: record.id, - entries: record.history, - replace: true, - } satisfies ServerHistoryMessage); - return; - } - if (message.type === "agent.session_replaced") { - await completeExternalSessionReplacement( - socket, - message as AgentSessionReplacedMessage, - ); - return; - } - if (message.type === "agent.event") { - const event = message as AgentEventMessage; - const record = sessions.get(event.sessionId); - if (record) { - record.updatedAt = Date.now(); - let lifecycleChanged = false; - if ( - event.event.type === "agent_start" || - event.event.type === "turn_start" - ) { - record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; - markAgentActivity(record); - } - if ( - event.event.type === "agent_start" || - event.event.type === "turn_start" - ) { - cancelQueueSettleFallback(record); - record.status = "working"; - record.agentRunning = true; - lifecycleChanged = true; - } - if (event.event.type === "agent_end" && !record.compaction) { - markAgentSettling(record); - record.status = - agentEndTerminalNotice(event.event)?.kind === "error" - ? "error" - : "idle"; - record.agentRunning = false; - scheduleQueueSettleFallback(record); - lifecycleChanged = true; - } - if (event.event.type === "compaction_start") { - markAgentSettling(record); - record.status = "working"; - record.agentRunning = true; - record.compaction = { - reason: - event.event.reason === "manual" || event.event.reason === "overflow" - ? event.event.reason - : "threshold", - startedAt: - typeof event.event.startedAt === "number" - ? event.event.startedAt - : Date.now(), - }; - } - if (event.event.type === "compaction_end") { - record.compaction = undefined; - if (event.event.aborted === true || event.event.willRetry === false) { - record.status = "idle"; - record.agentRunning = false; - scheduleQueueSettleFallback(record); - lifecycleChanged = true; - } - if (event.event.aborted !== true) { - broadcastCompactionNotice(record); - } - } - if ( - event.event.type === "agent_settled" && - isCurrentAgentSettlement(record) - ) { - cancelQueueSettleFallback(record); - record.settlingGeneration = undefined; - lifecycleChanged = true; - // Pi emits agent_settled only when no retry, compaction, or internal - // follow-up remains. It is authoritative even when an interrupted - // overflow compaction last advertised willRetry=true. - if (record.status !== "error") record.status = "idle"; - record.agentRunning = false; - void flushWebQueue(record); - } - const subagentsChanged = updateSubagentsFromToolEvent( - record, - event.event, - ); - let sessionMetadataChanged = false; - if (event.event.type === "session_info_changed") { - record.name = - typeof event.event.name === "string" && event.event.name - ? event.event.name - : undefined; - sessionMetadataChanged = true; - } - if (event.event.type === "message_end" && isRecord(event.event.message)) { - appendRecordHistory(record, { - type: "message", - id: randomUUID(), - parentId: null, - timestamp: new Date().toISOString(), - message: event.event.message, - }); - record.messageCount += 1; - if ( - event.event.message.role === "assistant" || - event.event.message.role === "toolResult" - ) { - record.usage ??= zeroWebUsage(); - addWebUsage(record.usage, event.event.message.usage); - } - if ( - event.event.message.role === "user" || - event.event.message.role === "assistant" - ) { - const preview = extractTextContent(event.event.message.content); - const terminalNotice = assistantTerminalNotice(event.event.message); - if (preview) record.preview = preview.slice(0, 180); - else if (terminalNotice) - record.preview = - `${terminalNotice.title}: ${terminalNotice.detail}`.slice(0, 180); - } - sessionMetadataChanged = true; - } - broadcast(event.sessionId, { - type: "server.event", - sessionId: event.sessionId, - event: event.event, - } satisfies ServerEventMessage); - if ( - sessionMetadataChanged || - lifecycleChanged || - event.event.type === "compaction_start" || - event.event.type === "compaction_end" - ) { - broadcastSessionToAll(record); - } else if (subagentsChanged) { - broadcast(record.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); - } - // PRs are commonly opened during an agent run without changing branches. - // Refresh after completion so the catalog does not retain the pre-PR lookup. - if (event.event.type === "agent_end") void hydrateGitMetadata(record); - } - return; - } - if (message.type === "agent.subagents") { - const update = message as AgentSubagentsMessage; - const record = sessions.get(update.sessionId); - if (!record) return; - record.subagents = mergeWebSubagentUpdates(record.subagents, update.agents); - record.subagentUsage = update.usage; - record.updatedAt = Date.now(); - broadcast(record.id, { - type: "server.event", - sessionId: record.id, - event: { - type: "subagents_update", - agents: update.agents, - usage: update.usage, - }, - } satisfies ServerEventMessage); - if ( - record.status === "idle" && - !hasActiveWebSubagents(record.subagents) && - record.queue.length > 0 - ) - void flushWebQueue(record); - return; - } - if (message.type === "agent.update") { - const update = message as AgentUpdateMessage; - const existing = sessions.get(update.session.id); - // Older bridge runtimes reported `working` again immediately after their - // authoritative agent_end event. Preserve the lifecycle event until a new - // agent_start arrives so completed runs cannot get stuck visually working. - const lifecycleSession = normalizeLegacySessionUpdate( - existing, - update.session, - ); - // Older native bridges keep reporting their initial preview. Preserve - // message_end/file-derived metadata after hello during rolling upgrades. - const session = existing - ? { ...lifecycleSession, preview: existing.preview } - : lifecycleSession; - const catalogChanged = catalogSessionChanged(existing, session); - const gitContextChanged = - existing?.cwd !== session.cwd || existing?.branch !== session.branch; - const record = upsertSession(session, "external", existing?.history ?? []); - record.agentSockets.add(socket); - record.updatedAt = update.session.updatedAt; - if (catalogChanged) broadcastSessionToAll(record); - else - broadcast(update.session.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); - if (gitContextChanged) void hydrateGitMetadata(record); - return; - } - if (message.type === "agent.response") { - const response = message as AgentResponseMessage; - const record = Array.from(sessions.values()).find((candidate) => - candidate.externalPending.has(response.requestId), - ); - if (!record) return; - const pending = record.externalPending.get(response.requestId); - if (!pending) return; - record.externalPending.delete(response.requestId); - record.externalRequestTargets.delete(response.requestId); - if (response.success) pending.resolve(response.data); - else - pending.reject( - new CommandRejectedError(response.error ?? "Agent command failed"), - ); - } -} - -async function deleteSessionRecord( - record: SessionRecord, - file?: string, - shouldCommit: () => boolean = () => true, -): Promise { - const finishQueueQuiescence = preserveRetryAroundQuiescence({ - isArmed: () => record.queueRetryTimer !== undefined, - cancel: () => { - if (record.queueRetryTimer) clearTimeout(record.queueRetryTimer); - record.queueRetryTimer = undefined; - }, - reopen: () => { - record.queueMutationsQuiesced = false; - }, - resume: () => scheduleWebQueueRetry(record), - }); - try { - await quiesceQueueMutations(record); - if (record.queueDirtyWorker) { - await record.queueDirtyWorker.cancelAndDrain(); - record.queueDirtyWorker = undefined; - } - if (!shouldCommit()) { - finishQueueQuiescence(); - return false; - } - // Make queue removal durable before deleting the file, maps, or sockets. - // A failed store write leaves the complete session available for retry. - await queueStoreWriter.mutate(persistedQueues, (queues) => { - queues.delete(record.id); - }); - } catch (error) { - finishQueueQuiescence(); - throw error; - } - - // Queue persistence yields to the event loop. If an agent reconnected while it - // was in flight, restore its queue instead of deleting the newly live record. - if (!shouldCommit()) { - // The durable delete completed, but the record became live again. Restore - // its retained queue through the retrying worker so a transient rollback - // write failure cannot leave an active session with no durable snapshot. - markWebQueueSnapshotDirty(record); - finishQueueQuiescence(); - return false; - } - - if (file) { - try { - rmSync(file, { force: true }); - } catch (error) { - // Restore a non-empty queue before making the failed deletion usable again. - let restoreError: unknown; - try { - await persistWebQueue(record); - } catch (cause) { - restoreError = cause; - } - finishQueueQuiescence(); - const message = error instanceof Error ? error.message : String(error); - if (restoreError) { - throw new Error( - `Failed to delete session file: ${message}; queue rollback failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, - ); - } - throw new Error(`Failed to delete session file: ${message}`); - } - } - - cancelWebQueueWork(record); - sessions.delete(record.id); - if (record.file) sessionsByFile.delete(normalizePath(record.file)); - // Notify sockets subscribed only to the removed session; once the record leaves - // the maps, a catalog-wide broadcast cannot find them. Keep browser sockets open - // so they can subscribe to a surviving session without reconnecting. - sendSessionRemoved(record.id, undefined, record.clientSockets); - for (const socket of record.clientSockets) socket.data.sessionId = undefined; - record.clientSockets.clear(); - for (const socket of record.agentSockets) { - try { - socket.close(); - } catch { - // ignore - } - } - return true; -} - -async function stopRecord(record: SessionRecord): Promise { - if (record.managed) { - await record.managed.shutdown(); - record.managed = undefined; - } - record.active = false; - record.status = "offline"; -} - -function hasStagedOrDurableReplacement(record: SessionRecord): boolean { - const sourceFile = record.file; - if (!sourceFile) return false; - try { - const sourceName = basename(sourceFile); - if ( - readdirSync(dirname(sourceFile)).some( - (entry) => - entry.startsWith(`${sourceName}.replaced-`) && entry.endsWith(".tmp"), - ) - ) - return true; - } catch { - // The containing session directory may itself have been removed. - } - for (const scan of scanSavedSessions(sessionsDir)) { - if (sessionFileKey(scan.file) === sessionFileKey(sourceFile)) continue; - const replacement = scan.replacement; - if ( - replacement?.previousSessionId === record.id && - sessionFileKey(replacement.previousSessionFile) === - sessionFileKey(sourceFile) - ) - return true; - } - return false; -} - -function isMissingInactiveSession(record: SessionRecord): boolean { - return Boolean( - record.file && - !record.active && - !record.managed && - record.agentSockets.size === 0 && - isConfirmedMissingPath(record.file) && - !hasStagedOrDurableReplacement(record), - ); -} - -async function reconcileMissingSessionFiles(): Promise { - if (shutdownStarted) return; - const candidates = [...sessions.values()].filter( - (record) => - !missingSessionReconciliations.has(record) && - isMissingInactiveSession(record), - ); - await Promise.all( - candidates.map(async (record) => { - missingSessionReconciliations.add(record); - const sessionFile = record.file; - try { - if ( - !sessionFile || - sessions.get(record.id) !== record || - !isMissingInactiveSession(record) - ) - return; - const managed = isManagedSessionFile(sessionFile); - let managedWorktree = record.managedWorktree; - if ( - managedWorktree && - hasOtherSessionInWorktree( - sessionsDir, - sessionFile, - managedWorktree.path, - ) - ) { - managedWorktree = undefined; - } - const deleted = await deleteSessionRecord( - record, - undefined, - () => - sessions.get(record.id) === record && - isMissingInactiveSession(record), - ); - if (!deleted) return; - if (managed) { - try { - deleteManagedSessionFile(sessionFile); - } catch (error) { - console.warn( - `Externally deleted session ${record.id} was removed from Pi web, but managed ownership cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - if ( - managedWorktree && - existsSync(managedWorktree.path) && - !hasOtherSessionInWorktree( - sessionsDir, - sessionFile, - managedWorktree.path, - ) - ) { - try { - const result = removeManagedWorktree(managedWorktree); - if (result.branchWarning) - console.warn( - `Removed externally deleted session worktree ${managedWorktree.path}, but could not delete branch ${managedWorktree.branch}: ${result.branchWarning}`, - ); - } catch (error) { - console.warn( - `Externally deleted session ${record.id} was removed from Pi web, but managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } catch (error) { - console.warn( - `Could not remove externally deleted session ${record.id} from Pi web: ${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - missingSessionReconciliations.delete(record); - } - }), - ); -} - -function scheduleManagedWorktreeCleanup( - sessionId: string, - sessionFile: string, - managedWorktree: NonNullable, -): void { - const timer = setTimeout(() => { - void (async () => { - // A new session may claim this checkout after durable deletion yields. - if ( - hasOtherSessionInWorktree( - sessionsDir, - sessionFile, - managedWorktree.path, - ) - ) - return; - try { - const result = await removeManagedWorktreeAsync(managedWorktree); - if (result.branchWarning) - console.warn( - `Removed worktree ${managedWorktree.path}, but could not delete branch ${managedWorktree.branch}: ${result.branchWarning}`, - ); - } catch (error) { - console.warn( - `Session ${sessionId} was deleted, but managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - })(); - }, 0); - timer.unref?.(); -} - -async function deleteSession(sessionId: string): Promise { - let record = - sessions.get(sessionId) ?? - sessionsByFile.get(normalizePath(sessionId)) ?? - (() => { - const scan = scanSavedSessions(sessionsDir).find( - (item) => - item.session.id === sessionId || - normalizePath(item.file) === normalizePath(sessionId), - ); - return scan - ? upsertSession( - scan.session, - "saved", - scan.history, - scan.managedWorktreeScanned, - ) - : undefined; - })(); - if (!record) throw new Error(`Unknown session: ${sessionId}`); - if (record.file) { - const pendingStart = managedSessionStarts.get(sessionFileKey(record.file)); - if (pendingStart) { - await pendingStart.catch(() => undefined); - record = - sessions.get(sessionId) ?? - sessionsByFile.get(sessionFileKey(record.file)) ?? - record; - } - } - const sessionFile = record.file; - let managedWorktree = record.managedWorktree; - if (!managedWorktree && sessionFile && !record.managedWorktreeScanned) { - managedWorktree = readManagedWorktreePrefix(sessionFile); - record.managedWorktreeScanned = true; - } - if ( - managedWorktree && - sessionFile && - hasOtherSessionInWorktree(sessionsDir, sessionFile, managedWorktree.path) - ) { - managedWorktree = undefined; - } - if (record.kind === "external" && record.agentSockets.size > 0) { - if (record.status === "working") - throw new Error( - "Abort or wait for the active session before deleting it", - ); - await routeCommand(record, { type: "shutdown" }); - const deadline = Date.now() + 3_000; - while (record.agentSockets.size > 0 && Date.now() < deadline) - await Bun.sleep(50); - if (record.agentSockets.size > 0) - throw new Error("The active Pi process did not shut down in time"); - } - await stopRecord(record); - let file: string | undefined; - if (record.file) { - try { - file = canonicalSessionFile(record.file); - } catch (error) { - if ( - !( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) - ) { - throw new Error( - `Refusing to delete unsafe session path: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } - await deleteSessionRecord(record, file); - if (sessionFile && isManagedSessionFile(sessionFile)) { - try { - deleteManagedSessionFile(sessionFile); - } catch (error) { - console.warn( - `Session ${sessionId} was deleted, but managed ownership cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - // Native shutdown and durable queue deletion may yield long enough for another - // Pi process to create a session in this checkout. Ownership can only be - // revoked here; never enable cleanup that was not part of the original request. - if ( - managedWorktree && - sessionFile && - hasOtherSessionInWorktree(sessionsDir, sessionFile, managedWorktree.path) - ) { - managedWorktree = undefined; - } - // Durable deletion and client notification are complete before best-effort - // worktree cleanup. Run slow checkout removal outside the HTTP request so a - // reverse proxy cannot report a false failure after the session is gone. - if (managedWorktree && sessionFile) - scheduleManagedWorktreeCleanup(sessionId, sessionFile, managedWorktree); -} - -/** Drop a not-yet-activated initial session file and its registrations. */ -function cleanupInitialSessionFile(initialSessionFile?: string): void { - if (!initialSessionFile) return; - const key = sessionFileKey(initialSessionFile); - const stale = sessionsByFile.get(key); - if (stale?.file && sessionFileKey(stale.file) === key) { - sessions.delete(stale.id); - sessionsByFile.delete(key); - } - if (isManagedSessionFile(initialSessionFile)) { - try { - deleteManagedSessionFile(initialSessionFile); - } catch { - /* preserve the original startup error */ - } - } - rmSync(initialSessionFile, { force: true }); -} - -async function handleApi(request: Request): Promise { - const url = new URL(request.url); - if ( - url.pathname.startsWith("/api/") && - request.method !== "GET" && - request.method !== "HEAD" && - request.method !== "OPTIONS" && - !isTrustedBrowserOrigin( - request, - tailscaleStatus.published ? tailscaleStatus.url : undefined, - ) - ) { - return textResponse("Forbidden origin", { status: 403 }); - } - if (request.method === "GET" && url.pathname === "/api/health") { - return jsonResponse({ - ok: true, - pid: process.pid, - port, - stateFile: stateFilePath, - capabilities: { - commandHello: true, - queueSteer: true, - worktreeRefs: true, - branchSuggestions: true, - }, - assets: webAssetsServable(), - root: rootDir, - tailscale: tailscaleStatus, - }); - } - if (request.method === "GET" && url.pathname === "/api/directories") { - return jsonResponse({ - directories: listDirectorySuggestions(url.searchParams.get("q") ?? "", { - baseDir: rootDir, - }), - }); - } - if (request.method === "GET" && url.pathname === "/api/branches") { - const requestedCwd = (url.searchParams.get("cwd") ?? "").trim(); - if (!requestedCwd) return badRequest("Missing cwd"); - let cwd: string; - try { - cwd = resolveWebCwd(requestedCwd, { baseDir: rootDir }); - } catch (error) { - return badRequest(error instanceof Error ? error.message : String(error)); - } - try { - if (!statSync(cwd).isDirectory()) - return badRequest(`cwd is not a directory: ${cwd}`); - } catch { - return badRequest(`cwd does not exist: ${cwd}`); - } - let branches: RepositoryBranches; - try { - branches = listRepositoryBranches(cwd); - } catch (error) { - // Not a Git repository (or Git is unavailable): the browser just shows - // no suggestions instead of surfacing an error mid-typing. - return jsonResponse({ - local: [], - remote: [], - error: error instanceof Error ? error.message : String(error), - }); - } - return jsonResponse(branches); - } - if (request.method === "POST" && url.pathname === "/api/tailscale") { - const body = (await request.json().catch(() => undefined)) as - | { - enabled?: unknown; - httpsPort?: unknown; - serviceName?: unknown; - current?: unknown; - } - | undefined; - if (!body || typeof body.enabled !== "boolean") - return badRequest("Missing enabled boolean"); - const persisted = await readTailscaleWebSettings(settingsPath); - const suppliedCurrent = isRecord(body.current) ? body.current : undefined; - const current: TailscaleWebSettings = - suppliedCurrent && typeof suppliedCurrent.enabled === "boolean" - ? { - enabled: suppliedCurrent.enabled, - httpsPort: - typeof suppliedCurrent.httpsPort === "number" && - Number.isInteger(suppliedCurrent.httpsPort) && - suppliedCurrent.httpsPort >= 1 && - suppliedCurrent.httpsPort <= 65_535 - ? suppliedCurrent.httpsPort - : persisted.httpsPort, - // The persisted route identity is authoritative; a browser may report - // the previously applied port, but cannot select another Service to remove. - serviceName: persisted.serviceName, - } - : persisted; - const settings: TailscaleWebSettings = { - enabled: body.enabled, - httpsPort: - typeof body.httpsPort === "number" && - Number.isInteger(body.httpsPort) && - body.httpsPort >= 1 && - body.httpsPort <= 65_535 - ? body.httpsPort - : persisted.httpsPort, - serviceName: - typeof body.serviceName === "string" - ? body.serviceName.trim().replace(/^svc:/, "") || undefined - : persisted.serviceName, - }; - const status = settings.enabled - ? await configureTailscaleServe(settings, current) - : await removeTailscaleServe(current); - return jsonResponse({ tailscale: status }); - } - if (request.method === "GET" && url.pathname === "/api/sessions") { - await reconcileMissingSessionFiles(); - const scans = scanSavedSessions(sessionsDir, activeSessionFiles()); - for (const scan of scans) { - const existing = - sessions.get(scan.session.id) ?? - sessionsByFile.get(normalizePath(scan.file)); - if (!existing || !existing.active || existing.status === "offline") { - upsertSession( - scan.session, - "saved", - scan.history, - scan.managedWorktreeScanned, - ); - } - } - const merged = new Map(); - for (const scan of scans) { - const live = - sessions.get(scan.session.id) ?? - sessionsByFile.get(normalizePath(scan.file)); - if (live?.catalogReady === false) continue; - merged.set( - scan.session.file ? normalizePath(scan.session.file) : scan.session.id, - sessionToClientPayload(scan.session), - ); - } - for (const item of sessions.values()) { - if (item.catalogReady === false || isMissingInactiveSession(item)) - continue; - merged.set( - item.file ? normalizePath(item.file) : item.id, - sessionToClientPayload(item), - ); - } - return jsonResponse({ - sessions: sortSessions(Array.from(merged.values())), - }); - } - if (request.method === "POST" && url.pathname === "/api/sessions") { - const body = (await request.json().catch(() => undefined)) as - | CreateSessionRequest - | undefined; - if (!body) return badRequest("Missing session request"); - const requestedCwd = typeof body.cwd === "string" ? body.cwd.trim() : ""; - const worktreeName = - typeof body.worktreeName === "string" ? body.worktreeName.trim() : ""; - const worktreeBranch = - typeof body.worktreeBranch === "string" ? body.worktreeBranch.trim() : ""; - const worktreeStartPoint = - typeof body.worktreeStartPoint === "string" - ? body.worktreeStartPoint.trim() - : ""; - if (!requestedCwd) return badRequest("Specify a repository or directory"); - if (!worktreeName && (worktreeBranch || worktreeStartPoint)) - return badRequest( - "worktreeBranch and worktreeStartPoint require worktreeName", - ); - - let cwd: string; - try { - cwd = resolveWebCwd(requestedCwd, { baseDir: rootDir }); - } catch (error) { - return badRequest(error instanceof Error ? error.message : String(error)); - } - try { - if (!statSync(cwd).isDirectory()) - return badRequest(`cwd is not a directory: ${cwd}`); - } catch { - return badRequest(`cwd does not exist: ${cwd}`); - } - - let worktree: Awaited> | undefined; - if (worktreeName) { - if (!resolveSessionProject(cwd).id.startsWith("git:")) - return badRequest("Worktree repository is not a Git repository"); - try { - worktree = await createWebWorktree(cwd, worktreeName, { - branch: worktreeBranch || undefined, - startPoint: worktreeStartPoint || undefined, - }); - if (!worktree.existingCheckout) { - worktree = inheritManagedBranchOwnership( - worktree, - [...sessions.values()].map( - (candidate) => candidate.managedWorktree, - ), - ); - } - cwd = worktree.path; - } catch (error) { - return badRequest( - error instanceof Error ? error.message : String(error), - ); - } - } - let session: SessionRecord; - let initialSessionFile: string | undefined; - try { - const manager = SessionManager.create(cwd); - if (worktree) { - manager.appendCustomEntry( - WORKTREE_SESSION_ENTRY, - worktree.existingCheckout - ? { managed: false } - : { - path: worktree.path, - repoRoot: worktree.repoRoot, - name: worktree.name, - branch: worktree.branch, - branchCreated: worktree.branchCreated, - }, - ); - } - if (body.name?.trim()) manager.appendSessionInfo(body.name.trim()); - initialSessionFile = persistInitialSession(manager); - session = await createManagedSession(cwd, body.name, initialSessionFile); - if (worktree && !worktree.existingCheckout) - session.managedWorktree = worktree; - } catch (error) { - const startupMessage = - error instanceof Error ? error.message : String(error); - if (worktree) { - // An entered pre-existing checkout was not created here; clean up the - // stale initial session instead of retaining it for inspection. - if (worktree.existingCheckout) { - cleanupInitialSessionFile(initialSessionFile); - throw new Error(startupMessage); - } - throw new Error( - `${startupMessage}; initialized worktree retained at ${worktree.path} for inspection`, - ); - } - cleanupInitialSessionFile(initialSessionFile); - throw error; - } - return jsonResponse( - { session: sessionToClientPayload(session), worktree }, - { status: 201 }, - ); - } - if (request.method === "POST" && url.pathname === "/api/sessions/resume") { - const body = (await request.json().catch(() => undefined)) as - | ResumeSessionRequest - | undefined; - if (!body || typeof body.file !== "string" || !body.file.trim()) - return badRequest("Missing file"); - let file: string; - try { - file = canonicalSessionFile(resolve(rootDir, body.file)); - } catch (error) { - return badRequest( - error instanceof Error ? error.message : "Session file does not exist", - ); - } - const scan = parseSessionMetadataFile(file); - if (!scan) return badRequest("Invalid session file"); - const existing = sessionsByFile.get(normalizePath(file)); - if (existing?.active && existing.status !== "offline") { - return jsonResponse( - { error: "Session is already active" }, - { status: 409 }, - ); - } - const session = await createManagedSession( - scan.session.cwd, - scan.session.name, - file, - ); - return jsonResponse( - { session: sessionToClientPayload(session) }, - { status: 201 }, - ); - } - if (request.method === "DELETE") { - const pathname = url.pathname; - const match = /^\/api\/sessions\/([^/]+)$/.exec(pathname); - if (!match) return notFound(); - try { - await deleteSession(decodeURIComponent(match[1])); - return jsonResponse({ ok: true }); - } catch (error) { - return jsonResponse( - { error: error instanceof Error ? error.message : String(error) }, - { status: 400 }, - ); - } - } - return notFound(); -} - -function handleWebSocketOpen(socket: Bun.ServerWebSocket): void { - if (socket.data.kind === "client") - attachClientSocket(socket as Bun.ServerWebSocket); - else attachAgentSocket(socket as Bun.ServerWebSocket); -} - -async function handleWebSocketMessage( - socket: Bun.ServerWebSocket, - data: string | Uint8Array, -): Promise { - const parsed = parseSocketMessage< - ClientToServerMessage | AgentToServerMessage - >(data); - if (!parsed) { - socket.close(1003, "Invalid JSON"); - return; - } - try { - if (socket.data.kind === "client") - await handleClientMessage( - socket as Bun.ServerWebSocket, - parsed as ClientToServerMessage, - ); - else - await handleAgentMessage( - socket as Bun.ServerWebSocket, - parsed as AgentToServerMessage, - ); - } catch (error) { - try { - const frame: unknown = parsed; - const requestId = - isRecord(frame) && typeof frame.requestId === "string" - ? frame.requestId - : undefined; - if (socket.data.kind === "client" && requestId) { - socket.send( - JSON.stringify({ - type: "server.response", - requestId, - success: false, - error: error instanceof Error ? error.message : String(error), - } satisfies ServerResponseMessage), - ); - } else { - console.error("Pi web agent message failed:", error); - socket.close(1011, "WebSocket message failed"); - } - } catch { - // ignore - } - } -} - -function handleWebSocketClose(socket: Bun.ServerWebSocket): void { - if (socket.data.kind === "client") { - connectedClientSockets.delete( - socket as Bun.ServerWebSocket, - ); - for (const record of sessions.values()) { - if ( - !record.clientSockets.delete( - socket as Bun.ServerWebSocket, - ) - ) - continue; - } - return; - } - for (const record of sessions.values()) { - if ( - !record.agentSockets.delete( - socket as Bun.ServerWebSocket, - ) - ) - continue; - for (const [requestId, target] of record.externalRequestTargets.entries()) { - if (target !== socket) continue; - record.externalRequestTargets.delete(requestId); - const pending = record.externalPending.get(requestId); - if (pending?.surviveDisconnect) continue; - if (pending) { - record.externalPending.delete(requestId); - pending.reject( - pending.commandType && - isUncertainRpcDeliveryCommand(pending.commandType) - ? new CommandDeliveryUncertainError( - `Agent socket closed before ${pending.commandType} acknowledgement`, - ) - : new Error("Agent socket closed"), - ); - } - } - if (record.agentSockets.size === 0 && record.kind === "external") { - record.status = "offline"; - record.active = false; - broadcastSessionToAll(record); - } - } -} - -async function cleanupAndExit(code = 0): Promise { - // Repeated TERM/INT delivery is common during deploys. Never reinterpret it as - // permission to abort managed work; an operator can still use SIGKILL for a - // truly wedged process. - if (shutdownStarted) return; - shutdownStarted = true; - if (missingSessionReconcileTimer) clearInterval(missingSessionReconcileTimer); - missingSessionReconcileTimer = undefined; - const busyNames = () => - [...sessions.values()] - .filter(shouldWaitForManagedShutdown) - .map((record) => record.name ?? record.id); - let busy = busyNames(); - if (busy.length > 0) - console.error( - `Waiting for active managed sessions before restart: ${busy.join(", ")}`, - ); - while (shouldContinueManagedShutdownWait(busy.length)) { - await Bun.sleep(100); - busy = busyNames(); - } - if (busy.length > 0) - console.error( - `Proceeding with shutdown while sessions remain active: ${busy.join(", ")}`, - ); - // Stop admitting queue work and drain each per-session mutation tail before the - // final snapshot. This prevents a late mutation from racing or following flush. - const records = [...sessions.values()]; - // A single rejected mutation tail must not skip the remaining cleanup or the - // final exit; each step is individually best-effort. - await Promise.all( - records.map((record) => - quiesceQueueMutations(record).catch(() => undefined), - ), - ); - // Accepted queue items are removed in memory before their durable snapshot may - // finish. Drain any existing write before the final write, all within one bound. - await Promise.all( - records.map(async (record) => { - if (record.queueDirtyWorker) - await record.queueDirtyWorker - .flushAndCancel(1_000) - .catch(() => undefined); - }), - ); - for (const record of sessions.values()) { - cancelWebQueueWork(record); - try { - await stopRecord(record); - } catch { - // ignore - } - } - try { - server?.stop(); - } catch { - // ignore - } - try { - if (readStateFile(stateFilePath)?.pid === process.pid) - rmSync(stateFilePath, { force: true }); - } catch { - // ignore - } - setTimeout(() => process.exit(code), 25).unref(); -} - -function webAssetsServable(): boolean { - try { - return statSync(join(distDir, "index.html")).isFile(); - } catch { - return false; - } -} - /** - * Decide whether this process may own machine-wide discovery (state file and - * Tailscale Serve route). A healthy daemon that already exists is deferred to; - * a daemon whose checkout no longer exists is replaced. Without this, a - * side-port daemon steals the shared Serve route and a daemon from a deleted - * checkout serves 404s forever while still passing /api/health. + * Pi web daemon entry point. All server features live in the sibling modules + * (see `webServerApp.ts` for the composition root); this file only boots the + * app so `bun run web/server/index.ts` keeps spawning the daemon directly. */ -type DaemonOwnership = - | { action: "own" } - | { action: "abort"; reason: string } - | { action: "defer"; owner: DaemonHealth; republished: boolean }; - -async function ownershipForIncumbent( - health: DaemonHealth, - republished: boolean, -): Promise { - switch (incumbentDisposition(health)) { - case "serve": - case "keep": - // Defer to the incumbent (keep = failed build over an existing - // checkout; respawning from the same checkout would fail identically). - return { action: "defer", owner: health, republished }; - case "evict": { - const stopped = await terminatePiWebDaemon(health.pid); - if (stopped) return { action: "own" }; - // The incumbent still holds the port; never race it for the listener. - return { - action: "abort", - reason: `pid ${health.pid} still holds port ${health.port} and could not be stopped`, - }; - } - } -} - -async function resolveDaemonOwnership(): Promise { - const existing = readStateFile(stateFilePath); - if (existing && existing.pid !== process.pid && isPidAlive(existing.pid)) { - // Only a verified Pi web daemon may be probed or stopped; a recycled pid - // naming an unrelated process must never be signaled. - if (await isPiWebDaemonPid(existing.pid)) { - const health = await waitForDaemonHealth( - existing.port, - existing.pid, - 3_000, - ); - if (!health) { - // Verified daemon that never answers: wedged. Replace it, but only - // claim ownership when it actually stopped; otherwise another process - // still holds the port and Bun.serve would fail to listen. - if (await terminatePiWebDaemon(existing.pid)) return { action: "own" }; - return { - action: "abort", - reason: `pid ${existing.pid} still holds port ${existing.port} and could not be stopped`, - }; - } - // The state file already names the incumbent, so it needs no rewrite. - return ownershipForIncumbent(health, false); - } - return { action: "own" }; - } - // Discovery state is missing or points at a dead pid. When the intended - // port already has a healthy daemon, adopt it by restoring its state file - // instead of racing it for the port; evict it only when provably broken. - if (port !== 0) { - const health = await waitForDaemonHealth(port, undefined, 1_500); - if ( - health && - isPidAlive(health.pid) && - (await isPiWebDaemonPid(health.pid)) - ) { - return ownershipForIncumbent(health, true); - } - } - return { action: "own" }; -} - -// web/dist is not checked in; rebuild it on every startup so a long-running -// server always serves the client assets that match the checked-out source. -async function buildWebClientAssets(): Promise { - // Test suites opt out of the per-startup rebuild: they spawn ~30 daemons per - // run and never exercise asset freshness, and each rebuild puts the state - // file behind a full vite build. Skip only when assets already exist. - if (process.env.PI_WEB_SKIP_ASSET_BUILD === "1") { - try { - if (statSync(join(distDir, "index.html")).isFile()) return; - } catch { - // No built assets yet; fall through and build them. - } - } - console.log("Building web client assets..."); - const build = Bun.spawn({ - cmd: ["bun", "run", "webBuild"], - cwd: rootDir, - stdout: "inherit", - stderr: "inherit", - }); - const code = await build.exited; - if (code !== 0) - console.error( - `webBuild exited with code ${code}; serving whatever assets already exist in ${distDir}.`, - ); -} +import { createWebServerApp } from "./webServerApp.js"; -async function main(): Promise { - // Signals must be handled before ownership resolution: eviction can wait a - // long time for the incumbent's graceful exit, and an interrupt during that - // window would otherwise kill this process with default handlers mid-eviction. - process.on("SIGINT", () => void cleanupAndExit(0)); - process.on("SIGTERM", () => void cleanupAndExit(0)); - process.on("exit", () => { - try { - if (readStateFile(stateFilePath)?.pid === process.pid) - rmSync(stateFilePath, { force: true }); - } catch { - // ignore - } - }); - const ownership = await resolveDaemonOwnership(); - if (ownership.action === "abort") { - console.error(`pi web server could not start: ${ownership.reason}`); - process.exit(1); - } - if (ownership.action === "defer") { - // Another daemon already owns machine-wide discovery. Restoring its state - // file (when missing) lets Pi sessions find it; never touch its Tailscale - // Serve route, never race it for the port. Exit quietly instead. - if (ownership.republished) { - ensureDir(dirname(stateFilePath)); - writeStateFileAtomic(stateFilePath, { - pid: ownership.owner.pid, - port: ownership.owner.port, - startedAt: Date.now(), - version: WEB_STATE_VERSION, - ...(isRecord(ownership.owner.tailscale) - ? { tailscale: ownership.owner.tailscale as TailscaleStatus } - : {}), - }); - } - console.log( - `pi web server already running (pid ${ownership.owner.pid} on port ${ownership.owner.port}); deferring`, - ); - process.exit(0); - } - await buildWebClientAssets(); - await recoverStagedSourceSessionDeletions(); - webState = getOrCreateWebState(); - server = Bun.serve({ - hostname: host, - port, - async fetch(request, serverInstance) { - const url = new URL(request.url); - if (url.pathname === "/ws/client") { - // Browser WebSockets are not covered by CORS. Require the initiating - // page to have the exact host served directly or forwarded by Tailscale. - // This preserves tokenless iOS bookmarks while preventing arbitrary web - // origins from driving shell-capable sessions. - if ( - !isTrustedBrowserOrigin( - request, - tailscaleStatus.published ? tailscaleStatus.url : undefined, - ) - ) - return new Response("Forbidden WebSocket origin", { status: 403 }); - const upgraded = serverInstance.upgrade(request, { - data: { - kind: "client", - id: randomUUID(), - authed: false, - } as SocketData, - }); - return upgraded - ? undefined - : new Response("Upgrade failed", { status: 400 }); - } - if (url.pathname === "/ws/agent") { - // The Pi bridge connects directly to localhost without Origin or proxy - // headers. Tailscale Serve forwards this route too, so Origin absence alone - // must not let a tailnet client impersonate an agent. - const forwarded = [ - "forwarded", - "x-forwarded-for", - "x-forwarded-host", - "x-forwarded-proto", - ].some((header) => request.headers.has(header)); - if (request.headers.has("origin") || forwarded) - return new Response("Forbidden agent WebSocket", { status: 403 }); - const upgraded = serverInstance.upgrade(request, { - data: { - kind: "agent", - id: randomUUID(), - authed: false, - } as SocketData, - }); - return upgraded - ? undefined - : new Response("Upgrade failed", { status: 400 }); - } - try { - const asset = staticAssetResponse(request); - return asset ?? (await handleApi(request)); - } catch (error) { - return internalError( - error instanceof Error ? error.message : String(error), - ); - } - }, - websocket: { - maxPayloadLength: MAX_WEBSOCKET_PAYLOAD_BYTES, - open(socket) { - handleWebSocketOpen(socket as Bun.ServerWebSocket); - }, - message(socket, data) { - void handleWebSocketMessage( - socket as Bun.ServerWebSocket, - data as string | Uint8Array, - ); - }, - close(socket) { - handleWebSocketClose(socket as Bun.ServerWebSocket); - }, - }, - }); - port = server.port ?? port; - webState = { ...webState, port }; - // Configure Serve only after localhost is listening, then publish discovery - // state with the final tailnet URL. Simultaneous startup losers never acquire - // the port and therefore cannot overwrite the winning server's state. - await configureTailscaleServe(); - console.log(`pi web server listening on http://${host}:${port}`); - missingSessionReconcileTimer = setInterval( - () => void reconcileMissingSessionFiles(), - MISSING_SESSION_RECONCILE_INTERVAL_MS, - ); - missingSessionReconcileTimer.unref?.(); - // Keep readiness independent from JSONL catalog work, then restore only the - // browser-owned sessions that were active before the daemon stopped. Native - // bridge sessions reconnect themselves and are never started in RPC mode here. - setTimeout( - () => - void restoreManagedSessions().catch((error) => { - console.error("Could not restore managed web sessions:", error); - }), - 250, - ).unref(); -} +const app = createWebServerApp(); -void main().catch((error) => { +void app.lifecycle.start().catch((error) => { console.error(error); process.exit(1); }); diff --git a/web/server/managedSessionCreate.ts b/web/server/managedSessionCreate.ts new file mode 100644 index 0000000..51a20dd --- /dev/null +++ b/web/server/managedSessionCreate.ts @@ -0,0 +1,466 @@ +import { randomUUID } from "node:crypto"; +import { + agentEndTerminalNotice, + assistantTerminalNotice, +} from "../assistant-message.js"; +import { messagesToWebHistory } from "../history.js"; +import type { + ServerEventMessage, + ServerHistoryMessage, + ServerSessionMessage, +} from "../protocol.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { CompactionNotice } from "./compactionNotice.js"; +import type { GitMetadata } from "./gitMetadata.js"; +import type { ManagedSessionRefresh } from "./managedSessionRefresh.js"; +import { + preserveRetryAroundQuiescence, + quiesceQueueMutations, +} from "./queue-mutation.js"; +import type { RecordSync } from "./recordSync.js"; +import type { RpcSessionFactory } from "./rpcSessions.js"; +import type { + SessionFileCatalog, + SessionQueueCoordinator, + SessionRecord, +} from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { ServerStores } from "./serverStores.js"; +import type { SessionHistory } from "./sessionHistory.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; + +/** + * Starts managed RPC sessions (web-owned Pi runtimes), including the live + * event handler that keeps the record, history, and queue in sync, plus + * daemon-restart restoration of previously active managed sessions. + */ +export function createManagedSessionLauncher(options: { + state: ServerRuntimeState; + catalog: SessionFileCatalog; + stores: ServerStores; + history: SessionHistory; + recordSync: RecordSync; + queue: SessionQueueCoordinator; + broadcast: ClientBroadcast; + registry: SessionRegistry; + refresh: ManagedSessionRefresh; + git: GitMetadata; + compactionNotice: CompactionNotice; + rpcSessions: RpcSessionFactory; +}) { + const { + state: runtime, + catalog, + stores, + history, + recordSync, + queue, + broadcast, + registry, + refresh, + git, + compactionNotice, + rpcSessions, + } = options; + const { + normalizePath, + canonicalSessionFile, + isManagedSessionFile, + deleteManagedSessionFile, + replaceManagedSessionFile, + parseSessionMetadataFile, + parseSessionHistoryFile, + isRecord, + zeroWebUsage, + addWebUsage, + extractTextContent, + compactionEntryFromEvent, + } = catalog; + const { + cloneWebQueue, + scheduleWebQueueRetry, + markAgentActivity, + markAgentSettling, + isCurrentAgentSettlement, + cancelQueueSettleFallback, + scheduleQueueSettleFallback, + flushWebQueue, + } = queue; + const { persistedQueues } = stores; + const { replaceRecordHistory, appendRecordHistory } = history; + const { + updateRecordFromState, + updateRecordFromStats, + updateSubagentsFromToolEvent, + } = recordSync; + const { + broadcast: broadcastToSessionClients, + broadcastSessionToAll, + sendSessionHistory, + } = broadcast; + const { sessionToClientPayload } = registry; + const { sendSessionState } = queue; + const { refreshManagedSession } = refresh; + const { hydrateGitMetadata } = git; + const { broadcastCompactionNotice } = compactionNotice; + const { createRpcSession } = rpcSessions; + + async function createManagedSessionUnlocked( + cwd: string, + name?: string, + sessionFile?: string, + ): Promise { + const resumed = sessionFile + ? parseSessionMetadataFile(sessionFile) + : undefined; + const existingRecord = resumed + ? (runtime.sessions.get(resumed.session.id) ?? + runtime.sessionsByFile.get(normalizePath(resumed.file))) + : undefined; + let restoreExistingQueueIntake: (() => void) | undefined; + if (existingRecord) { + restoreExistingQueueIntake = preserveRetryAroundQuiescence({ + isArmed: () => existingRecord.queueRetryTimer !== undefined, + cancel: () => { + if (existingRecord.queueRetryTimer) + clearTimeout(existingRecord.queueRetryTimer); + existingRecord.queueRetryTimer = undefined; + }, + reopen: () => { + existingRecord.queueMutationsQuiesced = false; + }, + resume: () => scheduleWebQueueRetry(existingRecord), + }); + await quiesceQueueMutations(existingRecord); + if (existingRecord.queueDirtyWorker) { + await existingRecord.queueDirtyWorker.cancelAndDrain(); + existingRecord.queueDirtyWorker = undefined; + } + } + const record: SessionRecord = { + id: resumed?.session.id ?? randomUUID(), + file: sessionFile, + cwd, + name: name ?? resumed?.session.name, + model: resumed?.session.model, + thinkingLevel: resumed?.session.thinkingLevel, + status: "starting", + source: "web", + createdAt: resumed?.session.createdAt ?? Date.now(), + updatedAt: Date.now(), + messageCount: resumed?.session.messageCount ?? 0, + preview: resumed?.session.preview, + parentSession: resumed?.session.parentSession, + usage: resumed?.session.usage, + contextUsage: resumed?.session.contextUsage, + kind: "managed", + history: existingRecord?.history ?? resumed?.history ?? [], + historyReady: existingRecord?.historyReady ?? false, + active: true, + agentSockets: new Set(), + clientSockets: existingRecord?.clientSockets ?? new Set(), + externalRequestTargets: new Map(), + externalPending: new Map(), + queue: cloneWebQueue( + existingRecord?.queue ?? + persistedQueues.get(resumed?.session.id ?? "") ?? + [], + ), + // A newly started RPC runtime has no surviving subagents. An explicit empty + // snapshot clears stale browser telemetry retained across daemon reconnects. + subagents: [], + managedWorktree: resumed?.session.managedWorktree, + catalogReady: false, + }; + runtime.sessions.set(record.id, record); + if (record.file) + runtime.sessionsByFile.set(normalizePath(record.file), record); + + const managed = createRpcSession({ + cwd, + name, + sessionFile, + onEvent: (event) => { + record.updatedAt = Date.now(); + updateSubagentsFromToolEvent(record, event); + if (event.type === "agent_start" || event.type === "turn_start") { + record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; + markAgentActivity(record); + } + if (event.type === "agent_start" || event.type === "turn_start") { + cancelQueueSettleFallback(record); + record.status = "working"; + record.agentRunning = true; + } + if (event.type === "agent_end" && !record.compaction) { + markAgentSettling(record); + record.status = + agentEndTerminalNotice(event)?.kind === "error" ? "error" : "idle"; + record.agentRunning = false; + scheduleQueueSettleFallback(record); + } + if (event.type === "compaction_start") { + markAgentSettling(record); + record.status = "working"; + record.agentRunning = true; + record.compaction = { + reason: + event.reason === "manual" || event.reason === "overflow" + ? event.reason + : "threshold", + startedAt: + typeof event.startedAt === "number" + ? event.startedAt + : Date.now(), + }; + } + if (event.type === "compaction_end") { + record.compaction = undefined; + if (event.aborted === true || event.willRetry === false) { + record.status = "idle"; + record.agentRunning = false; + scheduleQueueSettleFallback(record); + } + } + if ( + event.type === "agent_settled" && + isCurrentAgentSettlement(record) + ) { + cancelQueueSettleFallback(record); + record.settlingGeneration = undefined; + // Pi emits agent_settled only when no retry, compaction, or internal + // follow-up remains. It is authoritative even when an interrupted + // overflow compaction last advertised willRetry=true. + if (record.status !== "error") record.status = "idle"; + record.agentRunning = false; + void refreshManagedSession(record, true); + void flushWebQueue(record); + } + + if (event.type === "message_end" && isRecord(event.message)) { + if ( + event.message.role === "assistant" || + event.message.role === "toolResult" + ) { + record.usage ??= zeroWebUsage(); + addWebUsage(record.usage, event.message.usage); + } + appendRecordHistory(record, { + type: "message", + id: randomUUID(), + parentId: null, + timestamp: new Date().toISOString(), + message: event.message, + }); + record.messageCount += 1; + const terminalNotice = assistantTerminalNotice(event.message); + if (terminalNotice && !extractTextContent(event.message.content)) { + record.preview = + `${terminalNotice.title}: ${terminalNotice.detail}`.slice(0, 180); + } + } + const compactionEntry = compactionEntryFromEvent(event); + if (compactionEntry) { + replaceRecordHistory(record, [compactionEntry]); + broadcastToSessionClients(record.id, { + type: "server.history", + sessionId: record.id, + entries: record.history, + replace: true, + } satisfies ServerHistoryMessage); + const runtimeSession = record.managed; + if (runtimeSession) { + // Track the trailing authoritative snapshot so any compaction_end + // notice can wait for it before broadcasting; otherwise the notice + // would be wiped by this history replacement on subscribed clients. + const refresh: Promise = runtimeSession + .getMessages() + .then(({ messages }) => { + if ( + record.managed !== runtimeSession || + runtime.sessions.get(record.id) !== record + ) + return; + replaceRecordHistory(record, messagesToWebHistory(messages)); + broadcastToSessionClients(record.id, { + type: "server.history", + sessionId: record.id, + entries: record.history, + replace: true, + } satisfies ServerHistoryMessage); + }) + .catch((error) => + console.error( + `Could not refresh compacted history for ${record.id}: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + .finally(() => { + if (record.compactionHistoryRefresh === refresh) + record.compactionHistoryRefresh = undefined; + }); + record.compactionHistoryRefresh = refresh; + } + } + if (event.type === "compaction_end" && event.aborted !== true) { + broadcastCompactionNotice(record); + } + broadcastToSessionClients(record.id, { + type: "server.event", + sessionId: record.id, + event, + } satisfies ServerEventMessage); + const catalogChanged = + event.type === "agent_start" || + event.type === "turn_start" || + event.type === "agent_end" || + event.type === "agent_settled" || + event.type === "compaction_start" || + event.type === "compaction_end" || + event.type === "message_end"; + if (catalogChanged) broadcastSessionToAll(record); + else + broadcastToSessionClients(record.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + if (event.type === "agent_end") void hydrateGitMetadata(record); + }, + onExit: () => { + record.status = "offline"; + record.active = false; + record.managed = undefined; + broadcastSessionToAll(record); + }, + }); + record.managed = managed; + const provisionalId = record.id; + try { + await managed.start(); + const state = await managed.getState(); + updateRecordFromState(record, state); + if (record.file) replaceManagedSessionFile(sessionFile, record.file); + if (record.id !== provisionalId) { + runtime.sessions.delete(provisionalId); + runtime.sessions.set(record.id, record); + } + try { + replaceRecordHistory( + record, + messagesToWebHistory((await managed.getMessages()).messages), + ); + } catch { + // A failed context request must not publish a blank active resume. Read only + // a bounded suffix instead of hydrating the append-only session archive. + if (!record.historyReady && record.file) + replaceRecordHistory(record, parseSessionHistoryFile(record.file)); + } + try { + updateRecordFromStats(record, await managed.getSessionStats()); + } catch { + // Keep history-derived usage when stats are unavailable. + } + record.status = "idle"; + record.catalogReady = true; + broadcastSessionToAll(record); + void hydrateGitMetadata(record); + for (const socket of record.clientSockets) { + socket.data.sessionId = record.id; + sendSessionState(socket, record); + sendSessionHistory(socket, record); + } + void flushWebQueue(record); + return record; + } catch (error) { + await managed.shutdown(); + if (runtime.sessions.get(provisionalId) === record) + runtime.sessions.delete(provisionalId); + if (runtime.sessions.get(record.id) === record) + runtime.sessions.delete(record.id); + for (const file of [sessionFile, record.file]) { + if (file && runtime.sessionsByFile.get(normalizePath(file)) === record) + runtime.sessionsByFile.delete(normalizePath(file)); + } + if (existingRecord) { + runtime.sessions.set(existingRecord.id, existingRecord); + if (existingRecord.file) + runtime.sessionsByFile.set( + normalizePath(existingRecord.file), + existingRecord, + ); + restoreExistingQueueIntake?.(); + broadcastSessionToAll(existingRecord); + } + throw error; + } + } + + async function createManagedSession( + cwd: string, + name?: string, + sessionFile?: string, + ): Promise { + if (!sessionFile) return await createManagedSessionUnlocked(cwd, name); + const key = normalizePath(sessionFile); + const existing = runtime.managedSessionStarts.get(key); + if (existing) return await existing; + const start = createManagedSessionUnlocked(cwd, name, sessionFile); + runtime.managedSessionStarts.set(key, start); + try { + return await start; + } finally { + if (runtime.managedSessionStarts.get(key) === start) + runtime.managedSessionStarts.delete(key); + } + } + + async function restoreManagedSessions(): Promise { + for (const storedFile of stores.managedSessionStore.list()) { + let file: string; + try { + file = canonicalSessionFile(storedFile); + } catch { + try { + deleteManagedSessionFile(storedFile); + } catch (error) { + console.error( + `Could not remove missing managed session ${storedFile}:`, + error, + ); + } + continue; + } + const existing = runtime.sessionsByFile.get(normalizePath(file)); + if (existing?.active && existing.status !== "offline") continue; + if (!isManagedSessionFile(file)) continue; + const scan = parseSessionMetadataFile(file); + if (!scan) { + try { + deleteManagedSessionFile(file); + } catch (error) { + console.error( + `Could not remove invalid managed session ${file}:`, + error, + ); + } + continue; + } + try { + await createManagedSession(scan.session.cwd, scan.session.name, file); + } catch (error) { + console.error( + `Could not restore managed web session ${scan.session.id}:`, + error, + ); + } + } + } + + return { + createManagedSession, + restoreManagedSessions, + }; +} + +export type ManagedSessionLauncher = ReturnType< + typeof createManagedSessionLauncher +>; diff --git a/web/server/managedSessionRefresh.ts b/web/server/managedSessionRefresh.ts new file mode 100644 index 0000000..ba1e2ad --- /dev/null +++ b/web/server/managedSessionRefresh.ts @@ -0,0 +1,465 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, readdirSync, renameSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { messagesToWebHistory } from "../history.js"; +import { replacementFromEntries } from "../worktree-replacement.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import { preserveRetryAroundQuiescence } from "./queue-mutation.js"; +import type { RecordSync } from "./recordSync.js"; +import { + runManagedRefresh, + serializeManagedRefresh, +} from "./refresh-policy.js"; +import type { + SessionFileCatalog, + SessionQueueCoordinator, + SessionRecord, +} from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { ServerStores } from "./serverStores.js"; +import type { SessionHistory } from "./sessionHistory.js"; +import { + hasOtherSessionInWorktree, + managedWorktreeFromEntries, + removeManagedWorktree, +} from "./worktrees.js"; + +export type ManagedIdentityTransition = { + rollback(): void; + commit(): void; +}; + +function stageSourceSessionDeletion( + previousFile: string, + nextFile: string, + sessionFileKey: (file: string) => string, + canonicalSessionFile: (file: string) => string, +): ManagedIdentityTransition { + const source = canonicalSessionFile(previousFile); + if (sessionFileKey(source) === sessionFileKey(nextFile)) + throw new Error("Replacement session file must differ from its source"); + const tombstone = `${source}.replaced-${randomUUID()}.tmp`; + renameSync(source, tombstone); + return { + rollback: () => { + if (existsSync(source)) + throw new Error( + `Refusing to overwrite a recreated source session: ${source}`, + ); + if (existsSync(tombstone)) renameSync(tombstone, source); + else throw new Error(`Missing staged source session ${tombstone}`); + }, + commit: () => { + try { + rmSync(tombstone, { force: true }); + } catch (error) { + console.warn( + `Source session was removed, but its staged tombstone could not be cleaned: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + }; +} + +/** + * Transactional identity refresh for managed RPC sessions (clone/fork/worktree + * replacements) plus startup recovery of staged source-session deletions. + */ +export function createManagedSessionRefresh(options: { + state: ServerRuntimeState; + config: WebServerConfig; + catalog: SessionFileCatalog; + stores: ServerStores; + history: SessionHistory; + recordSync: RecordSync; + queue: SessionQueueCoordinator; + broadcast: ClientBroadcast; +}) { + const { + state: runtime, + catalog, + stores, + history, + recordSync, + queue, + broadcast, + } = options; + const { sessionsDir } = options.config; + const { + normalizePath, + sessionFileKey, + canonicalSessionFile, + isManagedSessionFile, + replaceManagedSessionFile, + parseSessionMetadataFile, + parseSessionFile, + listSavedSessionFiles, + isRecord, + } = catalog; + const { persistedQueues, queueStoreWriter, managedSessionStore } = stores; + const { migratePersistedQueue, scheduleWebQueueRetry, cloneWebQueue } = queue; + const { updateRecordFromState, updateRecordFromStats } = recordSync; + const { replaceRecordHistory } = history; + const { broadcastSessionToAll, sendSessionRemoved } = broadcast; + + async function refreshManagedSession( + record: SessionRecord, + suppressErrors = false, + stageIdentityTransition?: ( + previousFile: string, + nextFile: string, + ) => ManagedIdentityTransition, + ): Promise { + await runManagedRefresh( + () => + serializeManagedRefresh(record, async () => { + const managed = record.managed; + if (!managed) return; + let finishQueueMigration: (() => void) | undefined; + let identityTransition: ManagedIdentityTransition | undefined; + try { + const oldId = record.id; + const oldFile = record.file; + const state = await managed.getState(); + const nextState = isRecord(state) ? { ...state } : state; + const newId = + isRecord(nextState) && typeof nextState.sessionId === "string" + ? nextState.sessionId + : oldId; + const newFile = + isRecord(nextState) && typeof nextState.sessionFile === "string" + ? nextState.sessionFile + : oldFile; + const fileChanged = Boolean( + newFile && + (!oldFile || + sessionFileKey(newFile) !== sessionFileKey(oldFile)), + ); + let ownershipMigrated = false; + try { + const pendingDeletion = record.pendingWorktreeSourceDeletion; + let verifiedPendingDeletion = false; + if ( + pendingDeletion && + pendingDeletion.sessionId === oldId && + oldFile && + newFile && + newId !== oldId && + sessionFileKey(pendingDeletion.sessionFile) === + sessionFileKey(oldFile) + ) { + const marker = parseSessionMetadataFile(newFile)?.replacement; + verifiedPendingDeletion = Boolean( + marker && + marker.previousSessionId === oldId && + sessionFileKey(marker.previousSessionFile) === + sessionFileKey(oldFile) && + marker.replacementSessionId === newId, + ); + } + if ( + pendingDeletion && + pendingDeletion.sessionId === oldId && + newId !== oldId && + !verifiedPendingDeletion + ) { + // session_start can be observed before the verified activation marker is + // appended. Leave the old identity intact until the worktree callback commits. + return; + } + const transitionFactory = + stageIdentityTransition ?? + (verifiedPendingDeletion + ? (previousFile: string, nextFile: string) => + stageSourceSessionDeletion( + previousFile, + nextFile, + sessionFileKey, + canonicalSessionFile, + ) + : undefined); + if (oldFile && newFile && newId !== oldId && transitionFactory) { + identityTransition = transitionFactory(oldFile, newFile); + } + if (newFile) { + ownershipMigrated = + fileChanged || !isManagedSessionFile(newFile); + replaceManagedSessionFile(oldFile, newFile); + } + if (newId !== oldId) { + finishQueueMigration = preserveRetryAroundQuiescence({ + isArmed: () => record.queueRetryTimer !== undefined, + cancel: () => { + if (record.queueRetryTimer) + clearTimeout(record.queueRetryTimer); + record.queueRetryTimer = undefined; + }, + reopen: () => { + record.queueMutationsQuiesced = false; + }, + resume: () => scheduleWebQueueRetry(record), + }); + await migratePersistedQueue(record, oldId, newId); + if (isRecord(nextState)) delete nextState.sessionId; + } + } catch (transitionError) { + const rollbackErrors: string[] = []; + if (identityTransition) { + try { + identityTransition.rollback(); + } catch (error) { + rollbackErrors.push( + `source-session rollback failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + identityTransition = undefined; + } + if (oldFile && (fileChanged || newId !== oldId)) { + try { + await managed.switchSession(oldFile); + const restored = await managed.getState(); + if ( + !isRecord(restored) || + restored.sessionId !== oldId || + typeof restored.sessionFile !== "string" || + sessionFileKey(restored.sessionFile) !== + sessionFileKey(oldFile) + ) { + throw new Error( + "Pi did not restore the original session identity", + ); + } + } catch (error) { + rollbackErrors.push( + `runtime rollback failed: ${error instanceof Error ? error.message : String(error)}`, + ); + await managed.shutdown().catch(() => undefined); + record.managed = undefined; + record.active = false; + record.status = "offline"; + } + } else if (!oldFile && fileChanged) { + await managed.shutdown().catch(() => undefined); + record.managed = undefined; + record.active = false; + record.status = "offline"; + } + if (ownershipMigrated && oldFile && newFile) { + try { + replaceManagedSessionFile(newFile, oldFile); + } catch (error) { + rollbackErrors.push( + `ownership rollback failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + finishQueueMigration?.(); + finishQueueMigration = undefined; + const message = + transitionError instanceof Error + ? transitionError.message + : String(transitionError); + throw new Error( + rollbackErrors.length > 0 + ? `${message}; ${rollbackErrors.join("; ")}` + : message, + ); + } + + updateRecordFromState(record, nextState); + try { + replaceRecordHistory( + record, + messagesToWebHistory((await managed.getMessages()).messages), + ); + } catch { + // Keep the last complete bounded history snapshot. + } + try { + updateRecordFromStats(record, await managed.getSessionStats()); + } catch { + // Stats are supplementary; keep history-derived usage. + } + if (oldId !== newId) { + record.id = newId; + runtime.sessions.delete(oldId); + runtime.sessions.set(newId, record); + for (const socket of record.clientSockets) + socket.data.sessionId = newId; + finishQueueMigration?.(); + finishQueueMigration = undefined; + sendSessionRemoved(oldId, newId); + } + if (oldFile && oldFile !== record.file) + runtime.sessionsByFile.delete(normalizePath(oldFile)); + if (record.file) + runtime.sessionsByFile.set(normalizePath(record.file), record); + identityTransition?.commit(); + if ( + record.pendingWorktreeSourceDeletion?.sessionId === oldId && + oldId !== newId + ) { + record.pendingWorktreeSourceDeletion = undefined; + } + identityTransition = undefined; + broadcastSessionToAll(record); + } catch (error) { + finishQueueMigration?.(); + if (identityTransition) { + try { + identityTransition.rollback(); + } catch (rollbackError) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; source-session rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + } + throw error; + } + }), + { + suppressErrors, + onBackgroundError: (error) => + console.error( + `Could not refresh managed session ${record.id}:`, + error, + ), + }, + ); + } + + async function recoverStagedSourceSessionDeletions(): Promise { + const tombstones: Array<{ tombstone: string; source: string }> = []; + const stack = [sessionsDir]; + while (stack.length > 0) { + const directory = stack.pop(); + if (!directory) break; + let entries: Array<{ + name: string; + isDirectory(): boolean; + isFile(): boolean; + }>; + try { + entries = readdirSync(directory, { withFileTypes: true }) as Array<{ + name: string; + isDirectory(): boolean; + isFile(): boolean; + }>; + } catch { + continue; + } + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) stack.push(path); + else if (entry.isFile()) { + const match = path.match(/^(.*\.jsonl)\.replaced-[0-9a-f-]+\.tmp$/i); + if (match) tombstones.push({ tombstone: path, source: match[1] }); + } + } + } + if (tombstones.length === 0) return; + const saved = listSavedSessionFiles(sessionsDir).flatMap((file) => { + const scan = parseSessionFile(file); + return scan ? [scan] : []; + }); + for (const staged of tombstones) { + if (existsSync(staged.source)) { + console.warn( + `Retaining staged source session because its original path was recreated: ${staged.tombstone}`, + ); + continue; + } + try { + const sourceWasManaged = managedSessionStore.has(staged.source); + const sourceScan = parseSessionFile(staged.tombstone); + const sourceId = sourceScan?.session.id; + const replacement = sourceId + ? saved.find((candidate) => { + const marker = replacementFromEntries(candidate.entries); + return ( + marker?.previousSessionId === sourceId && + normalizePath(marker.previousSessionFile) === + normalizePath(staged.source) && + marker.replacementSessionId === candidate.session.id + ); + }) + : undefined; + if (!sourceId || !replacement) { + if (!existsSync(staged.source)) + renameSync(staged.tombstone, staged.source); + continue; + } + // replacement is only defined when sourceId is, but the find callback + // above loses that narrowing across the closure boundary. + const sourceQueue = sourceId + ? persistedQueues.get(sourceId) + : undefined; + const replacementQueue = persistedQueues.get(replacement.session.id); + if (sourceQueue?.length) { + const ids = new Set(); + const queueItems = [ + ...cloneWebQueue(sourceQueue), + ...cloneWebQueue(replacementQueue ?? []), + ].filter((item) => { + if (ids.has(item.id)) return false; + ids.add(item.id); + return true; + }); + await queueStoreWriter.mutate(persistedQueues, (queues) => { + if (sourceId) queues.delete(sourceId); + queues.set(replacement.session.id, queueItems); + }); + } + const previousManagedWorktree = managedWorktreeFromEntries( + sourceScan?.entries ?? [], + ); + if (sourceWasManaged) + replaceManagedSessionFile(staged.source, replacement.file); + let worktreeCleanupFailed = false; + if ( + previousManagedWorktree && + !hasOtherSessionInWorktree( + sessionsDir, + staged.source, + previousManagedWorktree.path, + ) + ) { + try { + const cleanup = removeManagedWorktree(previousManagedWorktree); + if (cleanup.branchWarning) + console.warn( + `Recovered source deletion removed worktree ${previousManagedWorktree.path}, but branch cleanup failed: ${cleanup.branchWarning}`, + ); + } catch (error) { + worktreeCleanupFailed = true; + console.warn( + `Recovered source session deletion, but previous managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (!worktreeCleanupFailed) rmSync(staged.tombstone, { force: true }); + } catch (error) { + console.warn( + `Could not recover staged source session deletion ${staged.tombstone}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + try { + managedSessionStore.recanonicalize(); + } catch (error) { + console.warn( + `Could not recanonicalize managed session ownership after source recovery: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return { + refreshManagedSession, + recoverStagedSourceSessionDeletions, + }; +} + +export type ManagedSessionRefresh = ReturnType< + typeof createManagedSessionRefresh +>; diff --git a/web/server/missingSessions.ts b/web/server/missingSessions.ts new file mode 100644 index 0000000..1f4ac9b --- /dev/null +++ b/web/server/missingSessions.ts @@ -0,0 +1,60 @@ +import { readdirSync } from "node:fs"; +import { basename, dirname } from "node:path"; +import { isConfirmedMissingPath } from "./file-presence.js"; +import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; + +/** + * Predicates for sessions whose backing file disappeared without a durable + * replacement marker. Used to hide records from the catalog and reap leftovers. + */ +export function createMissingSessions(options: { + sessionsDir: string; + catalog: SessionFileCatalog; +}) { + const { sessionsDir, catalog } = options; + const { sessionFileKey, scanSavedSessions } = catalog; + + function hasStagedOrDurableReplacement(record: SessionRecord): boolean { + const sourceFile = record.file; + if (!sourceFile) return false; + try { + const sourceName = basename(sourceFile); + if ( + readdirSync(dirname(sourceFile)).some( + (entry) => + entry.startsWith(`${sourceName}.replaced-`) && + entry.endsWith(".tmp"), + ) + ) + return true; + } catch { + // The containing session directory may itself have been removed. + } + for (const scan of scanSavedSessions(sessionsDir)) { + if (sessionFileKey(scan.file) === sessionFileKey(sourceFile)) continue; + const replacement = scan.replacement; + if ( + replacement?.previousSessionId === record.id && + sessionFileKey(replacement.previousSessionFile) === + sessionFileKey(sourceFile) + ) + return true; + } + return false; + } + + function isMissingInactiveSession(record: SessionRecord): boolean { + return Boolean( + record.file && + !record.active && + !record.managed && + record.agentSockets.size === 0 && + isConfirmedMissingPath(record.file) && + !hasStagedOrDurableReplacement(record), + ); + } + + return { hasStagedOrDurableReplacement, isMissingInactiveSession }; +} + +export type MissingSessions = ReturnType; diff --git a/web/server/recordSync.ts b/web/server/recordSync.ts new file mode 100644 index 0000000..a9745c1 --- /dev/null +++ b/web/server/recordSync.ts @@ -0,0 +1,211 @@ +import type { WebSession } from "../protocol.js"; +import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; + +/** + * Applies runtime state, usage stats, and subagent tool events onto live + * session records. + */ +export function createRecordSync(options: { + catalog: SessionFileCatalog; + state: ServerRuntimeState; +}) { + const { catalog, state: runtime } = options; + const { + isRecord, + normalizePath, + parseSessionMetadataFile, + toNumber, + zeroWebUsage, + } = catalog; + + function updateRecordFromState(record: SessionRecord, state: unknown): void { + const s = state as Record | undefined; + if (!s) return; + const model = s.model as Record | null | undefined; + if (model && typeof model.id === "string") { + record.model = + typeof model.provider === "string" && model.provider + ? `${model.provider}/${model.id}` + : model.id; + } + if (typeof s.thinkingLevel === "string") + record.thinkingLevel = s.thinkingLevel; + if (typeof s.sessionFile === "string") { + record.file = s.sessionFile; + runtime.sessionsByFile.set(normalizePath(s.sessionFile), record); + const scan = parseSessionMetadataFile(s.sessionFile); + if (scan?.session.cwd) record.cwd = scan.session.cwd; + if (scan?.managedWorktreeScanned) + record.managedWorktree = scan.session.managedWorktree; + } + if (typeof s.sessionId === "string") record.id = s.sessionId; + record.name = + typeof s.sessionName === "string" && s.sessionName + ? s.sessionName + : undefined; + if (typeof s.messageCount === "number") + record.messageCount = s.messageCount; + if (s.isCompacting === true) { + record.compaction ??= { reason: "threshold", startedAt: Date.now() }; + record.status = "working"; + } else if (s.isCompacting === false) { + record.compaction = undefined; + if (s.isStreaming === false && record.status !== "error") + record.status = "idle"; + } + record.updatedAt = Date.now(); + } + + function updateRecordFromStats(record: SessionRecord, value: unknown): void { + if (!isRecord(value)) return; + if (isRecord(value.tokens)) { + const usage = record.usage ?? zeroWebUsage(); + usage.input = toNumber(value.tokens.input); + usage.output = toNumber(value.tokens.output); + usage.cacheRead = toNumber(value.tokens.cacheRead); + usage.cacheWrite = toNumber(value.tokens.cacheWrite); + usage.totalTokens = toNumber(value.tokens.total); + usage.cost.total = toNumber(value.cost, usage.cost.total); + record.usage = usage; + } + if (isRecord(value.contextUsage)) { + record.contextUsage = { + tokens: + value.contextUsage.tokens === null + ? null + : toNumber(value.contextUsage.tokens), + contextWindow: toNumber(value.contextUsage.contextWindow), + percent: + value.contextUsage.percent === null + ? null + : toNumber(value.contextUsage.percent), + }; + } + } + + function updateSubagentsFromToolEvent( + record: SessionRecord, + event: Record, + ): boolean { + if ( + event.type !== "tool_execution_end" || + typeof event.toolName !== "string" || + !event.toolName.startsWith("subagent_") + ) + return false; + const result = isRecord(event.result) ? event.result : undefined; + const details = + result && isRecord(result.details) ? result.details : undefined; + if (!details || !Array.isArray(details.agents)) return false; + const previous = new Map( + (record.subagents ?? []).map((agent) => [agent.id, agent]), + ); + const now = Date.now(); + record.subagents = details.agents.flatMap((value) => { + if ( + !isRecord(value) || + typeof value.id !== "string" || + typeof value.status !== "string" + ) + return []; + const prior = previous.get(value.id); + return [ + { + id: value.id, + status: value.status as NonNullable< + WebSession["subagents"] + >[number]["status"], + model: + typeof value.model === "string" + ? value.model + : (prior?.model ?? "unknown model"), + effort: + typeof value.effort === "string" + ? value.effort + : (prior?.effort ?? "off"), + turns: + typeof value.turns === "number" ? value.turns : (prior?.turns ?? 0), + currentTool: + typeof value.currentTool === "string" + ? value.currentTool + : undefined, + queued: + typeof value.queued === "number" + ? value.queued + : (prior?.queued ?? 0), + createdAt: prior?.createdAt ?? now, + updatedAt: now, + completedAt: + value.status === "completed" || + value.status === "failed" || + value.status === "terminated" + ? now + : prior?.completedAt, + error: typeof value.error === "string" ? value.error : prior?.error, + usage: isRecord(value.usage) + ? (value.usage as NonNullable< + WebSession["subagents"] + >[number]["usage"]) + : prior?.usage, + transcript: Array.isArray(value.transcript) + ? value.transcript.flatMap((entry) => { + if ( + !isRecord(entry) || + typeof entry.timestamp !== "number" || + typeof entry.role !== "string" || + typeof entry.text !== "string" + ) + return []; + return [ + { + timestamp: entry.timestamp, + role: entry.role, + text: entry.text, + }, + ]; + }) + : prior?.transcript, + streamingText: + typeof value.streamingText === "string" + ? value.streamingText + : prior?.streamingText, + }, + ]; + }); + return true; + } + + function catalogSessionChanged( + previous: WebSession | undefined, + next: WebSession, + ): boolean { + if (!previous) return true; + return ( + previous.file !== next.file || + previous.cwd !== next.cwd || + previous.name !== next.name || + previous.branch !== next.branch || + previous.model !== next.model || + previous.thinkingLevel !== next.thinkingLevel || + previous.status !== next.status || + previous.source !== next.source || + previous.messageCount !== next.messageCount || + previous.preview !== next.preview || + previous.parentSession !== next.parentSession || + previous.pullRequest?.number !== next.pullRequest?.number || + previous.pullRequest?.url !== next.pullRequest?.url || + previous.compaction?.reason !== next.compaction?.reason || + previous.compaction?.startedAt !== next.compaction?.startedAt + ); + } + + return { + updateRecordFromState, + updateRecordFromStats, + updateSubagentsFromToolEvent, + catalogSessionChanged, + }; +} + +export type RecordSync = ReturnType; diff --git a/web/server/rpcSessions.ts b/web/server/rpcSessions.ts new file mode 100644 index 0000000..5651bc4 --- /dev/null +++ b/web/server/rpcSessions.ts @@ -0,0 +1,37 @@ +import { dirname } from "node:path"; +import { + ManagedRpcSession, + type ManagedRpcSessionOptions, +} from "./managed-rpc-session.js"; +import type { SessionFileCatalog } from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; + +/** + * Creates RPC sessions bound to this daemon's runtime directory. All managed + * sessions and ad-hoc saved-session commands share this factory. + */ +export function createRpcSessionFactory(options: { + config: WebServerConfig; + catalog: SessionFileCatalog; +}) { + const { config, catalog } = options; + const { parseSessionMetadataFile } = catalog; + + function createRpcSession( + options_: Omit< + ManagedRpcSessionOptions, + "runtimeDirectory" | "replacementForSessionFile" + >, + ): ManagedRpcSession { + return new ManagedRpcSession({ + ...options_, + runtimeDirectory: dirname(config.stateFilePath), + replacementForSessionFile: (file) => + parseSessionMetadataFile(file)?.replacement, + }); + } + + return { createRpcSession }; +} + +export type RpcSessionFactory = ReturnType; diff --git a/web/server/server-types.ts b/web/server/server-types.ts index 225eb52..6210c9f 100644 --- a/web/server/server-types.ts +++ b/web/server/server-types.ts @@ -5,8 +5,15 @@ import type { } from "../protocol.js"; import type { DirtySnapshotRetryWorker } from "./dirty-snapshot-worker.js"; import type { ManagedRpcSession } from "./managed-rpc-session.js"; +import type { createSessionFileCatalog } from "./session-file-catalog.js"; +import type { createSessionQueueCoordinator } from "./session-queue-coordinator.js"; import type { ManagedWorktree } from "./worktrees.js"; +export type SessionFileCatalog = ReturnType; +export type SessionQueueCoordinator = ReturnType< + typeof createSessionQueueCoordinator +>; + export type WebSocketKind = "client" | "agent"; export type SessionSource = WebSession["source"]; export type SessionStatus = WebSession["status"]; diff --git a/web/server/serverConfig.ts b/web/server/serverConfig.ts new file mode 100644 index 0000000..d416074 --- /dev/null +++ b/web/server/serverConfig.ts @@ -0,0 +1,74 @@ +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { DEFAULT_WEB_PORT } from "../protocol.js"; + +export type WebServerConfig = { + /** Repository root serving the web client and running `webBuild`. */ + rootDir: string; + /** Built browser assets served by the static responder. */ + distDir: string; + /** Machine-wide daemon discovery state file. */ + stateFilePath: string; + agentDir: string; + settingsPath: string; + sessionsDir: string; + queueStorePath: string; + managedSessionStorePath: string; + host: string; + /** Port requested via env before `Bun.serve` reports the bound port. */ + initialPort: number; +}; + +export const LONG_RUNNING_COMMAND_TIMEOUT_MS = 10 * 60_000; +export const MISSING_SESSION_RECONCILE_INTERVAL_MS = 1_000; +// Accept one legacy agent.hello containing a large session until running Pi +// processes reload the bridge that sends metadata-only hello frames. +export const MAX_WEBSOCKET_PAYLOAD_BYTES = 32 * 1024 * 1024; + +function envPath(name: string, fallback: string): string { + const value = process.env[name]; + return resolve( + value ? (isAbsolute(value) ? value : join(process.cwd(), value)) : fallback, + ); +} + +/** Resolve daemon-wide paths and settings from the process environment. */ +export function resolveWebServerConfig(): WebServerConfig { + const rootDir = resolve( + process.env.PI_WEB_ROOT + ? isAbsolute(process.env.PI_WEB_ROOT) + ? process.env.PI_WEB_ROOT + : join(process.cwd(), process.env.PI_WEB_ROOT) + : process.cwd(), + ); + const stateFilePath = envPath( + "PI_WEB_STATE_FILE", + join(homedir(), ".pi", "agent", "web", "server.json"), + ); + const agentDir = resolve( + process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), + ); + const configuredPort = Number( + process.env.PI_WEB_PORT ?? `${DEFAULT_WEB_PORT}`, + ); + return { + rootDir, + distDir: join(rootDir, "web", "dist"), + stateFilePath, + agentDir, + settingsPath: join(agentDir, "settings.json"), + sessionsDir: join(agentDir, "sessions"), + queueStorePath: join(dirname(stateFilePath), "queues.json"), + managedSessionStorePath: join( + dirname(stateFilePath), + "managed-sessions.json", + ), + host: "127.0.0.1", + initialPort: + Number.isInteger(configuredPort) && + configuredPort >= 0 && + configuredPort <= 65_535 + ? configuredPort + : DEFAULT_WEB_PORT, + }; +} diff --git a/web/server/serverLifecycle.ts b/web/server/serverLifecycle.ts new file mode 100644 index 0000000..2050863 --- /dev/null +++ b/web/server/serverLifecycle.ts @@ -0,0 +1,317 @@ +import { randomUUID } from "node:crypto"; +import { rmSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type ServerStateFile, WEB_STATE_VERSION } from "../protocol.js"; +import type { TailscaleStatus } from "../tailscale.js"; +import type { DaemonOwnershipResolver } from "./daemonOwnership.js"; +import type { DiscoveryState } from "./discoveryState.js"; +import { internalError, isTrustedBrowserOrigin } from "./http-utils.js"; +import type { HttpApi } from "./httpApi.js"; +import type { ManagedSessionLauncher } from "./managedSessionCreate.js"; +import type { ManagedSessionRefresh } from "./managedSessionRefresh.js"; +import { quiesceQueueMutations } from "./queue-mutation.js"; +import type { + SessionFileCatalog, + SessionQueueCoordinator, + SessionRecord, + SocketData, +} from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import { + MAX_WEBSOCKET_PAYLOAD_BYTES, + MISSING_SESSION_RECONCILE_INTERVAL_MS, +} from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { SessionDeletion } from "./sessionDeletion.js"; +import { + shouldContinueManagedShutdownWait, + shouldWaitForManagedShutdown, +} from "./shutdown-policy.js"; +import { + ensureDir, + getOrCreateWebState, + readStateFile, + writeStateFileAtomic, +} from "./stateFileStore.js"; +import type { WebSocketGateway } from "./webSocketGateway.js"; + +/** Daemon startup (ownership, assets, listener, tailscale) and shutdown. */ +export function createServerLifecycle(options: { + config: WebServerConfig; + state: ServerRuntimeState; + catalog: SessionFileCatalog; + discovery: DiscoveryState; + ownership: DaemonOwnershipResolver; + httpApi: HttpApi; + gateway: WebSocketGateway; + launcher: ManagedSessionLauncher; + refresh: ManagedSessionRefresh; + deletion: SessionDeletion; + queue: SessionQueueCoordinator; + staticAssetResponse: (request: Request) => Response | undefined; +}) { + const { + config, + state: runtime, + catalog, + discovery, + ownership, + httpApi, + gateway, + launcher, + refresh, + deletion, + queue, + staticAssetResponse, + } = options; + const { isRecord } = catalog; + const { cancelWebQueueWork } = queue; + + async function cleanupAndExit(code = 0): Promise { + // Repeated TERM/INT delivery is common during deploys. Never reinterpret it as + // permission to abort managed work; an operator can still use SIGKILL for a + // truly wedged process. + if (runtime.shutdownStarted) return; + runtime.shutdownStarted = true; + if (runtime.missingSessionReconcileTimer) + clearInterval(runtime.missingSessionReconcileTimer); + runtime.missingSessionReconcileTimer = undefined; + const busyNames = () => + [...runtime.sessions.values()] + .filter(shouldWaitForManagedShutdown) + .map((record) => record.name ?? record.id); + let busy = busyNames(); + if (busy.length > 0) + console.error( + `Waiting for active managed sessions before restart: ${busy.join(", ")}`, + ); + while (shouldContinueManagedShutdownWait(busy.length)) { + await Bun.sleep(100); + busy = busyNames(); + } + if (busy.length > 0) + console.error( + `Proceeding with shutdown while sessions remain active: ${busy.join(", ")}`, + ); + // Stop admitting queue work and drain each per-session mutation tail before the + // final snapshot. This prevents a late mutation from racing or following flush. + const records: SessionRecord[] = [...runtime.sessions.values()]; + // A single rejected mutation tail must not skip the remaining cleanup or the + // final exit; each step is individually best-effort. + await Promise.all( + records.map((record) => + quiesceQueueMutations(record).catch(() => undefined), + ), + ); + // Accepted queue items are removed in memory before their durable snapshot may + // finish. Drain any existing write before the final write, all within one bound. + await Promise.all( + records.map(async (record) => { + if (record.queueDirtyWorker) + await record.queueDirtyWorker + .flushAndCancel(1_000) + .catch(() => undefined); + }), + ); + for (const record of runtime.sessions.values()) { + cancelWebQueueWork(record); + try { + await deletion.stopRecord(record); + } catch { + // ignore + } + } + try { + runtime.server?.stop(); + } catch { + // ignore + } + try { + if (readStateFile(config.stateFilePath)?.pid === process.pid) + rmSync(config.stateFilePath, { force: true }); + } catch { + // ignore + } + setTimeout(() => process.exit(code), 25).unref(); + } + + // web/dist is not checked in; rebuild it on every startup so a long-running + // server always serves the client assets that match the checked-out source. + async function buildWebClientAssets(): Promise { + // Test suites opt out of the per-startup rebuild: they spawn ~30 daemons per + // run and never exercise asset freshness, and each rebuild puts the state + // file behind a full vite build. Skip only when assets already exist. + if (process.env.PI_WEB_SKIP_ASSET_BUILD === "1") { + try { + if (statSync(join(config.distDir, "index.html")).isFile()) return; + } catch { + // No built assets yet; fall through and build them. + } + } + console.log("Building web client assets..."); + const build = Bun.spawn({ + cmd: ["bun", "run", "webBuild"], + cwd: config.rootDir, + stdout: "inherit", + stderr: "inherit", + }); + const code = await build.exited; + if (code !== 0) + console.error( + `webBuild exited with code ${code}; serving whatever assets already exist in ${config.distDir}.`, + ); + } + + async function start(): Promise { + // Signals must be handled before ownership resolution: eviction can wait a + // long time for the incumbent's graceful exit, and an interrupt during that + // window would otherwise kill this process with default handlers mid-eviction. + process.on("SIGINT", () => void cleanupAndExit(0)); + process.on("SIGTERM", () => void cleanupAndExit(0)); + process.on("exit", () => { + try { + if (readStateFile(config.stateFilePath)?.pid === process.pid) + rmSync(config.stateFilePath, { force: true }); + } catch { + // ignore + } + }); + const resolved = await ownership.resolveDaemonOwnership(); + if (resolved.action === "abort") { + console.error(`pi web server could not start: ${resolved.reason}`); + process.exit(1); + } + if (resolved.action === "defer") { + // Another daemon already owns machine-wide discovery. Restoring its state + // file (when missing) lets Pi sessions find it; never touch its Tailscale + // Serve route, never race it for the port. Exit quietly instead. + if (resolved.republished) { + ensureDir(dirname(config.stateFilePath)); + const restored: ServerStateFile = { + pid: resolved.owner.pid, + port: resolved.owner.port, + startedAt: Date.now(), + version: WEB_STATE_VERSION, + ...(isRecord(resolved.owner.tailscale) + ? { tailscale: resolved.owner.tailscale as TailscaleStatus } + : {}), + }; + writeStateFileAtomic(config.stateFilePath, restored); + } + console.log( + `pi web server already running (pid ${resolved.owner.pid} on port ${resolved.owner.port}); deferring`, + ); + process.exit(0); + } + await buildWebClientAssets(); + await refresh.recoverStagedSourceSessionDeletions(); + discovery.setWebState(getOrCreateWebState(runtime.port)); + runtime.server = Bun.serve({ + hostname: config.host, + port: runtime.port, + async fetch(request, serverInstance) { + const url = new URL(request.url); + if (url.pathname === "/ws/client") { + // Browser WebSockets are not covered by CORS. Require the initiating + // page to have the exact host served directly or forwarded by Tailscale. + // This preserves tokenless iOS bookmarks while preventing arbitrary web + // origins from driving shell-capable sessions. + const tailscale = discovery.getTailscaleStatus(); + if ( + !isTrustedBrowserOrigin( + request, + tailscale.published ? tailscale.url : undefined, + ) + ) + return new Response("Forbidden WebSocket origin", { status: 403 }); + const upgraded = serverInstance.upgrade(request, { + data: { + kind: "client", + id: randomUUID(), + authed: false, + } as SocketData, + }); + return upgraded + ? undefined + : new Response("Upgrade failed", { status: 400 }); + } + if (url.pathname === "/ws/agent") { + // The Pi bridge connects directly to localhost without Origin or proxy + // headers. Tailscale Serve forwards this route too, so Origin absence alone + // must not let a tailnet client impersonate an agent. + const forwarded = [ + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + ].some((header) => request.headers.has(header)); + if (request.headers.has("origin") || forwarded) + return new Response("Forbidden agent WebSocket", { status: 403 }); + const upgraded = serverInstance.upgrade(request, { + data: { + kind: "agent", + id: randomUUID(), + authed: false, + } as SocketData, + }); + return upgraded + ? undefined + : new Response("Upgrade failed", { status: 400 }); + } + try { + const asset = staticAssetResponse(request); + return asset ?? (await httpApi.handleApi(request)); + } catch (error) { + return internalError( + error instanceof Error ? error.message : String(error), + ); + } + }, + websocket: { + maxPayloadLength: MAX_WEBSOCKET_PAYLOAD_BYTES, + open(socket) { + gateway.handleWebSocketOpen( + socket as Bun.ServerWebSocket, + ); + }, + message(socket, data) { + void gateway.handleWebSocketMessage( + socket as Bun.ServerWebSocket, + data as string | Uint8Array, + ); + }, + close(socket) { + gateway.handleWebSocketClose( + socket as Bun.ServerWebSocket, + ); + }, + }, + }); + runtime.port = runtime.server.port ?? runtime.port; + discovery.setWebState({ ...discovery.getWebState(), port: runtime.port }); + // Configure Serve only after localhost is listening, then publish discovery + // state with the final tailnet URL. Simultaneous startup losers never acquire + // the port and therefore cannot overwrite the winning server's state. + await discovery.configureTailscaleServe(); + console.log( + `pi web server listening on http://${config.host}:${runtime.port}`, + ); + runtime.missingSessionReconcileTimer = setInterval( + () => void deletion.reconcileMissingSessionFiles(), + MISSING_SESSION_RECONCILE_INTERVAL_MS, + ); + runtime.missingSessionReconcileTimer.unref?.(); + // Keep readiness independent from JSONL catalog work, then restore only the + // browser-owned sessions that were active before the daemon stopped. Native + // bridge sessions reconnect themselves and are never started in RPC mode here. + setTimeout( + () => + void launcher.restoreManagedSessions().catch((error) => { + console.error("Could not restore managed web sessions:", error); + }), + 250, + ).unref(); + } + + return { start, cleanupAndExit }; +} diff --git a/web/server/serverRuntimeState.ts b/web/server/serverRuntimeState.ts new file mode 100644 index 0000000..9cd6515 --- /dev/null +++ b/web/server/serverRuntimeState.ts @@ -0,0 +1,46 @@ +import type { ServerStateFile } from "../protocol.js"; +import type { TailscaleStatus } from "../tailscale.js"; +import type { ClientSocketData, SessionRecord } from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; + +/** + * Mutable daemon-wide runtime state shared by the server feature modules. + * Everything here was module-scope state in the original single-file server; + * grouping it keeps the feature factories testable and avoids hidden globals. + */ +export type ServerRuntimeState = { + /** Effective port; updated after `Bun.serve` binds (port 0 picks one). */ + port: number; + webState: ServerStateFile; + tailscaleStatus: TailscaleStatus; + shutdownStarted: boolean; + server?: Bun.Server; + missingSessionReconcileTimer?: ReturnType; + sessions: Map; + sessionsByFile: Map; + connectedClientSockets: Set>; + managedSessionStarts: Map>; + missingSessionReconciliations: Set; +}; + +export function createServerRuntimeState( + config: WebServerConfig, +): ServerRuntimeState { + return { + port: config.initialPort, + // Assigned in serverStartup once this process wins daemon ownership. + webState: undefined as unknown as ServerStateFile, + tailscaleStatus: { + installed: false, + enabled: false, + available: false, + published: false, + }, + shutdownStarted: false, + sessions: new Map(), + sessionsByFile: new Map(), + connectedClientSockets: new Set(), + managedSessionStarts: new Map(), + missingSessionReconciliations: new Set(), + }; +} diff --git a/web/server/serverStores.ts b/web/server/serverStores.ts new file mode 100644 index 0000000..5a27696 --- /dev/null +++ b/web/server/serverStores.ts @@ -0,0 +1,16 @@ +import { ManagedSessionStore } from "./managed-session-store.js"; +import { CoalescedQueueStoreWriter, readQueueStore } from "./queue-store.js"; +import type { WebServerConfig } from "./serverConfig.js"; + +/** Durable sidecar stores shared across session features. */ +export function createServerStores(config: WebServerConfig) { + return { + persistedQueues: readQueueStore(config.queueStorePath), + queueStoreWriter: new CoalescedQueueStoreWriter(config.queueStorePath), + managedSessionStore: new ManagedSessionStore( + config.managedSessionStorePath, + ), + }; +} + +export type ServerStores = ReturnType; diff --git a/web/server/sessionDeletion.ts b/web/server/sessionDeletion.ts new file mode 100644 index 0000000..d3b7036 --- /dev/null +++ b/web/server/sessionDeletion.ts @@ -0,0 +1,412 @@ +import { existsSync, rmSync } from "node:fs"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { CommandRouter } from "./commandRouter.js"; +import type { MissingSessions } from "./missingSessions.js"; +import { + preserveRetryAroundQuiescence, + quiesceQueueMutations, +} from "./queue-mutation.js"; +import type { + SessionFileCatalog, + SessionQueueCoordinator, + SessionRecord, +} from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { ServerStores } from "./serverStores.js"; +import type { SessionRegistry } from "./sessionRegistry.js"; +import { + hasOtherSessionInWorktree, + removeManagedWorktree, + removeManagedWorktreeAsync, +} from "./worktrees.js"; + +/** + * Durable session deletion (queues first, then file, then registrations), + * external-deletion reconciliation, and best-effort managed worktree cleanup. + */ +export function createSessionDeletion(options: { + state: ServerRuntimeState; + config: WebServerConfig; + catalog: SessionFileCatalog; + stores: ServerStores; + queue: SessionQueueCoordinator; + registry: SessionRegistry; + missingSessions: MissingSessions; + broadcast: ClientBroadcast; + router: CommandRouter; +}) { + const { + state: runtime, + catalog, + stores, + queue, + registry, + missingSessions, + broadcast, + router, + } = options; + const { sessionsDir } = options.config; + const { + normalizePath, + sessionFileKey, + canonicalSessionFile, + isManagedSessionFile, + deleteManagedSessionFile, + scanSavedSessions, + readManagedWorktreePrefix, + } = catalog; + const { + persistWebQueue, + markWebQueueSnapshotDirty, + cancelWebQueueWork, + scheduleWebQueueRetry, + } = queue; + const { queueStoreWriter, persistedQueues } = stores; + const { upsertSession } = registry; + const { isMissingInactiveSession } = missingSessions; + const { sendSessionRemoved } = broadcast; + const { routeCommand } = router; + + async function deleteSessionRecord( + record: SessionRecord, + file?: string, + shouldCommit: () => boolean = () => true, + ): Promise { + const finishQueueQuiescence = preserveRetryAroundQuiescence({ + isArmed: () => record.queueRetryTimer !== undefined, + cancel: () => { + if (record.queueRetryTimer) clearTimeout(record.queueRetryTimer); + record.queueRetryTimer = undefined; + }, + reopen: () => { + record.queueMutationsQuiesced = false; + }, + resume: () => scheduleWebQueueRetry(record), + }); + try { + await quiesceQueueMutations(record); + if (record.queueDirtyWorker) { + await record.queueDirtyWorker.cancelAndDrain(); + record.queueDirtyWorker = undefined; + } + if (!shouldCommit()) { + finishQueueQuiescence(); + return false; + } + // Make queue removal durable before deleting the file, maps, or sockets. + // A failed store write leaves the complete session available for retry. + await queueStoreWriter.mutate(persistedQueues, (queues) => { + queues.delete(record.id); + }); + } catch (error) { + finishQueueQuiescence(); + throw error; + } + + // Queue persistence yields to the event loop. If an agent reconnected while it + // was in flight, restore its queue instead of deleting the newly live record. + if (!shouldCommit()) { + // The durable delete completed, but the record became live again. Restore + // its retained queue through the retrying worker so a transient rollback + // write failure cannot leave an active session with no durable snapshot. + markWebQueueSnapshotDirty(record); + finishQueueQuiescence(); + return false; + } + + if (file) { + try { + rmSync(file, { force: true }); + } catch (error) { + // Restore a non-empty queue before making the failed deletion usable again. + let restoreError: unknown; + try { + await persistWebQueue(record); + } catch (cause) { + restoreError = cause; + } + finishQueueQuiescence(); + const message = error instanceof Error ? error.message : String(error); + if (restoreError) { + throw new Error( + `Failed to delete session file: ${message}; queue rollback failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ); + } + throw new Error(`Failed to delete session file: ${message}`); + } + } + + cancelWebQueueWork(record); + runtime.sessions.delete(record.id); + if (record.file) runtime.sessionsByFile.delete(normalizePath(record.file)); + // Notify sockets subscribed only to the removed session; once the record leaves + // the maps, a catalog-wide broadcast cannot find them. Keep browser sockets open + // so they can subscribe to a surviving session without reconnecting. + sendSessionRemoved(record.id, undefined, record.clientSockets); + for (const socket of record.clientSockets) + socket.data.sessionId = undefined; + record.clientSockets.clear(); + for (const socket of record.agentSockets) { + try { + socket.close(); + } catch { + // ignore + } + } + return true; + } + + async function stopRecord(record: SessionRecord): Promise { + if (record.managed) { + await record.managed.shutdown(); + record.managed = undefined; + } + record.active = false; + record.status = "offline"; + } + + async function reconcileMissingSessionFiles(): Promise { + if (runtime.shutdownStarted) return; + const candidates = [...runtime.sessions.values()].filter( + (record) => + !runtime.missingSessionReconciliations.has(record) && + isMissingInactiveSession(record), + ); + await Promise.all( + candidates.map(async (record) => { + runtime.missingSessionReconciliations.add(record); + const sessionFile = record.file; + try { + if ( + !sessionFile || + runtime.sessions.get(record.id) !== record || + !isMissingInactiveSession(record) + ) + return; + const managed = isManagedSessionFile(sessionFile); + let managedWorktree = record.managedWorktree; + if ( + managedWorktree && + hasOtherSessionInWorktree( + sessionsDir, + sessionFile, + managedWorktree.path, + ) + ) { + managedWorktree = undefined; + } + const deleted = await deleteSessionRecord( + record, + undefined, + () => + runtime.sessions.get(record.id) === record && + isMissingInactiveSession(record), + ); + if (!deleted) return; + if (managed) { + try { + deleteManagedSessionFile(sessionFile); + } catch (error) { + console.warn( + `Externally deleted session ${record.id} was removed from Pi web, but managed ownership cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if ( + managedWorktree && + existsSync(managedWorktree.path) && + !hasOtherSessionInWorktree( + sessionsDir, + sessionFile, + managedWorktree.path, + ) + ) { + try { + const result = removeManagedWorktree(managedWorktree); + if (result.branchWarning) + console.warn( + `Removed externally deleted session worktree ${managedWorktree.path}, but could not delete branch ${managedWorktree.branch}: ${result.branchWarning}`, + ); + } catch (error) { + console.warn( + `Externally deleted session ${record.id} was removed from Pi web, but managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } catch (error) { + console.warn( + `Could not remove externally deleted session ${record.id} from Pi web: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + runtime.missingSessionReconciliations.delete(record); + } + }), + ); + } + + function scheduleManagedWorktreeCleanup( + sessionId: string, + sessionFile: string, + managedWorktree: NonNullable, + ): void { + const timer = setTimeout(() => { + void (async () => { + // A new session may claim this checkout after durable deletion yields. + if ( + hasOtherSessionInWorktree( + sessionsDir, + sessionFile, + managedWorktree.path, + ) + ) + return; + try { + const result = await removeManagedWorktreeAsync(managedWorktree); + if (result.branchWarning) + console.warn( + `Removed worktree ${managedWorktree.path}, but could not delete branch ${managedWorktree.branch}: ${result.branchWarning}`, + ); + } catch (error) { + console.warn( + `Session ${sessionId} was deleted, but managed worktree cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + })(); + }, 0); + timer.unref?.(); + } + + async function deleteSession(sessionId: string): Promise { + let record = + runtime.sessions.get(sessionId) ?? + runtime.sessionsByFile.get(normalizePath(sessionId)) ?? + (() => { + const scan = scanSavedSessions(sessionsDir).find( + (item) => + item.session.id === sessionId || + normalizePath(item.file) === normalizePath(sessionId), + ); + return scan + ? upsertSession( + scan.session, + "saved", + scan.history, + scan.managedWorktreeScanned, + ) + : undefined; + })(); + if (!record) throw new Error(`Unknown session: ${sessionId}`); + if (record.file) { + const pendingStart = runtime.managedSessionStarts.get( + sessionFileKey(record.file), + ); + if (pendingStart) { + await pendingStart.catch(() => undefined); + record = + runtime.sessions.get(sessionId) ?? + runtime.sessionsByFile.get(sessionFileKey(record.file)) ?? + record; + } + } + const sessionFile = record.file; + let managedWorktree = record.managedWorktree; + if (!managedWorktree && sessionFile && !record.managedWorktreeScanned) { + managedWorktree = readManagedWorktreePrefix(sessionFile); + record.managedWorktreeScanned = true; + } + if ( + managedWorktree && + sessionFile && + hasOtherSessionInWorktree(sessionsDir, sessionFile, managedWorktree.path) + ) { + managedWorktree = undefined; + } + if (record.kind === "external" && record.agentSockets.size > 0) { + if (record.status === "working") + throw new Error( + "Abort or wait for the active session before deleting it", + ); + await routeCommand(record, { type: "shutdown" }); + const deadline = Date.now() + 3_000; + while (record.agentSockets.size > 0 && Date.now() < deadline) + await Bun.sleep(50); + if (record.agentSockets.size > 0) + throw new Error("The active Pi process did not shut down in time"); + } + await stopRecord(record); + let file: string | undefined; + if (record.file) { + try { + file = canonicalSessionFile(record.file); + } catch (error) { + if ( + !( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) + ) { + throw new Error( + `Refusing to delete unsafe session path: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + await deleteSessionRecord(record, file); + if (sessionFile && isManagedSessionFile(sessionFile)) { + try { + deleteManagedSessionFile(sessionFile); + } catch (error) { + console.warn( + `Session ${sessionId} was deleted, but managed ownership cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + // Native shutdown and durable queue deletion may yield long enough for another + // Pi process to create a session in this checkout. Ownership can only be + // revoked here; never enable cleanup that was not part of the original request. + if ( + managedWorktree && + sessionFile && + hasOtherSessionInWorktree(sessionsDir, sessionFile, managedWorktree.path) + ) { + managedWorktree = undefined; + } + // Durable deletion and client notification are complete before best-effort + // worktree cleanup. Run slow checkout removal outside the HTTP request so a + // reverse proxy cannot report a false failure after the session is gone. + if (managedWorktree && sessionFile) + scheduleManagedWorktreeCleanup(sessionId, sessionFile, managedWorktree); + } + + /** Drop a not-yet-activated initial session file and its registrations. */ + function cleanupInitialSessionFile(initialSessionFile?: string): void { + if (!initialSessionFile) return; + const key = sessionFileKey(initialSessionFile); + const stale = runtime.sessionsByFile.get(key); + if (stale?.file && sessionFileKey(stale.file) === key) { + runtime.sessions.delete(stale.id); + runtime.sessionsByFile.delete(key); + } + if (isManagedSessionFile(initialSessionFile)) { + try { + deleteManagedSessionFile(initialSessionFile); + } catch { + /* preserve the original startup error */ + } + } + rmSync(initialSessionFile, { force: true }); + } + + return { + deleteSession, + deleteSessionRecord, + stopRecord, + reconcileMissingSessionFiles, + cleanupInitialSessionFile, + }; +} + +export type SessionDeletion = ReturnType; diff --git a/web/server/sessionHistory.ts b/web/server/sessionHistory.ts new file mode 100644 index 0000000..df79275 --- /dev/null +++ b/web/server/sessionHistory.ts @@ -0,0 +1,74 @@ +import { + buildContextEntries, + type SessionEntry, +} from "@earendil-works/pi-coding-agent"; +import { + boundedWebHistory, + WEB_HISTORY_MAX_BYTES, + WEB_HISTORY_MAX_ENTRIES, + webHistoryByteLength, +} from "../history.js"; +import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; + +/** + * Bounded semantic history maintained per record. Pure module: all functions + * operate on the record passed in and catalog helpers only. + */ +export function createSessionHistory(options: { catalog: SessionFileCatalog }) { + const { parseSessionFile, isRecord } = options.catalog; + + function replaceRecordHistory( + record: SessionRecord, + entries: readonly unknown[], + ): void { + record.history = boundedWebHistory(entries); + record.historyReady = true; + record.historyBytes = webHistoryByteLength(record.history); + } + + function appendRecordHistory(record: SessionRecord, entry: unknown): void { + const appended = boundedWebHistory([entry], { maxEntries: 1 }); + if (appended.length === 0) return; + record.history.push(appended[0]); + record.historyReady = true; + record.historyBytes = + (record.historyBytes ?? + webHistoryByteLength(record.history.slice(0, -1))) + + webHistoryByteLength(appended); + while ( + record.history.length > WEB_HISTORY_MAX_ENTRIES || + record.historyBytes > WEB_HISTORY_MAX_BYTES + ) { + const first = record.history[0]; + const preserveSummary = + isRecord(first) && + typeof first.id === "string" && + first.id.startsWith("web-compaction-"); + const removeIndex = preserveSummary && record.history.length > 1 ? 1 : 0; + const [removed] = record.history.splice(removeIndex, 1); + record.historyBytes = Math.max( + 2, + record.historyBytes - webHistoryByteLength([removed]), + ); + } + } + + function sessionHistoryForRecord(record: SessionRecord): unknown[] { + if (record.active || record.historyReady) return [...record.history]; + if (record.file) { + const scan = parseSessionFile(record.file); + if (scan) { + replaceRecordHistory( + record, + buildContextEntries(scan.history as SessionEntry[]), + ); + return [...record.history]; + } + } + return [...record.history]; + } + + return { replaceRecordHistory, appendRecordHistory, sessionHistoryForRecord }; +} + +export type SessionHistory = ReturnType; diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts new file mode 100644 index 0000000..9660d14 --- /dev/null +++ b/web/server/sessionRegistry.ts @@ -0,0 +1,212 @@ +import { + buildContextEntries, + type SessionEntry, +} from "@earendil-works/pi-coding-agent"; +import { boundedWebHistory, webHistoryByteLength } from "../history.js"; +import { compareWebSessions, type WebSession } from "../protocol.js"; +import type { MissingSessions } from "./missingSessions.js"; +import { resolveSessionProject } from "./projects.js"; +import type { + AgentSocketData, + ClientSocketData, + SessionFileCatalog, + SessionKind, + SessionRecord, +} from "./server-types.js"; +import type { WebServerConfig } from "./serverConfig.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { ServerStores } from "./serverStores.js"; +import type { SessionHistory } from "./sessionHistory.js"; +import { managedWorktreeFromEntries } from "./worktrees.js"; + +/** + * Owns the live session catalog: the id- and file-keyed record maps, record + * construction from scans, and the projected client payload. + */ +export function createSessionRegistry(options: { + state: ServerRuntimeState; + config: WebServerConfig; + catalog: SessionFileCatalog; + stores: ServerStores; + history: SessionHistory; + missingSessions: MissingSessions; + /** Late-bound so session deletion can depend on the registry. */ + reconcileMissingSessions: () => void; +}) { + const { state: runtime, catalog, stores, history, missingSessions } = options; + const { sessionsDir } = options.config; + const { normalizePath, scanSavedSessions } = catalog; + const { persistedQueues } = stores; + const { isMissingInactiveSession } = missingSessions; + const { replaceRecordHistory } = history; + + function sessionToClientPayload( + session: WebSession, + includeSubagentTranscripts = false, + ): WebSession { + const project = resolveSessionProject(session.cwd); + const subagents = includeSubagentTranscripts + ? session.subagents + : session.subagents?.map( + ({ + transcript: _transcript, + streamingText: _streamingText, + ...agent + }) => agent, + ); + return { + id: session.id, + file: session.file, + cwd: session.cwd, + name: session.name, + branch: session.branch, + model: session.model, + thinkingLevel: session.thinkingLevel, + status: session.status, + source: session.source, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + preview: session.preview, + parentSession: session.parentSession, + projectId: project.id, + projectName: project.name, + repositoryRoot: project.root, + managedWorktree: + "managedWorktree" in session ? session.managedWorktree : undefined, + pullRequest: session.pullRequest, + subagents, + subagentUsage: session.subagentUsage, + usage: session.usage, + contextUsage: session.contextUsage, + compaction: session.compaction, + }; + } + + function sortSessions(list: WebSession[]): WebSession[] { + return list.sort(compareWebSessions); + } + + function makeSessionRecord( + session: WebSession, + kind: SessionKind, + history: unknown[] = [], + managedWorktreeScanned = false, + ): SessionRecord { + const displayHistory = boundedWebHistory( + kind === "saved" + ? buildContextEntries(history as SessionEntry[]) + : history, + ); + const historyManagedWorktree = managedWorktreeFromEntries(history); + const record = runtime.sessions.get(session.id) ?? { + ...session, + kind, + history: displayHistory, + historyReady: history.length > 0, + historyBytes: webHistoryByteLength(displayHistory), + active: kind !== "saved", + agentRunning: session.status === "working", + managedWorktreeScanned, + agentSockets: new Set>(), + clientSockets: new Set>(), + externalRequestTargets: new Map(), + externalPending: new Map(), + queue: (persistedQueues.get(session.id) ?? []).map((item) => ({ + ...item, + images: item.images?.map((image) => ({ ...image })), + })), + }; + Object.assign(record, session); + record.kind = kind; + if (history.length > 0) { + record.history = displayHistory; + record.historyReady = true; + record.historyBytes = webHistoryByteLength(record.history); + } + record.managedWorktree = managedWorktreeScanned + ? session.managedWorktree + : (historyManagedWorktree ?? + session.managedWorktree ?? + record.managedWorktree); + if (managedWorktreeScanned) record.managedWorktreeScanned = true; + record.active = kind !== "saved" || record.active; + if (kind === "saved") record.active = false; + return record; + } + + function upsertSession( + session: WebSession, + kind: SessionKind, + history: unknown[] = [], + managedWorktreeScanned = false, + ): SessionRecord { + const existing = runtime.sessions.get(session.id); + const record = + existing ?? + makeSessionRecord(session, kind, history, managedWorktreeScanned); + const historyManagedWorktree = + history.length > 0 ? managedWorktreeFromEntries(history) : undefined; + Object.assign(record, session); + record.kind = kind; + if (history.length > 0) + replaceRecordHistory( + record, + kind === "saved" + ? buildContextEntries(history as SessionEntry[]) + : history, + ); + record.managedWorktree = managedWorktreeScanned + ? session.managedWorktree + : (historyManagedWorktree ?? + session.managedWorktree ?? + record.managedWorktree); + if (managedWorktreeScanned) record.managedWorktreeScanned = true; + if (kind !== "saved") record.active = true; + runtime.sessions.set(record.id, record); + if (record.file) + runtime.sessionsByFile.set(normalizePath(record.file), record); + return record; + } + + function activeSessionFiles(): Set { + return new Set( + [...runtime.sessions.values()].flatMap((record) => + record.active && record.file ? [normalizePath(record.file)] : [], + ), + ); + } + + function sessionSnapshot(): WebSession[] { + options.reconcileMissingSessions(); + const merged = new Map(); + for (const record of runtime.sessions.values()) { + if (record.catalogReady === false || isMissingInactiveSession(record)) + continue; + const key = record.file ? normalizePath(record.file) : record.id; + merged.set(key, sessionToClientPayload(record)); + } + for (const scan of scanSavedSessions(sessionsDir, activeSessionFiles())) { + const key = scan.session.file + ? normalizePath(scan.session.file) + : scan.session.id; + const live = + runtime.sessionsByFile.get(key) ?? + runtime.sessions.get(scan.session.id); + if (!live?.active || live.status === "offline") + merged.set(key, sessionToClientPayload(scan.session)); + } + return sortSessions(Array.from(merged.values())); + } + + return { + sessionToClientPayload, + sortSessions, + makeSessionRecord, + upsertSession, + activeSessionFiles, + sessionSnapshot, + }; +} + +export type SessionRegistry = ReturnType; diff --git a/web/server/sessionReplacement.ts b/web/server/sessionReplacement.ts new file mode 100644 index 0000000..86d2b0d --- /dev/null +++ b/web/server/sessionReplacement.ts @@ -0,0 +1,166 @@ +import { existsSync } from "node:fs"; +import type { AgentSessionReplacedMessage } from "../protocol.js"; +import { replacementFromEntries } from "../worktree-replacement.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import { quiesceQueueMutations } from "./queue-mutation.js"; +import type { + AgentSocketData, + SessionFileCatalog, + SessionQueueCoordinator, +} from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import type { ServerStores } from "./serverStores.js"; + +/** + * Commits an external (native bridge) session replacement reported over the + * agent socket: merges queues, migrates sockets and pending requests, and + * retires the source record. + */ +export function createSessionReplacement(options: { + state: ServerRuntimeState; + catalog: SessionFileCatalog; + stores: ServerStores; + queue: SessionQueueCoordinator; + broadcast: ClientBroadcast; +}) { + const { state: runtime, catalog, stores, queue, broadcast } = options; + const { normalizePath } = catalog; + const { persistedQueues, queueStoreWriter } = stores; + const { + cloneWebQueue, + webQueueEvent, + cancelWebQueueWork, + scheduleQueueSettleFallback, + } = queue; + const { broadcast: broadcastToSessionClients, sendSessionRemoved } = + broadcast; + + async function completeExternalSessionReplacement( + socket: Bun.ServerWebSocket, + replacement: AgentSessionReplacedMessage, + ): Promise { + if (replacement.previousSessionId === replacement.replacementSessionId) + throw new Error("Replacement session must differ from its source"); + const next = runtime.sessions.get(replacement.replacementSessionId); + if (!next || !next.agentSockets.has(socket)) + throw new Error("Replacement session is not bound to this agent socket"); + const durableReplacement = next.file + ? catalog.parseSessionMetadataFile(next.file)?.replacement + : replacementFromEntries(next.history); + if ( + !durableReplacement || + durableReplacement.previousSessionId !== replacement.previousSessionId || + normalizePath(durableReplacement.previousSessionFile) !== + normalizePath(replacement.previousSessionFile) || + durableReplacement.replacementSessionId !== + replacement.replacementSessionId + ) + throw new Error( + "Replacement activation does not match the durable session marker", + ); + const previous = runtime.sessions.get(replacement.previousSessionId); + if ( + previous && + (!previous.file || + normalizePath(previous.file) !== + normalizePath(replacement.previousSessionFile)) + ) { + throw new Error( + "Replacement source file does not match the registered source session", + ); + } + + if (!previous) { + if (existsSync(replacement.previousSessionFile)) return; + const orphaned = persistedQueues.get(replacement.previousSessionId); + if (orphaned?.length) { + const ids = new Set(); + const mergedQueue = [ + ...cloneWebQueue(orphaned), + ...cloneWebQueue(next.queue), + ].filter((item) => { + if (ids.has(item.id)) return false; + ids.add(item.id); + return true; + }); + await queueStoreWriter.mutate(persistedQueues, (queues) => { + queues.delete(replacement.previousSessionId); + if (mergedQueue.length > 0) queues.set(next.id, mergedQueue); + }); + next.queue = mergedQueue; + broadcastToSessionClients(next.id, webQueueEvent(next)); + if (next.status === "idle" && next.agentRunning !== true) + scheduleQueueSettleFallback(next); + } + sendSessionRemoved(replacement.previousSessionId, next.id); + return; + } + + // A durable marker can be written immediately before unlink. If Pi crashes in + // that narrow window, retain both sessions rather than treating it as committed. + if (existsSync(replacement.previousSessionFile)) return; + await quiesceQueueMutations(previous); + if (previous.queueDirtyWorker) { + await previous.queueDirtyWorker.cancelAndDrain(); + previous.queueDirtyWorker = undefined; + } + const ids = new Set(); + const mergedQueue = [ + ...cloneWebQueue(previous.queue), + ...cloneWebQueue(next.queue), + ].filter((item) => { + if (ids.has(item.id)) return false; + ids.add(item.id); + return true; + }); + try { + await queueStoreWriter.mutate(persistedQueues, (queues) => { + queues.delete(previous.id); + if (mergedQueue.length > 0) queues.set(next.id, mergedQueue); + else queues.delete(next.id); + }); + } catch (error) { + previous.queueMutationsQuiesced = false; + throw error; + } + next.queue = mergedQueue; + next.queueDeliveryAttempts ??= previous.queueDeliveryAttempts; + next.queueTransitionAttempts ??= previous.queueTransitionAttempts; + + for (const client of previous.clientSockets) { + next.clientSockets.add(client); + client.data.sessionId = next.id; + } + for (const [requestId, pending] of previous.externalPending) { + pending.owner = next; + next.externalPending.set(requestId, pending); + next.externalRequestTargets.set(requestId, socket); + } + previous.externalPending.clear(); + previous.externalRequestTargets.clear(); + cancelWebQueueWork(previous); + runtime.sessions.delete(previous.id); + if (previous.file) + runtime.sessionsByFile.delete(normalizePath(previous.file)); + for (const agent of previous.agentSockets) { + if (agent === socket) continue; + try { + agent.close(); + } catch { + /* ignore */ + } + } + broadcastToSessionClients(next.id, webQueueEvent(next)); + sendSessionRemoved(previous.id, next.id); + if ( + next.status === "idle" && + next.agentRunning !== true && + next.queue.length > 0 + ) + scheduleQueueSettleFallback(next); + } + + return { completeExternalSessionReplacement }; +} + +export type SessionReplacement = ReturnType; diff --git a/web/server/stateFileStore.ts b/web/server/stateFileStore.ts new file mode 100644 index 0000000..5a64f93 --- /dev/null +++ b/web/server/stateFileStore.ts @@ -0,0 +1,47 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { type ServerStateFile, WEB_STATE_VERSION } from "../protocol.js"; + +export function ensureDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function readStateFile(path: string): ServerStateFile | undefined { + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed && + parsed.version === WEB_STATE_VERSION && + typeof parsed.pid === "number" && + typeof parsed.port === "number" && + typeof parsed.startedAt === "number" + ) { + return parsed as ServerStateFile; + } + } catch { + // ignore + } + return undefined; +} + +export function writeStateFileAtomic( + path: string, + state: ServerStateFile, +): void { + ensureDir(dirname(path)); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { + mode: 0o600, + }); + renameSync(tempPath, path); +} + +export function getOrCreateWebState(port: number): ServerStateFile { + return { + pid: process.pid, + port, + startedAt: Date.now(), + version: WEB_STATE_VERSION, + }; +} diff --git a/web/server/tsconfig.json b/web/server/tsconfig.json index 1af8324..d101816 100644 --- a/web/server/tsconfig.json +++ b/web/server/tsconfig.json @@ -12,5 +12,5 @@ "noEmit": true, "strict": true }, - "include": ["./index.ts", "../protocol.ts", "../worktree-command.ts"] + "include": ["./**/*.ts", "../protocol.ts", "../worktree-command.ts"] } diff --git a/web/server/webServerApp.ts b/web/server/webServerApp.ts new file mode 100644 index 0000000..a21ccdb --- /dev/null +++ b/web/server/webServerApp.ts @@ -0,0 +1,214 @@ +import { createAgentMessages } from "./agentMessages.js"; +import { createClientBroadcast } from "./clientBroadcast.js"; +import { createClientMessages } from "./clientMessages.js"; +import { createCommandRouter } from "./commandRouter.js"; +import { createCompactionNotice } from "./compactionNotice.js"; +import { createDaemonOwnership } from "./daemonOwnership.js"; +import { createDiscoveryState } from "./discoveryState.js"; +import { createGitMetadata } from "./gitMetadata.js"; +import { createHttpApi } from "./httpApi.js"; +import { createManagedSessionLauncher } from "./managedSessionCreate.js"; +import { createManagedSessionRefresh } from "./managedSessionRefresh.js"; +import { createMissingSessions } from "./missingSessions.js"; +import { createRecordSync } from "./recordSync.js"; +import { createRpcSessionFactory } from "./rpcSessions.js"; +import type { SessionQueueCoordinator } from "./server-types.js"; +import { + resolveWebServerConfig, + type WebServerConfig, +} from "./serverConfig.js"; +import { createServerLifecycle } from "./serverLifecycle.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; +import { createServerRuntimeState } from "./serverRuntimeState.js"; +import { createServerStores } from "./serverStores.js"; +import { createSessionFileCatalog } from "./session-file-catalog.js"; +import { createSessionQueueCoordinator } from "./session-queue-coordinator.js"; +import { createSessionDeletion } from "./sessionDeletion.js"; +import { createSessionHistory } from "./sessionHistory.js"; +import { createSessionRegistry } from "./sessionRegistry.js"; +import { createSessionReplacement } from "./sessionReplacement.js"; +import { createStaticAssetResponder } from "./static-assets.js"; +import { createWebSocketGateway } from "./webSocketGateway.js"; + +/** + * Composition root for the web daemon. Wires the feature modules together in + * dependency order; the two genuine cycles (queue coordinator ↔ command + * router, registry ↔ deletion) are resolved with late-bound references. + */ +export function createWebServerApp() { + const config: WebServerConfig = resolveWebServerConfig(); + const state: ServerRuntimeState = createServerRuntimeState(config); + const stores = createServerStores(config); + const catalog = createSessionFileCatalog({ + sessionsDir: config.sessionsDir, + managedSessionStore: stores.managedSessionStore, + }); + const missingSessions = createMissingSessions({ + sessionsDir: config.sessionsDir, + catalog, + }); + const history = createSessionHistory({ catalog }); + const recordSync = createRecordSync({ catalog, state }); + + let reconcileMissingSessions: () => void = () => undefined; + const registry = createSessionRegistry({ + state, + config, + catalog, + stores, + history, + missingSessions, + reconcileMissingSessions: () => reconcileMissingSessions(), + }); + + const broadcast = createClientBroadcast({ state, registry, history }); + const git = createGitMetadata({ state, catalog, broadcast }); + const rpcSessions = createRpcSessionFactory({ config, catalog }); + + let router: ReturnType | undefined; + const queue: SessionQueueCoordinator = createSessionQueueCoordinator({ + persistedQueues: stores.persistedQueues, + queueStoreWriter: stores.queueStoreWriter, + currentRecord: (id) => state.sessions.get(id), + isShutdownStarted: () => state.shutdownStarted, + broadcast: broadcast.broadcast, + deliverCommand: (record, command) => { + if (!router) throw new Error("Command router used before initialization"); + return router.routeCommand(record, command); + }, + projectSession: registry.sessionToClientPayload, + }); + + const compactionNotice = createCompactionNotice({ + state, + broadcastCompactionComplete: queue.broadcastCompactionComplete, + }); + const refresh = createManagedSessionRefresh({ + state, + config, + catalog, + stores, + history, + recordSync, + queue, + broadcast, + }); + const replacement = createSessionReplacement({ + state, + catalog, + stores, + queue, + broadcast, + }); + router = createCommandRouter({ + config, + catalog, + queue, + registry, + broadcast, + refresh, + git, + rpcSessions, + }); + + const deletion = createSessionDeletion({ + state, + config, + catalog, + stores, + queue, + registry, + missingSessions, + broadcast, + router, + }); + reconcileMissingSessions = () => void deletion.reconcileMissingSessionFiles(); + + const launcher = createManagedSessionLauncher({ + state, + catalog, + stores, + history, + recordSync, + queue, + broadcast, + registry, + refresh, + git, + compactionNotice, + rpcSessions, + }); + const clientMessages = createClientMessages({ + state, + sessionsDir: config.sessionsDir, + catalog, + registry, + queue, + broadcast, + router, + refresh, + }); + const agentMessages = createAgentMessages({ + state, + catalog, + registry, + history, + recordSync, + queue, + broadcast, + git, + compactionNotice, + replacement, + }); + const discovery = createDiscoveryState({ config, state }); + const httpApi = createHttpApi({ + config, + state, + discovery, + catalog, + registry, + missingSessions, + launcher, + deletion, + }); + const gateway = createWebSocketGateway({ + state, + catalog, + broadcast, + clientMessages, + agentMessages, + }); + const ownership = createDaemonOwnership({ config, state }); + const staticAssetResponse = createStaticAssetResponder(config.distDir); + const lifecycle = createServerLifecycle({ + config, + state, + catalog, + discovery, + ownership, + httpApi, + gateway, + launcher, + refresh, + deletion, + queue, + staticAssetResponse, + }); + + return { + config, + state, + discovery, + deletion, + launcher, + refresh, + httpApi, + gateway, + ownership, + queue, + lifecycle, + staticAssetResponse, + }; +} + +export type WebServerApp = ReturnType; diff --git a/web/server/webSocketGateway.ts b/web/server/webSocketGateway.ts new file mode 100644 index 0000000..d1be7f5 --- /dev/null +++ b/web/server/webSocketGateway.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentToServerMessage, + ClientToServerMessage, + ServerResponseMessage, +} from "../protocol.js"; +import type { AgentMessages } from "./agentMessages.js"; +import type { ClientBroadcast } from "./clientBroadcast.js"; +import type { ClientMessages } from "./clientMessages.js"; +import { + CommandDeliveryUncertainError, + isUncertainRpcDeliveryCommand, +} from "./managed-rpc-session.js"; +import type { + AgentSocketData, + ClientSocketData, + SessionFileCatalog, + SocketData, +} from "./server-types.js"; +import type { ServerRuntimeState } from "./serverRuntimeState.js"; + +const decoder = new TextDecoder(); + +/** WebSocket endpoint behavior for browser clients and Pi bridge agents. */ +export function createWebSocketGateway(options: { + state: ServerRuntimeState; + catalog: SessionFileCatalog; + broadcast: ClientBroadcast; + clientMessages: ClientMessages; + agentMessages: AgentMessages; +}) { + const { + state: runtime, + catalog, + broadcast, + clientMessages, + agentMessages, + } = options; + const { isRecord } = catalog; + const { broadcastSessionToAll } = broadcast; + const { handleClientMessage } = clientMessages; + const { handleAgentMessage } = agentMessages; + + function attachClientSocket( + socket: Bun.ServerWebSocket, + ): void { + socket.data = { kind: "client", id: randomUUID(), authed: false }; + } + + function attachAgentSocket( + socket: Bun.ServerWebSocket, + ): void { + socket.data = { kind: "agent", id: randomUUID(), authed: false }; + } + + function parseSocketMessage(data: string | Uint8Array): T | undefined { + const text = typeof data === "string" ? data : decoder.decode(data); + try { + return JSON.parse(text) as T; + } catch { + return undefined; + } + } + + function handleWebSocketOpen(socket: Bun.ServerWebSocket): void { + if (socket.data.kind === "client") + attachClientSocket(socket as Bun.ServerWebSocket); + else attachAgentSocket(socket as Bun.ServerWebSocket); + } + + async function handleWebSocketMessage( + socket: Bun.ServerWebSocket, + data: string | Uint8Array, + ): Promise { + const parsed = parseSocketMessage< + ClientToServerMessage | AgentToServerMessage + >(data); + if (!parsed) { + socket.close(1003, "Invalid JSON"); + return; + } + try { + if (socket.data.kind === "client") + await handleClientMessage( + socket as Bun.ServerWebSocket, + parsed as ClientToServerMessage, + ); + else + await handleAgentMessage( + socket as Bun.ServerWebSocket, + parsed as AgentToServerMessage, + ); + } catch (error) { + try { + const frame: unknown = parsed; + const requestId = + isRecord(frame) && typeof frame.requestId === "string" + ? frame.requestId + : undefined; + if (socket.data.kind === "client" && requestId) { + socket.send( + JSON.stringify({ + type: "server.response", + requestId, + success: false, + error: error instanceof Error ? error.message : String(error), + } satisfies ServerResponseMessage), + ); + } else { + console.error("Pi web agent message failed:", error); + socket.close(1011, "WebSocket message failed"); + } + } catch { + // ignore + } + } + } + + function handleWebSocketClose(socket: Bun.ServerWebSocket): void { + if (socket.data.kind === "client") { + runtime.connectedClientSockets.delete( + socket as Bun.ServerWebSocket, + ); + for (const record of runtime.sessions.values()) { + if ( + !record.clientSockets.delete( + socket as Bun.ServerWebSocket, + ) + ) + continue; + } + return; + } + for (const record of runtime.sessions.values()) { + if ( + !record.agentSockets.delete( + socket as Bun.ServerWebSocket, + ) + ) + continue; + for (const [ + requestId, + target, + ] of record.externalRequestTargets.entries()) { + if (target !== socket) continue; + record.externalRequestTargets.delete(requestId); + const pending = record.externalPending.get(requestId); + if (pending?.surviveDisconnect) continue; + if (pending) { + record.externalPending.delete(requestId); + pending.reject( + pending.commandType && + isUncertainRpcDeliveryCommand(pending.commandType) + ? new CommandDeliveryUncertainError( + `Agent socket closed before ${pending.commandType} acknowledgement`, + ) + : new Error("Agent socket closed"), + ); + } + } + if (record.agentSockets.size === 0 && record.kind === "external") { + record.status = "offline"; + record.active = false; + broadcastSessionToAll(record); + } + } + } + + return { + handleWebSocketOpen, + handleWebSocketMessage, + handleWebSocketClose, + }; +} + +export type WebSocketGateway = ReturnType; From 9ec7c7707c0d3ff234787387bdbbfbecef50f918 Mon Sep 17 00:00:00 2001 From: Ian Walter <122028+ianwalter@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:24:28 -0400 Subject: [PATCH 2/3] Add Informant lint job Runs `bun run lint` (biome lint .) on every push and open non-draft PR, alongside the existing build, test, and typecheck jobs. --- .informant/jobs/lint.toml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .informant/jobs/lint.toml diff --git a/.informant/jobs/lint.toml b/.informant/jobs/lint.toml new file mode 100644 index 0000000..0efbaef --- /dev/null +++ b/.informant/jobs/lint.toml @@ -0,0 +1,7 @@ +name = "lint" +command = """ +set -eu +bun run lint +""" +timeout_minutes = 10 +container = { cpu = 1, memory_mb = 2048 } \ No newline at end of file From d6d914f24c62ecb68fdbfdce68e4973b54fcb545 Mon Sep 17 00:00:00 2001 From: pi-agent Date: Wed, 19 Aug 2026 09:55:02 -0400 Subject: [PATCH 3/3] Address PR #13 CodeRabbit review feedback - agentMessages: enforce per-session socket ownership for agent.event, agent.subagents, agent.update, and agent.response so a bridge frame cannot mutate sessions it has not been bound to. Defer setting socket.data.authed until after the agent.hello session record is created and the socket is bound to it. - commandRouter: start the temporary managed session inside the existing try/finally so temp.shutdown() always runs on failure. - compactionNotice: consume rejection from the compaction history refresh so its finally callback cannot produce an unhandled rejection. - gitMetadata: run git branch and gh pr view concurrently. - httpApi: return structured HTTP 500 jsonResponse errors for managed session creation failures instead of rethrowing. - managedSessionCreate: merge adjacent agent_start/turn_start checks. - managedSessionRefresh: switch the tombstone scan to a single recursive readdirSync({recursive, withFileTypes}) call. - missingSessions: thread an optional precomputed scan list through hasStagedOrDurableReplacement / isMissingInactiveSession so one reconciliation pass reuses a single scanSavedSessions result. - serverConfig: trim PI_WEB_PORT and fall back to DEFAULT_WEB_PORT when the trimmed value is empty. - serverLifecycle: bound the managed-shutdown wait with a max duration and add a WEB_BUILD_TIMEOUT_MS for the client asset build. - sessionDeletion: route sessionsByFile/managedSessionStarts lookups through sessionFileKey (= normalizePath) so keys match producers and document the shared key contract. - sessionHistory: add a non-empty history guard to the trim loop and correct the module header to reflect lazy-hydration side effects. - sessionRegistry: type reconcileMissingSessions as void | Promise, consume its rejection at the snapshot call site, only compute displayHistory for new records, share the scan across the snapshot pass, and drop the redundant record.active assignment. - sessionReplacement: restore the queue retry timer after a failed store write so the retained queue does not stall. - webServerApp: make the late-bound reconcileMissingSessions fail fast before its reassignment, matching the deliverCommand binding. - webSocketGateway: stop reassigning socket.data in the open handler; upgrade-time initialization is the single assignment site. --- web/server/agentMessages.ts | 267 ++++++++++++++-------------- web/server/commandRouter.ts | 2 +- web/server/compactionNotice.ts | 5 +- web/server/gitMetadata.ts | 12 +- web/server/httpApi.ts | 17 +- web/server/managedSessionCreate.ts | 3 +- web/server/managedSessionRefresh.ts | 50 +++--- web/server/missingSessions.ts | 21 ++- web/server/serverConfig.ts | 9 +- web/server/serverLifecycle.ts | 26 +-- web/server/sessionDeletion.ts | 23 ++- web/server/sessionHistory.ts | 11 +- web/server/sessionRegistry.ts | 73 ++++---- web/server/sessionReplacement.ts | 3 + web/server/webServerApp.ts | 4 +- web/server/webSocketGateway.ts | 18 +- 16 files changed, 290 insertions(+), 254 deletions(-) diff --git a/web/server/agentMessages.ts b/web/server/agentMessages.ts index bfa2ea3..9a6408c 100644 --- a/web/server/agentMessages.ts +++ b/web/server/agentMessages.ts @@ -93,9 +93,12 @@ export function createAgentMessages(options: { async function handleAgentMessage( socket: Bun.ServerWebSocket, message: AgentToServerMessage, - ): Promise { + ): Promise { + // Trust the /ws/agent upgrade (local-only and rejected when forwarded by + // Tailscale Serve) plus per-session socket ownership. agent.hello is the + // session-binding handshake: only mark the socket authenticated after the + // session record is created or updated and the socket is bound to it. if (message.type === "agent.hello") { - socket.data.authed = true; const hello = message as AgentHelloMessage; const kind: SessionKind = hello.session.source === "saved" @@ -114,6 +117,8 @@ export function createAgentMessages(options: { kind, authoritativeHistory ? helloHistory : [], ); + if (!record) return; + socket.data.authed = true; if (authoritativeHistory) replaceRecordHistory(record, helloHistory); else if (!record.historyReady && record.file) { // Bridges loaded before historyMode existed reconnect with an empty hello. @@ -190,152 +195,145 @@ export function createAgentMessages(options: { if (message.type === "agent.event") { const event = message as AgentEventMessage; const record = runtime.sessions.get(event.sessionId); - if (record) { - record.updatedAt = Date.now(); - let lifecycleChanged = false; - if ( - event.event.type === "agent_start" || - event.event.type === "turn_start" - ) { - record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; - markAgentActivity(record); - } - if ( - event.event.type === "agent_start" || - event.event.type === "turn_start" - ) { - cancelQueueSettleFallback(record); - record.status = "working"; - record.agentRunning = true; - lifecycleChanged = true; - } - if (event.event.type === "agent_end" && !record.compaction) { - markAgentSettling(record); - record.status = - agentEndTerminalNotice(event.event)?.kind === "error" - ? "error" - : "idle"; + if (!record || !record.agentSockets.has(socket)) return; + record.updatedAt = Date.now(); + let lifecycleChanged = false; + if ( + event.event.type === "agent_start" || + event.event.type === "turn_start" + ) { + record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; + markAgentActivity(record); + cancelQueueSettleFallback(record); + record.status = "working"; + record.agentRunning = true; + lifecycleChanged = true; + } + if (event.event.type === "agent_end" && !record.compaction) { + markAgentSettling(record); + record.status = + agentEndTerminalNotice(event.event)?.kind === "error" + ? "error" + : "idle"; + record.agentRunning = false; + scheduleQueueSettleFallback(record); + lifecycleChanged = true; + } + if (event.event.type === "compaction_start") { + markAgentSettling(record); + record.status = "working"; + record.agentRunning = true; + record.compaction = { + reason: + event.event.reason === "manual" || event.event.reason === "overflow" + ? event.event.reason + : "threshold", + startedAt: + typeof event.event.startedAt === "number" + ? event.event.startedAt + : Date.now(), + }; + } + if (event.event.type === "compaction_end") { + record.compaction = undefined; + if (event.event.aborted === true || event.event.willRetry === false) { + record.status = "idle"; record.agentRunning = false; scheduleQueueSettleFallback(record); lifecycleChanged = true; } - if (event.event.type === "compaction_start") { - markAgentSettling(record); - record.status = "working"; - record.agentRunning = true; - record.compaction = { - reason: - event.event.reason === "manual" || - event.event.reason === "overflow" - ? event.event.reason - : "threshold", - startedAt: - typeof event.event.startedAt === "number" - ? event.event.startedAt - : Date.now(), - }; - } - if (event.event.type === "compaction_end") { - record.compaction = undefined; - if (event.event.aborted === true || event.event.willRetry === false) { - record.status = "idle"; - record.agentRunning = false; - scheduleQueueSettleFallback(record); - lifecycleChanged = true; - } - if (event.event.aborted !== true) { - broadcastCompactionNotice(record); - } - } - if ( - event.event.type === "agent_settled" && - isCurrentAgentSettlement(record) - ) { - cancelQueueSettleFallback(record); - record.settlingGeneration = undefined; - lifecycleChanged = true; - // Pi emits agent_settled only when no retry, compaction, or internal - // follow-up remains. It is authoritative even when an interrupted - // overflow compaction last advertised willRetry=true. - if (record.status !== "error") record.status = "idle"; - record.agentRunning = false; - void flushWebQueue(record); - } - const subagentsChanged = updateSubagentsFromToolEvent( - record, - event.event, - ); - let sessionMetadataChanged = false; - if (event.event.type === "session_info_changed") { - record.name = - typeof event.event.name === "string" && event.event.name - ? event.event.name - : undefined; - sessionMetadataChanged = true; + if (event.event.aborted !== true) { + broadcastCompactionNotice(record); } + } + if ( + event.event.type === "agent_settled" && + isCurrentAgentSettlement(record) + ) { + cancelQueueSettleFallback(record); + record.settlingGeneration = undefined; + lifecycleChanged = true; + // Pi emits agent_settled only when no retry, compaction, or internal + // follow-up remains. It is authoritative even when an interrupted + // overflow compaction last advertised willRetry=true. + if (record.status !== "error") record.status = "idle"; + record.agentRunning = false; + void flushWebQueue(record); + } + const subagentsChanged = updateSubagentsFromToolEvent( + record, + event.event, + ); + let sessionMetadataChanged = false; + if (event.event.type === "session_info_changed") { + record.name = + typeof event.event.name === "string" && event.event.name + ? event.event.name + : undefined; + sessionMetadataChanged = true; + } + if ( + event.event.type === "message_end" && + isRecord(event.event.message) + ) { + appendRecordHistory(record, { + type: "message", + id: randomUUID(), + parentId: null, + timestamp: new Date().toISOString(), + message: event.event.message, + }); + record.messageCount += 1; if ( - event.event.type === "message_end" && - isRecord(event.event.message) + event.event.message.role === "assistant" || + event.event.message.role === "toolResult" ) { - appendRecordHistory(record, { - type: "message", - id: randomUUID(), - parentId: null, - timestamp: new Date().toISOString(), - message: event.event.message, - }); - record.messageCount += 1; - if ( - event.event.message.role === "assistant" || - event.event.message.role === "toolResult" - ) { - record.usage ??= zeroWebUsage(); - addWebUsage(record.usage, event.event.message.usage); - } - if ( - event.event.message.role === "user" || - event.event.message.role === "assistant" - ) { - const preview = extractTextContent(event.event.message.content); - const terminalNotice = assistantTerminalNotice(event.event.message); - if (preview) record.preview = preview.slice(0, 180); - else if (terminalNotice) - record.preview = - `${terminalNotice.title}: ${terminalNotice.detail}`.slice( - 0, - 180, - ); - } - sessionMetadataChanged = true; + record.usage ??= zeroWebUsage(); + addWebUsage(record.usage, event.event.message.usage); } - broadcastToSessionClients(event.sessionId, { - type: "server.event", - sessionId: event.sessionId, - event: event.event, - } satisfies ServerEventMessage); if ( - sessionMetadataChanged || - lifecycleChanged || - event.event.type === "compaction_start" || - event.event.type === "compaction_end" + event.event.message.role === "user" || + event.event.message.role === "assistant" ) { - broadcastSessionToAll(record); - } else if (subagentsChanged) { - broadcastToSessionClients(record.id, { - type: "server.session", - session: sessionToClientPayload(record), - } satisfies ServerSessionMessage); + const preview = extractTextContent(event.event.message.content); + const terminalNotice = assistantTerminalNotice(event.event.message); + if (preview) record.preview = preview.slice(0, 180); + else if (terminalNotice) + record.preview = + `${terminalNotice.title}: ${terminalNotice.detail}`.slice( + 0, + 180, + ); } - // PRs are commonly opened during an agent run without changing branches. - // Refresh after completion so the catalog does not retain the pre-PR lookup. - if (event.event.type === "agent_end") void hydrateGitMetadata(record); + sessionMetadataChanged = true; } + broadcastToSessionClients(event.sessionId, { + type: "server.event", + sessionId: event.sessionId, + event: event.event, + } satisfies ServerEventMessage); + if ( + sessionMetadataChanged || + lifecycleChanged || + event.event.type === "compaction_start" || + event.event.type === "compaction_end" + ) { + broadcastSessionToAll(record); + } else if (subagentsChanged) { + broadcastToSessionClients(record.id, { + type: "server.session", + session: sessionToClientPayload(record), + } satisfies ServerSessionMessage); + } + // PRs are commonly opened during an agent run without changing branches. + // Refresh after completion so the catalog does not retain the pre-PR lookup. + if (event.event.type === "agent_end") void hydrateGitMetadata(record); return; } if (message.type === "agent.subagents") { const update = message as AgentSubagentsMessage; const record = runtime.sessions.get(update.sessionId); - if (!record) return; + if (!record || !record.agentSockets.has(socket)) return; record.subagents = mergeWebSubagentUpdates( record.subagents, update.agents, @@ -362,6 +360,7 @@ export function createAgentMessages(options: { if (message.type === "agent.update") { const update = message as AgentUpdateMessage; const existing = runtime.sessions.get(update.session.id); + if (!existing || !existing.agentSockets.has(socket)) return; // Older bridge runtimes reported `working` again immediately after their // authoritative agent_end event. Preserve the lifecycle event until a new // agent_start arrives so completed runs cannot get stuck visually working. @@ -399,6 +398,12 @@ export function createAgentMessages(options: { candidate.externalPending.has(response.requestId), ); if (!record) return; + if (!record.agentSockets.has(socket)) return; + if ( + record.externalRequestTargets.get(response.requestId) !== undefined && + record.externalRequestTargets.get(response.requestId) !== socket + ) + return; const pending = record.externalPending.get(response.requestId); if (!pending) return; record.externalPending.delete(response.requestId); diff --git a/web/server/commandRouter.ts b/web/server/commandRouter.ts index fbcd9a0..b8657a9 100644 --- a/web/server/commandRouter.ts +++ b/web/server/commandRouter.ts @@ -122,8 +122,8 @@ export function createCommandRouter(options: { }, onExit: () => undefined, }); - await temp.start(); try { + await temp.start(); switch (command.type) { case "clone": return await temp.clone(); diff --git a/web/server/compactionNotice.ts b/web/server/compactionNotice.ts index 7426509..8e52f56 100644 --- a/web/server/compactionNotice.ts +++ b/web/server/compactionNotice.ts @@ -18,7 +18,10 @@ export function createCompactionNotice(options: { if (runtime.sessions.get(record.id) !== record) return; broadcastCompactionComplete(record); }; - if (refresh) void refresh.finally(deliver); + if (refresh) + void refresh.then(deliver, () => { + deliver(); + }); else deliver(); } diff --git a/web/server/gitMetadata.ts b/web/server/gitMetadata.ts index 073a844..9423774 100644 --- a/web/server/gitMetadata.ts +++ b/web/server/gitMetadata.ts @@ -46,14 +46,10 @@ export function createGitMetadata(options: { const generation = (record.gitMetadataGeneration ?? 0) + 1; record.gitMetadataGeneration = generation; const cwd = record.cwd; - const branch = await commandOutput( - ["git", "branch", "--show-current"], - cwd, - ); - const raw = await commandOutput( - ["gh", "pr", "view", "--json", "number,url"], - cwd, - ); + const [branch, raw] = await Promise.all([ + commandOutput(["git", "branch", "--show-current"], cwd), + commandOutput(["gh", "pr", "view", "--json", "number,url"], cwd), + ]); let pullRequest: WebSession["pullRequest"]; if (raw) { try { diff --git a/web/server/httpApi.ts b/web/server/httpApi.ts index 90230c4..90c288c 100644 --- a/web/server/httpApi.ts +++ b/web/server/httpApi.ts @@ -344,14 +344,23 @@ export function createHttpApi(options: { // stale initial session instead of retaining it for inspection. if (worktree.existingCheckout) { cleanupInitialSessionFile(initialSessionFile); - throw new Error(startupMessage); + return jsonResponse( + { error: startupMessage }, + { status: 500 }, + ); } - throw new Error( - `${startupMessage}; initialized worktree retained at ${worktree.path} for inspection`, + return jsonResponse( + { + error: `${startupMessage}; initialized worktree retained at ${worktree.path} for inspection`, + }, + { status: 500 }, ); } cleanupInitialSessionFile(initialSessionFile); - throw error; + return jsonResponse( + { error: startupMessage }, + { status: 500 }, + ); } return jsonResponse( { session: sessionToClientPayload(session), worktree }, diff --git a/web/server/managedSessionCreate.ts b/web/server/managedSessionCreate.ts index 51a20dd..aa8d51b 100644 --- a/web/server/managedSessionCreate.ts +++ b/web/server/managedSessionCreate.ts @@ -186,8 +186,6 @@ export function createManagedSessionLauncher(options: { if (event.type === "agent_start" || event.type === "turn_start") { record.agentStartGeneration = (record.agentStartGeneration ?? 0) + 1; markAgentActivity(record); - } - if (event.type === "agent_start" || event.type === "turn_start") { cancelQueueSettleFallback(record); record.status = "working"; record.agentRunning = true; @@ -400,6 +398,7 @@ export function createManagedSessionLauncher(options: { sessionFile?: string, ): Promise { if (!sessionFile) return await createManagedSessionUnlocked(cwd, name); + // Use normalized paths for pending-start dedupe and in-flight start tracking. const key = normalizePath(sessionFile); const existing = runtime.managedSessionStarts.get(key); if (existing) return await existing; diff --git a/web/server/managedSessionRefresh.ts b/web/server/managedSessionRefresh.ts index ba1e2ad..7397321 100644 --- a/web/server/managedSessionRefresh.ts +++ b/web/server/managedSessionRefresh.ts @@ -331,32 +331,30 @@ export function createManagedSessionRefresh(options: { async function recoverStagedSourceSessionDeletions(): Promise { const tombstones: Array<{ tombstone: string; source: string }> = []; - const stack = [sessionsDir]; - while (stack.length > 0) { - const directory = stack.pop(); - if (!directory) break; - let entries: Array<{ - name: string; - isDirectory(): boolean; - isFile(): boolean; - }>; - try { - entries = readdirSync(directory, { withFileTypes: true }) as Array<{ - name: string; - isDirectory(): boolean; - isFile(): boolean; - }>; - } catch { - continue; - } - for (const entry of entries) { - const path = join(directory, entry.name); - if (entry.isDirectory()) stack.push(path); - else if (entry.isFile()) { - const match = path.match(/^(.*\.jsonl)\.replaced-[0-9a-f-]+\.tmp$/i); - if (match) tombstones.push({ tombstone: path, source: match[1] }); - } - } + type RecursiveDirent = { + parentPath?: string; + path?: string; + name: string; + isFile(): boolean; + }; + let entries: RecursiveDirent[] = []; + try { + entries = readdirSync(sessionsDir, { + recursive: true, + withFileTypes: true, + }) as unknown as RecursiveDirent[]; + } catch { + entries = []; + } + for (const entry of entries) { + if (!entry.isFile()) continue; + const basePath = + entry.parentPath ?? (typeof entry.path === "string" + ? entry.path + : sessionsDir); + const path = join(basePath, entry.name); + const match = path.match(/^(.*\.jsonl)\.replaced-[0-9a-f-]+\.tmp$/i); + if (match) tombstones.push({ tombstone: path, source: match[1] }); } if (tombstones.length === 0) return; const saved = listSavedSessionFiles(sessionsDir).flatMap((file) => { diff --git a/web/server/missingSessions.ts b/web/server/missingSessions.ts index 1f4ac9b..9b0709a 100644 --- a/web/server/missingSessions.ts +++ b/web/server/missingSessions.ts @@ -2,6 +2,7 @@ import { readdirSync } from "node:fs"; import { basename, dirname } from "node:path"; import { isConfirmedMissingPath } from "./file-presence.js"; import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; +import type { SessionFileScan } from "./session-file-catalog.js"; /** * Predicates for sessions whose backing file disappeared without a durable @@ -14,7 +15,10 @@ export function createMissingSessions(options: { const { sessionsDir, catalog } = options; const { sessionFileKey, scanSavedSessions } = catalog; - function hasStagedOrDurableReplacement(record: SessionRecord): boolean { + function hasStagedOrDurableReplacement( + record: SessionRecord, + scans: readonly SessionFileScan[] = scanSavedSessions(sessionsDir), + ): boolean { const sourceFile = record.file; if (!sourceFile) return false; try { @@ -30,27 +34,30 @@ export function createMissingSessions(options: { } catch { // The containing session directory may itself have been removed. } - for (const scan of scanSavedSessions(sessionsDir)) { - if (sessionFileKey(scan.file) === sessionFileKey(sourceFile)) continue; + const sourceKey = sessionFileKey(sourceFile); + for (const scan of scans) { + if (sessionFileKey(scan.file) === sourceKey) continue; const replacement = scan.replacement; if ( replacement?.previousSessionId === record.id && - sessionFileKey(replacement.previousSessionFile) === - sessionFileKey(sourceFile) + sessionFileKey(replacement.previousSessionFile) === sourceKey ) return true; } return false; } - function isMissingInactiveSession(record: SessionRecord): boolean { + function isMissingInactiveSession( + record: SessionRecord, + scans?: readonly SessionFileScan[], + ): boolean { return Boolean( record.file && !record.active && !record.managed && record.agentSockets.size === 0 && isConfirmedMissingPath(record.file) && - !hasStagedOrDurableReplacement(record), + !hasStagedOrDurableReplacement(record, scans), ); } diff --git a/web/server/serverConfig.ts b/web/server/serverConfig.ts index d416074..484de04 100644 --- a/web/server/serverConfig.ts +++ b/web/server/serverConfig.ts @@ -48,9 +48,12 @@ export function resolveWebServerConfig(): WebServerConfig { const agentDir = resolve( process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), ); - const configuredPort = Number( - process.env.PI_WEB_PORT ?? `${DEFAULT_WEB_PORT}`, - ); + const configuredPortEnv = + process.env.PI_WEB_PORT === undefined + ? `${DEFAULT_WEB_PORT}` + : process.env.PI_WEB_PORT.trim(); + const configuredPort = + configuredPortEnv.length > 0 ? Number(configuredPortEnv) : DEFAULT_WEB_PORT; return { rootDir, distDir: join(rootDir, "web", "dist"), diff --git a/web/server/serverLifecycle.ts b/web/server/serverLifecycle.ts index 2050863..53f3dd1 100644 --- a/web/server/serverLifecycle.ts +++ b/web/server/serverLifecycle.ts @@ -16,6 +16,9 @@ import type { SessionRecord, SocketData, } from "./server-types.js"; + +const WEB_BUILD_TIMEOUT_MS = 10_000; + import type { WebServerConfig } from "./serverConfig.js"; import { MAX_WEBSOCKET_PAYLOAD_BYTES, @@ -68,6 +71,7 @@ export function createServerLifecycle(options: { const { cancelWebQueueWork } = queue; async function cleanupAndExit(code = 0): Promise { + const managedShutdownTimeoutMs = 15_000; // Repeated TERM/INT delivery is common during deploys. Never reinterpret it as // permission to abort managed work; an operator can still use SIGKILL for a // truly wedged process. @@ -80,12 +84,14 @@ export function createServerLifecycle(options: { [...runtime.sessions.values()] .filter(shouldWaitForManagedShutdown) .map((record) => record.name ?? record.id); + const shutdownStartedAt = Date.now(); let busy = busyNames(); if (busy.length > 0) console.error( `Waiting for active managed sessions before restart: ${busy.join(", ")}`, ); while (shouldContinueManagedShutdownWait(busy.length)) { + if (Date.now() - shutdownStartedAt >= managedShutdownTimeoutMs) break; await Bun.sleep(100); busy = busyNames(); } @@ -132,7 +138,7 @@ export function createServerLifecycle(options: { } catch { // ignore } - setTimeout(() => process.exit(code), 25).unref(); + setTimeout(() => process.exit(code), 25); } // web/dist is not checked in; rebuild it on every startup so a long-running @@ -154,6 +160,7 @@ export function createServerLifecycle(options: { cwd: config.rootDir, stdout: "inherit", stderr: "inherit", + timeout: WEB_BUILD_TIMEOUT_MS, }); const code = await build.exited; if (code !== 0) @@ -269,10 +276,8 @@ export function createServerLifecycle(options: { }, websocket: { maxPayloadLength: MAX_WEBSOCKET_PAYLOAD_BYTES, - open(socket) { - gateway.handleWebSocketOpen( - socket as Bun.ServerWebSocket, - ); + open() { + gateway.handleWebSocketOpen(); }, message(socket, data) { void gateway.handleWebSocketMessage( @@ -304,13 +309,12 @@ export function createServerLifecycle(options: { // Keep readiness independent from JSONL catalog work, then restore only the // browser-owned sessions that were active before the daemon stopped. Native // bridge sessions reconnect themselves and are never started in RPC mode here. - setTimeout( - () => - void launcher.restoreManagedSessions().catch((error) => { - console.error("Could not restore managed web sessions:", error); - }), + setTimeout(() => + void launcher.restoreManagedSessions().catch((error) => { + console.error("Could not restore managed web sessions:", error); + }), 250, - ).unref(); + ); } return { start, cleanupAndExit }; diff --git a/web/server/sessionDeletion.ts b/web/server/sessionDeletion.ts index d3b7036..8a8cae5 100644 --- a/web/server/sessionDeletion.ts +++ b/web/server/sessionDeletion.ts @@ -168,10 +168,11 @@ export function createSessionDeletion(options: { async function reconcileMissingSessionFiles(): Promise { if (runtime.shutdownStarted) return; + const scans = scanSavedSessions(sessionsDir); const candidates = [...runtime.sessions.values()].filter( (record) => !runtime.missingSessionReconciliations.has(record) && - isMissingInactiveSession(record), + isMissingInactiveSession(record, scans), ); await Promise.all( candidates.map(async (record) => { @@ -181,7 +182,7 @@ export function createSessionDeletion(options: { if ( !sessionFile || runtime.sessions.get(record.id) !== record || - !isMissingInactiveSession(record) + !isMissingInactiveSession(record, scans) ) return; const managed = isManagedSessionFile(sessionFile); @@ -201,7 +202,7 @@ export function createSessionDeletion(options: { undefined, () => runtime.sessions.get(record.id) === record && - isMissingInactiveSession(record), + isMissingInactiveSession(record, scans), ); if (!deleted) return; if (managed) { @@ -298,14 +299,16 @@ export function createSessionDeletion(options: { })(); if (!record) throw new Error(`Unknown session: ${sessionId}`); if (record.file) { - const pendingStart = runtime.managedSessionStarts.get( - sessionFileKey(record.file), - ); + // Map keys are written with normalizePath (via sessionFileKey, which is the + // shared key helper) in managedSessionCreate.ts; reuse it here so we await + // in-flight starts and stale records even if path normalization varies. + const fileKey = sessionFileKey(record.file); + const pendingStart = runtime.managedSessionStarts.get(fileKey); if (pendingStart) { await pendingStart.catch(() => undefined); record = runtime.sessions.get(sessionId) ?? - runtime.sessionsByFile.get(sessionFileKey(record.file)) ?? + runtime.sessionsByFile.get(fileKey) ?? record; } } @@ -381,7 +384,11 @@ export function createSessionDeletion(options: { scheduleManagedWorktreeCleanup(sessionId, sessionFile, managedWorktree); } - /** Drop a not-yet-activated initial session file and its registrations. */ + /** + * Drop a not-yet-activated initial session file and its registrations. + * Map keys are produced with `sessionFileKey` (= normalizePath) so the lookup + * matches the writers in managedSessionCreate.ts. + */ function cleanupInitialSessionFile(initialSessionFile?: string): void { if (!initialSessionFile) return; const key = sessionFileKey(initialSessionFile); diff --git a/web/server/sessionHistory.ts b/web/server/sessionHistory.ts index df79275..df0f668 100644 --- a/web/server/sessionHistory.ts +++ b/web/server/sessionHistory.ts @@ -11,8 +11,10 @@ import { import type { SessionFileCatalog, SessionRecord } from "./server-types.js"; /** - * Bounded semantic history maintained per record. Pure module: all functions - * operate on the record passed in and catalog helpers only. + * Bounded semantic history maintained per record. + * sessionHistoryForRecord lazily hydrates file-backed records and mutates + * record.history, record.historyReady, and record.historyBytes through + * replaceRecordHistory. */ export function createSessionHistory(options: { catalog: SessionFileCatalog }) { const { parseSessionFile, isRecord } = options.catalog; @@ -36,8 +38,9 @@ export function createSessionHistory(options: { catalog: SessionFileCatalog }) { webHistoryByteLength(record.history.slice(0, -1))) + webHistoryByteLength(appended); while ( - record.history.length > WEB_HISTORY_MAX_ENTRIES || - record.historyBytes > WEB_HISTORY_MAX_BYTES + record.history.length > 0 && + (record.history.length > WEB_HISTORY_MAX_ENTRIES || + record.historyBytes > WEB_HISTORY_MAX_BYTES) ) { const first = record.history[0]; const preserveSummary = diff --git a/web/server/sessionRegistry.ts b/web/server/sessionRegistry.ts index 9660d14..fe202a6 100644 --- a/web/server/sessionRegistry.ts +++ b/web/server/sessionRegistry.ts @@ -31,7 +31,7 @@ export function createSessionRegistry(options: { history: SessionHistory; missingSessions: MissingSessions; /** Late-bound so session deletion can depend on the registry. */ - reconcileMissingSessions: () => void; + reconcileMissingSessions: () => void | Promise; }) { const { state: runtime, catalog, stores, history, missingSessions } = options; const { sessionsDir } = options.config; @@ -93,33 +93,40 @@ export function createSessionRegistry(options: { history: unknown[] = [], managedWorktreeScanned = false, ): SessionRecord { - const displayHistory = boundedWebHistory( - kind === "saved" - ? buildContextEntries(history as SessionEntry[]) - : history, - ); - const historyManagedWorktree = managedWorktreeFromEntries(history); - const record = runtime.sessions.get(session.id) ?? { - ...session, - kind, - history: displayHistory, - historyReady: history.length > 0, - historyBytes: webHistoryByteLength(displayHistory), - active: kind !== "saved", - agentRunning: session.status === "working", - managedWorktreeScanned, - agentSockets: new Set>(), - clientSockets: new Set>(), - externalRequestTargets: new Map(), - externalPending: new Map(), - queue: (persistedQueues.get(session.id) ?? []).map((item) => ({ - ...item, - images: item.images?.map((image) => ({ ...image })), - })), - }; + const existing = runtime.sessions.get(session.id); + const displayHistory = existing + ? existing.history + : boundedWebHistory( + kind === "saved" + ? buildContextEntries(history as SessionEntry[]) + : history, + ); + const historyManagedWorktree = existing + ? undefined + : managedWorktreeFromEntries(history); + const record = + existing ?? + ({ + ...session, + kind, + history: displayHistory, + historyReady: history.length > 0, + historyBytes: webHistoryByteLength(displayHistory), + active: kind !== "saved", + agentRunning: session.status === "working", + managedWorktreeScanned, + agentSockets: new Set>(), + clientSockets: new Set>(), + externalRequestTargets: new Map(), + externalPending: new Map(), + queue: (persistedQueues.get(session.id) ?? []).map((item) => ({ + ...item, + images: item.images?.map((image) => ({ ...image })), + })), + }) as SessionRecord; Object.assign(record, session); record.kind = kind; - if (history.length > 0) { + if (history.length > 0 && !existing) { record.history = displayHistory; record.historyReady = true; record.historyBytes = webHistoryByteLength(record.history); @@ -130,8 +137,7 @@ export function createSessionRegistry(options: { session.managedWorktree ?? record.managedWorktree); if (managedWorktreeScanned) record.managedWorktreeScanned = true; - record.active = kind !== "saved" || record.active; - if (kind === "saved") record.active = false; + record.active = kind !== "saved"; return record; } @@ -178,15 +184,20 @@ export function createSessionRegistry(options: { } function sessionSnapshot(): WebSession[] { - options.reconcileMissingSessions(); + void Promise.resolve(options.reconcileMissingSessions()).catch((error) => { + console.warn( + `Failed to reconcile missing sessions before snapshot: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + const scans = scanSavedSessions(sessionsDir, activeSessionFiles()); const merged = new Map(); for (const record of runtime.sessions.values()) { - if (record.catalogReady === false || isMissingInactiveSession(record)) + if (record.catalogReady === false || isMissingInactiveSession(record, scans)) continue; const key = record.file ? normalizePath(record.file) : record.id; merged.set(key, sessionToClientPayload(record)); } - for (const scan of scanSavedSessions(sessionsDir, activeSessionFiles())) { + for (const scan of scans) { const key = scan.session.file ? normalizePath(scan.session.file) : scan.session.id; diff --git a/web/server/sessionReplacement.ts b/web/server/sessionReplacement.ts index 86d2b0d..9948869 100644 --- a/web/server/sessionReplacement.ts +++ b/web/server/sessionReplacement.ts @@ -31,6 +31,7 @@ export function createSessionReplacement(options: { webQueueEvent, cancelWebQueueWork, scheduleQueueSettleFallback, + scheduleWebQueueRetry, } = queue; const { broadcast: broadcastToSessionClients, sendSessionRemoved } = broadcast; @@ -121,6 +122,8 @@ export function createSessionReplacement(options: { }); } catch (error) { previous.queueMutationsQuiesced = false; + if (previous.queueRetryTimer === undefined) + scheduleWebQueueRetry(previous); throw error; } next.queue = mergedQueue; diff --git a/web/server/webServerApp.ts b/web/server/webServerApp.ts index a21ccdb..c8c52e0 100644 --- a/web/server/webServerApp.ts +++ b/web/server/webServerApp.ts @@ -50,7 +50,9 @@ export function createWebServerApp() { const history = createSessionHistory({ catalog }); const recordSync = createRecordSync({ catalog, state }); - let reconcileMissingSessions: () => void = () => undefined; + let reconcileMissingSessions: () => void | Promise = () => { + throw new Error("Session registry called before initialization"); + }; const registry = createSessionRegistry({ state, config, diff --git a/web/server/webSocketGateway.ts b/web/server/webSocketGateway.ts index d1be7f5..9fcf94b 100644 --- a/web/server/webSocketGateway.ts +++ b/web/server/webSocketGateway.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import type { AgentToServerMessage, ClientToServerMessage, @@ -41,17 +40,6 @@ export function createWebSocketGateway(options: { const { handleClientMessage } = clientMessages; const { handleAgentMessage } = agentMessages; - function attachClientSocket( - socket: Bun.ServerWebSocket, - ): void { - socket.data = { kind: "client", id: randomUUID(), authed: false }; - } - - function attachAgentSocket( - socket: Bun.ServerWebSocket, - ): void { - socket.data = { kind: "agent", id: randomUUID(), authed: false }; - } function parseSocketMessage(data: string | Uint8Array): T | undefined { const text = typeof data === "string" ? data : decoder.decode(data); @@ -62,10 +50,8 @@ export function createWebSocketGateway(options: { } } - function handleWebSocketOpen(socket: Bun.ServerWebSocket): void { - if (socket.data.kind === "client") - attachClientSocket(socket as Bun.ServerWebSocket); - else attachAgentSocket(socket as Bun.ServerWebSocket); + function handleWebSocketOpen(): void { + return; } async function handleWebSocketMessage(