Something went wrong. Try again.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886import { PROTOCOL_VERSION } from '@/protocol/lifecycle';import type { AgentInfo, AgentStatus, RuntimeState } from '@/contracts/agent-info';import type { Message } from '@/types/message';import type { SessionEntry } from '@/types/session-entry';import type { SessionRuntimeInfo } from '@/contracts/session-runtime';import type { SessionSummary, SessionSummaryRuntime } from '@/contracts/session-summary';import { WorkspaceCreateResponseSchema, WorkspaceAttachResponseSchema, WorkspaceStatusResponseSchema, WorkspaceRetireResponseSchema, WorkspaceRetireRefusalSchema, type WorkspaceSummary, type WorkspaceDetail, type WorkspaceCreateResponse, type WorkspaceAttachResponse, type WorkspaceRetireResponse, type WorkspaceRetireRefusal,} from '@/protocol/response';import type { BranchDisposition } from '@/protocol/commands';import type { Usage } from '@/types/usage';import { Value } from '@sinclair/typebox/value';import { ToolUsageStatsSchema, type ToolUsageStats, type ToolUsageBucketGranularity,} from '@/contracts/tool-usage-stats';import { TokenUsageStatsSchema, type TokenUsageStats, type TokenUsageBucketGranularity,} from '@/contracts/token-usage-stats';import type { ViewportState } from './viewport-state';import { CorrelatedCommandClient, CorrelatedCommandError } from './correlated-command';import { mergeLiveSnapshot } from './session-history';import { type TranscriptEntry, type LiveEventDelta, type PendingWireRequest, type SystemEntry, liveEventToEntries, finishUnresolvedTools, isSystemEntry, validTranscriptMessage,} from './transcript';import { normalizePending, pendingKey, resolvesPending, type PendingRequest } from './permission';import { type SessionModelState, type SessionTabState, type ReplayPageMeta, createInitialSessionModelState, validSessionSummary, runtimeIsActive, applySessionList, selectSession as selectSessionPure, applyReplayPage, applyOlderPage, markPageLoading, applyTabError, applyActivateResult, markActivating, dequeueSend, clearActivating, appendLocalUserMessage, applySyncToSession, applyLiveToSession, applyRuntimeProjection, applyWorkspaceList, applyWorkspaceLifecycle, validWorkspaceSummary, transcriptAnchorKey, isStale, closeSessionTab, setSplitSession as setSplitSessionPure, swapPanes as swapPanesPure, focusedSessionIds,} from './session-state';import { type RuntimeControlsMap, type RuntimeControlState, type ModelCatalog, type ModeCatalog, type ModelDescriptor, type ReasoningCatalog, CAPABILITY, hasCapability, parseCapabilities, parseRuntimeEpoch, parseModelCatalog, parseModeCatalog, parseReasoningCatalog, parseModelDescriptor, applySyncToControls, applyModelCatalog, applyModeCatalog, applyCatalogError, markCatalogLoading, markModelMutationStart, markModeMutationStart, applyModelMutationSuccess, applyModeMutationSuccess, applyMutationError, applyModelBroadcast, applyModeBroadcast, markReasoningCatalogLoading, applyReasoningCatalog, applyReasoningCatalogError, markReasoningMutationStart, applyReasoningMutationSuccess, applyReasoningMutationError, applyReasoningBroadcast, disableReasoning, invalidateReasoning, clearRuntimeControls,} from './runtime-controls';
export interface WebSocketLike { binaryType: string; readonly readyState: number; onopen: (() => void) | null; onmessage: ((event: { data: string }) => void) | null; onclose: (() => void) | null; onerror: (() => void) | null; send(data: string): void; close(): void; }export type SocketFactory = (url: string, protocols: string[]) => WebSocketLike;export interface ConnectionTimer { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout; }const defaultConnectionTimers: ConnectionTimer = { setTimeout: ((...args: Parameters<typeof globalThis.setTimeout>) => globalThis.setTimeout(...args)) as typeof globalThis.setTimeout, clearTimeout: ((...args: Parameters<typeof globalThis.clearTimeout>) => globalThis.clearTimeout(...args)) as typeof globalThis.clearTimeout,};export interface ConnectionDeps { fetch?: typeof globalThis.fetch; socket?: SocketFactory; timers?: ConnectionTimer; wsUrl?: string; baseUrl?: string; helloTimeoutMs?: number; commandTimeoutMs?: number; now?: () => number; idFactory?: () => string; /** Publication scheduler. Protocol state is still applied synchronously; subscriber * notifications coalesce to the next frame for steady-state updates, while phase * transitions (error/offline/enrollment/connecting/connected) publish immediately. * Omitted (tests/SSR): every publication notifies synchronously. Injectable so * connection tests stay deterministic. */ requestFrame?: (callback: FrameRequestCallback) => number; /** Observability hook: called with the `type` of every parsed daemon message, in * arrival order. Wire-type frequency accounting for the perf recording (§1.2). */ onMessage?: (type: string) => void;}export type ConnectionErrorCode = 'enrollment_required' | 'compatibility_error' | 'bootstrap_error' | 'protocol_error';/** * Per-command timeout for `workspace.create`. The daemon-side flow includes * `git worktree add` — a FULL working-tree checkout that legitimately runs for minutes on * large repositories (bounded server-side at 120s by the worktree-ops mutation bound). The * default 10s command timeout aborted the browser's wait mid-provisioning, surfacing * "workspace.create timed out" for a create that eventually succeeded daemon-side. Must * exceed the daemon's git-mutation bound plus catalog/manifest/runtime-start overhead. */export const WORKSPACE_CREATE_COMMAND_TIMEOUT_MS = 180_000;export type ActivityPhase = 'idle' | 'thinking' | 'streaming' | 'tool' | 'waiting' | 'compacting' | 'retrying' | 'error';export interface ActivitySummary { phase: ActivityPhase; toolName?: string; timestamp: number; }// The typed normalized transcript view model + PendingWireRequest live in `./transcript`// (saga vr7kgx.3) and are re-exported here so the existing import surface is unchanged.export type { TranscriptEntry, TranscriptMessage, ToolEvent, SystemEntry, PendingWireRequest } from './transcript';export interface LifecycleNotice { command: string; sessionId?: string; code: string; message: string; retryableSessionId?: string; }export type OperationPending = Record<string, true>;export interface WebConnectionState { phase: 'enrollment' | 'connecting' | 'connected' | 'offline' | 'error'; agents: AgentInfo[]; activities: Record<string, ActivitySummary>; selectedAgentId: string | null; transcript: TranscriptEntry[]; pending: PendingWireRequest[]; operationPending: OperationPending; lifecycleNotice?: LifecycleNotice; /** Durable session catalog — the primary navigation model (saga vr7kgx.2). */ sessions: SessionSummary[]; /** Durable workspace catalog — the repository-scoped changes-in-flight view (saga * j8dtju.7). Populated from `workspace.list` responses + `workspace_*` lifecycle events; * preserves label/branch/ownership/lifecycle/repository group fields for the UI (j8dtju.11). * Empty for legacy daemons — identical to pre-worktree behavior. */ workspaces: WorkspaceSummary[]; /** Durable selected session id; survives refresh/reconnect. */ selectedSessionId: string | null; /** Split-pane session id — a second simultaneously-visible session (saga vr7kgx.14). * `null` = single-drawer mode. Together with `selectedSessionId` it forms the focused * set allowed to fetch history (replay) independently. */ splitSessionId: string | null; /** Per-session-keyed buffers: transcript, pending, replay page, private route handle. */ sessionTabs: Record<string, SessionTabState>; /** Latest `usage` event awaiting fold-into the next turn-end summary (saga vr7kgx.3). */ pendingUsage?: Usage; /** Per-session runtime-control state: capabilities, mode, model/mode catalogs (saga vr7kgx.6). */ runtimeControls: RuntimeControlsMap; error?: string; errorCode?: ConnectionErrorCode;}export function createInitialConnectionState(): WebConnectionState { const sessionModel = createInitialSessionModelState(); return { phase: 'enrollment', agents: [], activities: {}, selectedAgentId: null, transcript: [], pending: [], operationPending: {}, sessions: sessionModel.sessions, workspaces: sessionModel.workspaces, selectedSessionId: sessionModel.selectedSessionId, splitSessionId: sessionModel.splitSessionId, sessionTabs: sessionModel.tabs, runtimeControls: {}, };}/** Project the session-first slice out of a `WebConnectionState`. Pure. */export function sessionSlice(state: WebConnectionState): SessionModelState { return { sessions: state.sessions, workspaces: state.workspaces, selectedSessionId: state.selectedSessionId, splitSessionId: state.splitSessionId, tabs: state.sessionTabs };}/** Merge a session-first slice back into a `WebConnectionState` (immutably). Pure. */export function withSessionSlice(state: WebConnectionState, slice: SessionModelState): WebConnectionState { return { ...state, sessions: slice.sessions, workspaces: slice.workspaces, selectedSessionId: slice.selectedSessionId, splitSessionId: slice.splitSessionId, sessionTabs: slice.tabs };}const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);const syncMessages = (raw: Record<string, unknown>): Message[] => isRecord(raw.live_window) && Array.isArray(raw.live_window.messages) ? raw.live_window.messages.filter(validTranscriptMessage) : [];const idOf = (v: Record<string, unknown>) => typeof v.agent_id === 'string' ? v.agent_id : undefined;/** Normalize the daemon's `sync.pending_requests` into validated pending requests, dropping * malformed entries (fail closed). Keeps own + delegated-child requests — the daemon is * authoritative about what belongs on this route (saga vr7kgx.4). Pure. */const normalizePendingList = (raw: unknown): PendingRequest[] => { if (!Array.isArray(raw)) return []; const out: PendingRequest[] = []; for (const item of raw) { const req = normalizePending(item); if (req !== null) out.push(req); } return out;};const ACTIVITY_PHASES: ActivityPhase[] = ['idle', 'thinking', 'streaming', 'tool', 'waiting', 'compacting', 'retrying', 'error'];const AGENT_STATUSES = ['idle', 'streaming', 'waiting_for_permission', 'waiting_for_ui', 'compacting', 'error'];const RUNTIME_STATES = ['stopped', 'starting', 'running', 'failed'];const validAgent = (v: unknown): v is AgentInfo => isRecord(v) && typeof v.agent_id === 'string' && v.agent_id.length > 0 && typeof v.cwd === 'string' && AGENT_STATUSES.includes(String(v.status)) && RUNTIME_STATES.includes(String(v.runtime_state)) && typeof v.created_at === 'number' && Number.isFinite(v.created_at) && v.created_at >= 0;const validSessionRuntime = (v: unknown): v is SessionRuntimeInfo => isRecord(v) && typeof v.session_id === 'string' && v.session_id.length > 0 && typeof v.cwd === 'string' && v.cwd.length > 0 && AGENT_STATUSES.includes(String(v.status)) && RUNTIME_STATES.includes(String(v.runtime_state)) && typeof v.created_at === 'number' && Number.isFinite(v.created_at) && v.created_at >= 0;const validRuntimeUsage = (v: unknown): boolean => isRecord(v) && typeof v.input === 'number' && Number.isInteger(v.input) && typeof v.output === 'number' && Number.isInteger(v.output) && Object.keys(v).every((key) => ['input', 'output', 'cache_read', 'cache_write', 'cost', 'context_tokens', 'model', 'provider'].includes(key)) && Object.entries(v).every(([key, value]) => (key === 'model' || key === 'provider') ? typeof value === 'string' : typeof value === 'number' && Number.isFinite(value));const validRuntimeProjection = (v: unknown): v is Record<string, unknown> & SessionSummaryRuntime => isRecord(v) && AGENT_STATUSES.includes(String(v.status)) && RUNTIME_STATES.includes(String(v.runtime_state)) && Object.keys(v).every((key) => ['type', 'session_id', 'status', 'runtime_state', 'timestamp', 'usage'].includes(key)) && (v.usage === undefined || validRuntimeUsage(v.usage));const validActivity = (v: Record<string, unknown>): v is Record<string, unknown> & { phase: ActivityPhase; timestamp: number } => ACTIVITY_PHASES.includes(v.phase as ActivityPhase) && typeof v.timestamp === 'number' && Number.isFinite(v.timestamp) && v.timestamp >= 0;const safeToolName = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 96 && /^[A-Za-z0-9_.:-]+$/.test(value);const isGlobalLifecycle = (item: unknown) => isRecord(item) && ['agent_activity', 'agent_status_changed', 'agent_spawned', 'agent_exited'].includes(String(item.type));/** Token/thinking/tool-argument fragments are the only unbounded high-frequency event. * Completion, tool, permission, lifecycle, and error boundaries publish immediately. */const isStreamingFragment = (item: unknown): item is Record<string, unknown> => isRecord(item) && item.type === 'message_delta';const upsert = (agents: AgentInfo[], info: AgentInfo) => [...agents.filter((a) => a.agent_id !== info.agent_id), info];
/** Applies a protocol event. Registry snapshots are supplied by the correlated response path. */export function reduceConnection(state: WebConnectionState, raw: unknown): WebConnectionState { if (!isRecord(raw) || typeof raw.type !== 'string') return state; const type = raw.type; if (type === 'hello') return raw.protocol_version === PROTOCOL_VERSION ? state : { ...state, phase: 'error', error: 'Incompatible daemon protocol' }; if (type === 'agent_spawned' && validAgent(raw.info)) return { ...state, agents: upsert(state.agents, raw.info) }; if ((type === 'agent_exited' || type === 'agent_status_changed') && idOf(raw)) { const id = idOf(raw)!; const statuses = ['idle', 'streaming', 'waiting_for_permission', 'waiting_for_ui', 'compacting', 'error']; const runtimes = ['stopped', 'starting', 'running', 'failed']; if (type === 'agent_status_changed' && statuses.includes(String(raw.status)) && runtimes.includes(String(raw.runtime_state))) { const status = raw.status as AgentStatus; const runtime_state = raw.runtime_state as RuntimeState; return { ...state, agents: state.agents.map((agent) => agent.agent_id === id ? { ...agent, status, runtime_state } : agent) }; } if (type === 'agent_exited') return { ...state, agents: state.agents.map((agent) => agent.agent_id === id ? { ...agent, runtime_state: 'stopped' } : agent) }; } if (type === 'agent_activity' && idOf(raw)) { if (!validActivity(raw)) return state; const id = idOf(raw)!; const previous = state.activities[id]; if (previous && raw.timestamp < previous.timestamp) return state; const activity = { phase: raw.phase, timestamp: raw.timestamp, ...(safeToolName(raw.tool_name) ? { toolName: raw.tool_name } : {}) }; const status = ['idle', 'streaming', 'waiting_for_permission', 'waiting_for_ui', 'compacting', 'error'].includes(String(raw.status)) ? raw.status as AgentStatus : raw.phase === 'error' ? 'error' : raw.phase === 'compacting' ? 'compacting' : raw.phase === 'streaming' ? 'streaming' : raw.phase === 'waiting' ? 'waiting_for_ui' : undefined; const agents = status === undefined ? state.agents : state.agents.map((agent) => agent.agent_id === id ? { ...agent, status } : agent); return { ...state, agents, activities: { ...state.activities, [id]: activity } }; } if (type === 'sync' && idOf(raw) === state.selectedAgentId) { const window = syncMessages(raw); // Validate at the boundary and keep all server-authoritative pending requests (own + // delegated-child) — the daemon decides what belongs on this route; the web client no // longer re-filters by agent_id (saga vr7kgx.4). Malformed requests fail closed. const pending = normalizePendingList(raw.pending_requests); const { pendingUsage: _drop, ...rest } = state; return { ...rest, phase: 'connected', transcript: window, pending }; } if (idOf(raw) !== undefined && idOf(raw) !== state.selectedAgentId) return state; // Unified transcript/pending/usage application (saga vr7kgx.3): the shared // `liveEventToEntries` normalizer + `mergeLiveSnapshot` keep the legacy agent-first // transcript in lockstep with the session-first path (no drift, no Date.now/random, // full typed metadata, usage folds into the next turn-end). Pure. const delta = liveEventToEntries(raw); if (delta.entries.length === 0 && delta.pendingAdd === undefined && delta.pendingResolve === undefined && delta.pendingUsage === undefined) return state; return applyLiveDeltaToConnection(state, delta);}export function createCommand(type: string, payload: Record<string, unknown> = {}): string { return JSON.stringify({ type, ...payload }); }
/** * Apply a typed live-event delta to the legacy agent-first `WebConnectionState`: merge the * transcript (update-in-place + append), fold/store usage into turn-end, and merge pending * dialogs by `request_id`. Pure. Mirrors `applyLiveToSession` for the session-first path. */function applyLiveDeltaToConnection(state: WebConnectionState, delta: LiveEventDelta): WebConnectionState { let pending = state.pending; if (delta.pendingAdd !== undefined) { // Dedupe by composite identity (owner + request_id) — mirrors applyLiveToSession (saga vr7kgx.4). const keys = new Set(delta.pendingAdd.map((p) => pendingKey(p))); pending = [...pending.filter((p) => !keys.has(pendingKey(p))), ...delta.pendingAdd]; } if (delta.pendingResolve !== undefined && delta.pendingResolve.length > 0) { const resolves = delta.pendingResolve; pending = pending.filter((p) => !resolves.some((r) => resolvesPending(r, p))); } const folded = foldConnectionUsage(delta.entries, state.pendingUsage, delta.pendingUsage); const boundary = folded.entries.some((entry) => isSystemEntry(entry) && (entry.kind === 'turn_start' || entry.kind === 'turn_end')); const existing = boundary ? finishUnresolvedTools(state.transcript) : state.transcript; const transcript = mergeLiveSnapshot(existing, folded.entries, delta.appendMessageIds ?? []); if (folded.consumed) { const { pendingUsage: _drop, ...rest } = state; return { ...rest, transcript, pending }; } return { ...state, transcript, pending, ...(folded.usage !== undefined ? { pendingUsage: folded.usage } : {}) };}
/** * Fold the latest valid `usage` into the next turn-end entry. If a `turn_end` is present in * `entries`, its own `usage` is authoritative (kept); otherwise the stored/latest usage * attaches and `consumed` clears the stored slot. A fresh `usage` event (no turn-end) * becomes the latest stored value. Pure. */function foldConnectionUsage( entries: TranscriptEntry[], stored: Usage | undefined, incoming: Usage | undefined,): { entries: TranscriptEntry[]; usage: Usage | undefined; consumed: boolean } { const hasTurnEnd = entries.some((e) => isSystemEntry(e) && (e as { kind?: string }).kind === 'turn_end'); if (hasTurnEnd) { const usage = stored ?? incoming; const folded = entries.map((e) => { if (isSystemEntry(e) && (e as { kind?: string }).kind === 'turn_end') { const te = e as { kind: 'turn_end'; usage?: Usage } & SystemEntry; if (te.usage !== undefined) return e; return usage !== undefined ? { ...te, usage } : e; } return e; }); return { entries: folded, usage: undefined, consumed: true }; } const hasTurnStart = entries.some((entry) => isSystemEntry(entry) && entry.kind === 'turn_start'); return { entries, usage: incoming ?? (hasTurnStart ? undefined : stored), consumed: hasTurnStart };}
/** * Convert one live event into the session-tab deltas (transcript entries + pending changes * + usage) that `applyLiveToSession` merges. A thin wrapper over the shared * `liveEventToEntries` normalizer (saga vr7kgx.3) so the session-first path and the legacy * `reduceConnection` cannot drift. Used by the session-first live router. Pure. */export function sessionLiveDelta(raw: Record<string, unknown>): LiveEventDelta { return liveEventToEntries(raw);}export class LifecycleCommandError extends CorrelatedCommandError { constructor(command: string, code: string, message: string, data?: unknown) { super(command, code, message, data); this.name = 'LifecycleCommandError'; }}export interface CreateSessionResult { sessionId: string; }export interface TerminateSessionResult { deferred: boolean; }
export class BootstrapError extends Error { constructor(public readonly code: 'enrollment_required' | 'bootstrap_error', message: string, public readonly status?: number) { super(message); }}export async function bootstrapTicket(fetcher: typeof globalThis.fetch = globalThis.fetch.bind(globalThis), baseUrl = ''): Promise<string> { const response = await fetcher(`${baseUrl}/bootstrap`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (response.status === 401 || response.status === 403) throw new BootstrapError('enrollment_required', 'This browser needs enrollment.', response.status); if (!response.ok) throw new BootstrapError('bootstrap_error', `Bootstrap failed (${response.status})`, response.status); const value = await response.json(); // The daemon is the ticket-expiry authority. Comparing its absolute timestamp with the // browser clock can reject a freshly issued ticket when the two hosts have clock skew; // the daemon still validates expiry and single use during the WebSocket upgrade. if (!isRecord(value) || typeof value.ticket !== 'string' || value.ticket.length === 0 || typeof value.expires_at !== 'number' || !Number.isFinite(value.expires_at) || value.expires_at <= 0) throw new BootstrapError('bootstrap_error', 'Invalid bootstrap ticket'); return value.ticket;}
/** * Activation sync-ordering buffer caps (saga vr7kgx.2 race fix). The daemon emits `sync` * (and possibly buffered live events) for a freshly activated route BEFORE the * `session.activate` response resolves; the client buffers these keyed by route id and * flushes them once the response stores the private route handle. The buffer is strictly * bounded so malformed/unsolicited route ids cannot grow it without limit. */const MAX_ACTIVATION_BUFFERED_RECORDS = 256;const MAX_ACTIVATION_BUFFERED_BYTES = 1_048_576; // 1 MiBinterface BufferedActivationRecord { raw: Record<string, unknown>; bytes: number; }
/** Validate a `stats.tool_usage` response payload strictly; returns `undefined` on malformed * data (fail closed — the dashboard shows an error rather than rendering bad numbers). */export function parseToolUsageStats(data: unknown): ToolUsageStats | undefined { if (!Value.Check(ToolUsageStatsSchema, data)) return undefined; return data;}
/** Validate a `stats.token_usage` response payload strictly; returns `undefined` on malformed * data (fail closed — the dashboard shows an error rather than rendering bad numbers). */export function parseTokenUsageStats(data: unknown): TokenUsageStats | undefined { if (!Value.Check(TokenUsageStatsSchema, data)) return undefined; return data;}
export class WebConnection { private generation = 0; private socket?: WebSocketLike; private retry = 1000; private timer: ReturnType<typeof setTimeout> | undefined; private helloTimer: ReturnType<typeof setTimeout> | undefined; private helloReady = false; private registryReady = false; private syncReady = false; private terminal = false; private listAccepted = false; private liveBuffer: unknown[] = []; private listRequestId = ''; private sessionListRequestId = ''; private sessionListResolver: ((sessions: SessionSummary[]) => void) | undefined; private sessionCatalogRefresh: { generation: number; promise: Promise<SessionSummary[]> } | undefined; private sessionCatalogRequestSequence = 0; private sessionCatalogBootstrapped = false; private sessionCatalogDirty = false; private savedViewports: Record<string, ViewportState> = {}; private activationBuffer: Map<string, BufferedActivationRecord[]> = new Map(); private activationRequests: Map<string, Promise<string>> = new Map(); private pendingActivations = 0; private bufferedRecordCount = 0; private bufferedBytes = 0; private id: () => string; private readonly commands: CorrelatedCommandClient; /** Streaming events can arrive much faster than a browser can paint. Keep the protocol * reducer lossless and synchronous, but publish at most once per animation frame so a * burst of token chunks produces one markdown/DOM pass instead of one pass per chunk. */ private updateFrame: number | undefined; private pendingUpdate = false; constructor(private readonly deps: ConnectionDeps = {}, private state = createInitialConnectionState(), private readonly update: (state: WebConnectionState) => void = () => {}) { this.id = deps.idFactory ?? (() => crypto.randomUUID()); const timers = deps.timers ?? defaultConnectionTimers; this.commands = new CorrelatedCommandClient({ idFactory: this.id, send: (payload) => { if (this.socket?.readyState !== 1 || this.state.phase !== 'connected') throw new Error('not connected'); this.socket.send(payload); }, timers, ...(deps.commandTimeoutMs !== undefined ? { timeoutMs: deps.commandTimeoutMs } : {}), }); } get snapshot() { return this.state; } private set(state: WebConnectionState) { // Phase transitions gate whole UI states (error/offline/enrollment/connecting) — // they publish immediately. Everything else coalesces to the next frame when a // scheduler is provided: at burst rates (100+ live messages/s) a synchronous // publication per message starves the frame budget (PROFILING.md §6 P1). Protocol // state itself is always applied synchronously and losslessly — only the subscriber // notification defers. An immediate publication already includes every queued stream // delta, so the pending frame must not publish the same state a second time. const phaseChanged = state.phase !== this.state.phase; this.state = state; const requestFrame = this.deps.requestFrame; if (phaseChanged || requestFrame === undefined) { this.pendingUpdate = false; this.update(state); return; } this.scheduleCoalesced(requestFrame); } private scheduleCoalesced(requestFrame: (callback: FrameRequestCallback) => number): void { this.pendingUpdate = true; if (this.updateFrame !== undefined) return; this.updateFrame = requestFrame(() => { this.updateFrame = undefined; if (!this.pendingUpdate) return; this.pendingUpdate = false; this.update(this.state); }); } /** Apply state immediately, then coalesce the expensive Svelte publication to the next * paint. Non-browser/test callers without a frame scheduler retain synchronous updates. */ private setStreaming(state: WebConnectionState): void { this.state = state; const requestFrame = this.deps.requestFrame ?? (typeof globalThis.requestAnimationFrame === 'function' ? globalThis.requestAnimationFrame.bind(globalThis) : undefined); if (requestFrame === undefined) { this.update(state); return; } this.scheduleCoalesced(requestFrame); } private isConnected(): boolean { return this.socket?.readyState === 1 && this.state.phase === 'connected'; } private setOperationPending(key: string) { this.set({ ...this.state, operationPending: { ...this.state.operationPending, [key]: true } }); } private clearOperationPending(key: string) { const next = { ...this.state.operationPending }; delete next[key]; this.set({ ...this.state, operationPending: next }); } /** Replace the per-session runtime-control map (saga vr7kgx.6). */ private applyRuntimeControls(map: RuntimeControlsMap): void { this.set({ ...this.state, runtimeControls: map }); } dismissLifecycleNotice() { if (this.state.lifecycleNotice === undefined) return; const next = { ...this.state }; delete next.lifecycleNotice; this.set(next); } private notice(command: string, sessionId: string | undefined, error: CorrelatedCommandError) { if (error.code === 'CLOSED' || error.code === 'REPLACED') return; const data = isRecord(error.data) ? error.data : undefined; const lifecycleNotice: LifecycleNotice = { command, code: error.code, message: error.message, ...(sessionId !== undefined ? { sessionId } : {}), ...(typeof data?.session_id === 'string' ? { retryableSessionId: data.session_id } : {}), }; this.set({ ...this.state, lifecycleNotice }); } connect(ticket: string): void { this.commands.replace(); this.socket?.close(); // A replacement fences the old refresh even if its transport settles late. this.sessionCatalogRefresh = undefined; const generation = ++this.generation; this.terminal = false; const timers = this.deps.timers ?? defaultConnectionTimers; const factory = this.deps.socket ?? ((url, protocols) => new WebSocket(url, protocols) as unknown as WebSocketLike); const url = this.deps.wsUrl ?? `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`; this.helloReady = false; this.registryReady = false; this.syncReady = false; this.listAccepted = false; this.liveBuffer = []; this.listRequestId = ''; this.sessionListRequestId = ''; this.sessionListResolver = undefined; this.sessionCatalogBootstrapped = false; this.sessionCatalogDirty = false; this.pendingActivations = 0; this.activationRequests.clear(); this.clearActivationBuffer(); this.clearRuntimeControlBookkeeping(); const next = { ...this.state, phase: 'connecting' as const, runtimeControls: {} }; delete next.error; delete next.errorCode; this.set(next); const socket = factory(url, ['shard.v1', `shard.ticket.${ticket}`]); this.socket = socket; const stale = () => generation !== this.generation; this.helloTimer = timers.setTimeout(() => { if (!stale()) { this.terminal = true; this.set({ ...this.state, phase: 'error', error: 'Daemon hello timed out', errorCode: 'protocol_error' }); socket.close(); } }, this.deps.helloTimeoutMs ?? 5000); socket.onopen = () => { if (stale()) return; this.listRequestId = this.id(); socket.send(createCommand('agent.list', { id: this.listRequestId })); }; socket.onmessage = (event) => { if (stale()) return; try { const raw = JSON.parse(event.data) as unknown; if (isRecord(raw) && typeof raw.type === 'string') this.deps.onMessage?.(raw.type); if (isRecord(raw) && raw.type === 'hello') { if (this.helloTimer) timers.clearTimeout(this.helloTimer); const next = reduceConnection(this.state, raw); this.set(next); if (next.phase === 'error') { this.terminal = true; this.set({ ...next, errorCode: 'compatibility_error' }); socket.close(); return; } this.helloReady = true; return; } if (isRecord(raw) && raw.type === 'response' && this.commands.handle(raw)) return; if (isRecord(raw) && this.helloReady && !this.listAccepted && raw.type === 'response' && raw.id === this.listRequestId && raw.command === 'agent.list' && raw.success === true && isRecord(raw.data) && Array.isArray(raw.data.agents)) { this.listAccepted = true; const agents = raw.data.agents.filter(validAgent); this.registryReady = true; const preserved = this.state.selectedAgentId && agents.some((a) => a.agent_id === this.state.selectedAgentId) ? this.state.selectedAgentId : agents[0]?.agent_id ?? null; const emptyRegistry = agents.length === 0 && preserved === null; this.syncReady = emptyRegistry; this.retry = 1000; // The durable session catalog is the primary web navigation model. A legacy // agent attach may take time (or fail for a stale manifest), so it must not keep // the whole frontend in "Connecting" after the socket handshake and registry // snapshot succeeded. Keep buffering legacy agent events until its authoritative // sync arrives, but allow session.* commands and catalog loading immediately. this.set({ ...this.state, phase: 'connected', agents, selectedAgentId: preserved }); if (preserved) socket.send(createCommand('agent.attach', { id: this.id(), agent_id: preserved, role: 'interactive' })); const buffered = this.liveBuffer; this.liveBuffer = []; for (const item of buffered) { if (isGlobalLifecycle(item)) continue; if (isRecord(item) && item.type === 'sync') { const next = reduceConnection(this.state, item); this.syncReady = next.phase === 'connected'; this.retry = 1000; this.set(next); this.routeSessionSync(item); } else this.liveBuffer.push(item); } this.onRegistryReady(); return; } if (isRecord(raw) && this.helloReady && raw.type === 'response' && raw.id === this.sessionListRequestId && raw.command === 'session.list' && raw.success === true && isRecord(raw.data) && Array.isArray(raw.data.sessions)) { const sessions = raw.data.sessions.filter(validSessionSummary); this.applySessionModel(applySessionList(sessionSlice(this.state), sessions)); const resolver = this.sessionListResolver; this.sessionListResolver = undefined; resolver?.(sessions); return; } if (isRecord(raw) && raw.type === 'sync' && this.registryReady) { const next = reduceConnection(this.state, raw); this.syncReady = next.phase === 'connected'; this.retry = 1000; this.set(next); this.routeOrBufferSync(raw); const buffered = this.liveBuffer; this.liveBuffer = []; for (const item of buffered) { this.routeSessionLive(item, false); const reducedBuffered = reduceConnection(this.state, item); if (isStreamingFragment(item)) this.setStreaming(reducedBuffered); else this.set(reducedBuffered); if (isRecord(item)) { this.applySessionRuntimeEvent(item); this.applyWorkspaceLifecycleEvent(item); if (item.type === 'subagent_run_changed') void this.refreshSessionCatalog(true).catch(() => { /* reconnect retries */ }); } } this.maybeBootstrapSessions(); return; } if (!this.registryReady || !this.syncReady) { if (isRecord(raw) && raw.type === 'session_runtime_changed') this.applySessionRuntimeEvent(raw); else this.liveBuffer.push(raw); return; } if (isRecord(raw) && this.applyOwnerlessResolve(raw)) { const reducedOwnerless = reduceConnection(this.state, raw); this.set(reducedOwnerless); return; } this.routeOrBufferLive(raw, false); if (isRecord(raw) && (raw.type === 'model_changed' || raw.type === 'mode_changed' || raw.type === 'reasoning_level_changed')) this.routeRuntimeControlBroadcast(raw); const reduced = reduceConnection(this.state, raw); if (isStreamingFragment(raw)) this.setStreaming(reduced); else this.set(reduced); if (isRecord(raw)) { this.applySessionRuntimeEvent(raw); this.applyWorkspaceLifecycleEvent(raw); if (raw.type === 'subagent_run_changed') void this.refreshSessionCatalog(true).catch(() => { /* next run event/reconnect retries */ }); } } catch { this.set({ ...this.state, phase: 'error', error: 'Invalid daemon message' }); } }; socket.onclose = () => { if (stale() || this.terminal) return; if (this.helloTimer) timers.clearTimeout(this.helloTimer); this.commands.disconnect(); this.set({ ...this.state, phase: 'offline' }); this.scheduleReconnect(generation, this.retry); }; socket.onerror = () => { if (!stale()) socket.close(); }; }
/** * Keep attempting credentialed bootstrap for as long as this connection generation is * current. Daemon restarts can take arbitrarily longer than the old three-attempt window; * transient network/bootstrap failures therefore back off to 30s but never exhaust the * retry loop. Authentication failures are different: the browser must enroll again. */ private scheduleReconnect(generation: number, delay: number): void { const timers = this.deps.timers ?? defaultConnectionTimers; if (this.timer) timers.clearTimeout(this.timer); this.timer = timers.setTimeout(() => { this.timer = undefined; if (generation !== this.generation) return; void this.attemptReconnect(generation); }, delay); }
private async attemptReconnect(generation: number): Promise<void> { try { const freshTicket = await bootstrapTicket(this.deps.fetch ?? globalThis.fetch.bind(globalThis), this.deps.baseUrl); if (generation === this.generation) this.connect(freshTicket); } catch (error: unknown) { if (generation !== this.generation) return; if (error instanceof BootstrapError && error.code === 'enrollment_required') { this.set({ ...this.state, phase: 'enrollment', error: error.message, errorCode: error.code }); return; } this.set({ ...this.state, phase: 'offline', error: error instanceof Error ? error.message : 'Reconnect bootstrap failed', errorCode: 'bootstrap_error', }); this.retry = Math.min(30_000, this.retry * 2); this.scheduleReconnect(generation, this.retry); } } select(agentId: string): void { if (agentId === this.state.selectedAgentId || !this.state.agents.some((a) => a.agent_id === agentId)) return; const previous = this.state.selectedAgentId; const { pendingUsage: _drop, ...rest } = this.state; this.set({ ...rest, selectedAgentId: agentId, transcript: [], pending: [] }); if (this.socket?.readyState === 1) { if (previous) this.socket.send(createCommand('agent.detach', { id: crypto.randomUUID(), agent_id: previous })); this.socket.send(createCommand('agent.attach', { id: crypto.randomUUID(), agent_id: agentId, role: 'interactive' })); } } sendPrompt(message: string): boolean { if (!message.trim() || this.socket?.readyState !== 1 || !this.state.selectedAgentId || this.state.phase !== 'connected') return false; this.socket.send(createCommand('prompt', { id: this.id(), agent_id: this.state.selectedAgentId, message })); return true; } respond(requestId: string, response: Record<string, unknown>, owner?: string): boolean { // Route to the request's actual owner when supplied; fall back to the selected agent // (legacy agent-first path). A delegated-child prompt's owner differs from the selected // session runtime handle (saga vr7kgx.4). const agentId = owner ?? this.state.selectedAgentId; if (this.socket?.readyState !== 1 || !agentId || this.state.phase !== 'connected' || (response.type !== 'permission_response' && response.type !== 'ui_response')) return false; this.socket.send(createCommand(String(response.type), { ...response, agent_id: agentId, request_id: requestId })); return true; }
/** Generic fire-and-forget command send — request/response callers should use requestCommand. */ send(command: Record<string, unknown>): boolean { if (!this.isConnected()) return false; this.socket!.send(JSON.stringify({ id: this.id(), ...command })); return true; } /** * Correlated request/response command. `timeoutMs` (optional) overrides the connection's * per-command default for THIS request only — long-running lifecycle commands * (`workspace.create`) pass a dedicated bound instead of the 10s default. */ requestCommand<T>(type: string, payload: Record<string, unknown>, parse: (data: unknown) => T, timeoutMs?: number): Promise<T> { if (!this.isConnected()) return Promise.reject(new LifecycleCommandError(type, 'CLOSED', 'Not connected')); return this.commands.request(type, payload, parse, timeoutMs !== undefined ? { timeoutMs } : undefined).catch((error: CorrelatedCommandError) => { throw new LifecycleCommandError(error.command, error.code, error.message, error.data); }); }
/** * `stats.tool_usage` — fetch the tool-usage aggregate for the dashboard (PROTOCOL §10 * stats surface). Returns a validated {@link ToolUsageStats}; rejects with a * `LifecycleCommandError` on transport/protocol failure. The dashboard component owns the * `ToolUsageState` slice locally and calls this on mount + refresh + filter change. */ requestToolUsageStats(payload: { from?: number; to?: number; bucket?: ToolUsageBucketGranularity; tool?: string; includeSubagents?: boolean; }): Promise<ToolUsageStats> { const commandPayload: Record<string, unknown> = {}; if (payload.from !== undefined) commandPayload.from = payload.from; if (payload.to !== undefined) commandPayload.to = payload.to; if (payload.bucket !== undefined) commandPayload.bucket = payload.bucket; if (payload.tool !== undefined && payload.tool.length > 0) commandPayload.tool = payload.tool; if (payload.includeSubagents !== undefined) commandPayload.include_subagents = payload.includeSubagents; return this.requestCommand<ToolUsageStats>('stats.tool_usage', commandPayload, (data) => { // Successful stats responses wrap the aggregate as `{ stats }`. const body = isRecord(data) ? data.stats : undefined; const stats = parseToolUsageStats(body); if (stats === undefined) throw new Error('Invalid stats.tool_usage response'); return stats; }); }
/** * `stats.token_usage` — fetch the token/cost aggregate (by model & provider) for the * dashboard (PROTOCOL §10 stats surface; sibling to `stats.tool_usage`). Returns a * validated {@link TokenUsageStats}; rejects with a `LifecycleCommandError` on * transport/protocol failure. The dashboard component calls this on open + granularity * / range / refresh change. */ requestTokenUsageStats(payload: { from?: number; to?: number; bucket?: TokenUsageBucketGranularity; }): Promise<TokenUsageStats> { const commandPayload: Record<string, unknown> = {}; if (payload.from !== undefined) commandPayload.from = payload.from; if (payload.to !== undefined) commandPayload.to = payload.to; if (payload.bucket !== undefined) commandPayload.bucket = payload.bucket; return this.requestCommand<TokenUsageStats>('stats.token_usage', commandPayload, (data) => { // The orchestrator wraps the aggregate as `{ stats }` (mirrors `stats.tool_usage`). const body = isRecord(data) ? data.stats : undefined; const stats = parseTokenUsageStats(body); if (stats === undefined) throw new Error('Invalid stats.token_usage response'); return stats; }); }
/** `agent.detach` remains a compatibility helper for the legacy rack until `.2` replaces it. */ detach(agentId: string): boolean { return this.send({ type: 'agent.detach', agent_id: agentId }); } /** `steer` — inject into the live turn of the selected legacy agent (mid-turn). */ steer(message: string): boolean { if (!this.state.selectedAgentId) return false; return this.send({ type: 'steer', agent_id: this.state.selectedAgentId, message }); }
/** Inject into a live session turn and echo the user input after daemon acceptance. */ steerSession(sessionId: string, message: string): Promise<boolean> { const trimmed = message.trim(); const tab = this.state.sessionTabs[sessionId]; if (trimmed.length === 0 || tab?.attached !== true || tab.routeAgentId === undefined || tab.attachedGeneration !== this.generation || !this.isConnected()) return Promise.resolve(false); const generation = this.generation; return this.requestCommand<void>('steer', { agent_id: tab.routeAgentId, message: trimmed }, () => undefined) .then(() => { const current = this.state.sessionTabs[sessionId]; if (generation !== this.generation || current === undefined || current.routeAgentId !== tab.routeAgentId || current.attachedGeneration !== generation) return false; this.applySessionModel(appendLocalUserMessage(this.sessionModel(), sessionId, `local-user:${this.id()}`, trimmed)); return true; }, (error: LifecycleCommandError) => { if (generation === this.generation && this.state.sessionTabs[sessionId] !== undefined) { this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); } throw error; }); } /** `abort` — cancel the selected agent's in-flight turn. */ abort(): boolean { if (!this.state.selectedAgentId) return false; return this.send({ type: 'abort', agent_id: this.state.selectedAgentId }); } /** `session_op` mutations retained by the protocol. */ sessionOp(op: 'list' | 'compact' | 'fork', args?: Record<string, unknown>): boolean { if (!this.state.selectedAgentId) return false; return this.send({ type: 'session_op', agent_id: this.state.selectedAgentId, op, ...(args ? { args } : {}) }); }
/** * Fork a durable session at one transcript message, adopt the materialized JSONL through * the session-first activation path, and select the resulting durable session. */ async forkSessionAt(sourceSessionId: string, entryId: string): Promise<string> { const source = this.state.sessions.find((session) => session.session_id === sourceSessionId); if (source === undefined || entryId.length === 0) { throw new LifecycleCommandError('session_op', 'INVALID_REQUEST', 'source session and entry id are required'); } const routeAgentId = await this.activateSession(sourceSessionId, source.cwd); const result = await this.requestCommand<{ sessionId: string }>('session_op', { agent_id: routeAgentId, op: 'fork', args: { from_entry_id: entryId }, }, (data) => { if (!isRecord(data) || typeof data.session_id !== 'string' || data.session_id.length === 0) { throw new Error('Invalid session fork response'); } return { sessionId: data.session_id }; });
// Force a post-mutation catalog generation: an older in-flight refresh may have been // issued before the fork file existed and must neither coalesce nor overwrite this one. const sessions = await this.refreshSessionCatalog(true); const fork = sessions.find((session) => session.session_id === result.sessionId); if (fork === undefined) throw new LifecycleCommandError('session.list', 'NOT_FOUND', 'Forked session was not discovered'); this.applySessionModel(selectSessionPure(this.sessionModel(), fork.session_id)); await this.requestSessionReplay(fork.session_id, fork.cwd, { kind: 'newest' }); await this.activateSession(fork.session_id, fork.cwd); return fork.session_id; }
createSession(opts: { cwd: string; title?: string; model?: string; provider?: string; mode?: string }): Promise<CreateSessionResult> { const cwd = opts.cwd.trim(); if (cwd.length === 0) return Promise.reject(new LifecycleCommandError('session.create', 'INVALID_REQUEST', 'cwd is required')); const payload: Record<string, unknown> = { cwd }; for (const [key, value] of [['title', opts.title], ['model', opts.model], ['provider', opts.provider], ['mode', opts.mode]] as const) { if (typeof value === 'string' && value.trim().length > 0) payload[key] = value.trim(); } const key = 'session.create'; if (this.state.operationPending[key]) return Promise.reject(new LifecycleCommandError(key, 'CONFLICT', 'Create already pending')); this.setOperationPending(key); return this.requestCommand(key, payload, (data) => { if (!isRecord(data) || typeof data.session_id !== 'string' || data.session_id.length === 0 || typeof data.agent_id !== 'string' || data.agent_id.length === 0) throw new Error('Invalid session.create response'); return { sessionId: data.session_id }; }).then((result) => { this.clearOperationPending(key); this.dismissLifecycleNotice(); return result; }, (error: LifecycleCommandError) => { this.clearOperationPending(key); this.notice(key, undefined, error); throw error; }); }
inspectSessionRuntime(sessionId: string, cwd: string): Promise<SessionRuntimeInfo> { return this.sessionRuntimeCommand('session.status', sessionId, cwd, (data) => { if (!isRecord(data) || !validSessionRuntime(data.runtime) || data.runtime.session_id !== sessionId) throw new Error('Invalid session.status response'); return data.runtime; }); }
stopSessionRuntime(sessionId: string, cwd: string): Promise<TerminateSessionResult> { return this.sessionRuntimeCommand('session.terminate', sessionId, cwd, (data) => { if (!isRecord(data) || typeof data.deferred !== 'boolean') throw new Error('Invalid session.terminate response'); return { deferred: data.deferred }; }); }
killSessionRuntime(sessionId: string, cwd: string, force = false): Promise<void> { return this.sessionRuntimeCommand('session.kill', sessionId, cwd, () => undefined, { force }); }
private sessionRuntimeCommand<T>(command: string, sessionIdValue: string, cwdValue: string, parse: (data: unknown) => T, extra: Record<string, unknown> = {}): Promise<T> { const sessionId = sessionIdValue.trim(); const cwd = cwdValue.trim(); if (sessionId.length === 0 || cwd.length === 0) return Promise.reject(new LifecycleCommandError(command, 'INVALID_REQUEST', 'session_id and cwd are required')); const key = `${command}:${sessionId}`; if (this.state.operationPending[key]) return Promise.reject(new LifecycleCommandError(command, 'CONFLICT', `${command} already pending`)); this.setOperationPending(key); return this.requestCommand(command, { session_id: sessionId, cwd, ...extra }, parse) .then((result) => { this.clearOperationPending(key); this.dismissLifecycleNotice(); return result; }, (error: LifecycleCommandError) => { this.clearOperationPending(key); this.notice(command, sessionId, error); throw error; }); }
// ─── session-first state model (saga vr7kgx.2) ────────────────────────────────
/** Merge a session-first slice back into the connection state and emit. */ private applySessionModel(slice: SessionModelState): void { this.set(withSessionSlice(this.state, slice)); } /** Streaming-only variant: state remains synchronous for routing/snapshots while visual * subscribers are frame-coalesced. */ private applyStreamingSessionModel(slice: SessionModelState): void { this.setStreaming(withSessionSlice(this.state, slice)); }
/** Supply a persisted viewport before reconnect restoration begins. */ setViewportState(sessionId: string, viewport: ViewportState | null): void { if (viewport === null) delete this.savedViewports[sessionId]; else this.savedViewports[sessionId] = viewport; }
/** Apply only the daemon's sanitized, durable-session runtime projection. */ private applySessionRuntimeEvent(raw: Record<string, unknown>): void { if (raw.type !== 'session_runtime_changed' || typeof raw.session_id !== 'string' || raw.session_id.length === 0 || typeof raw.timestamp !== 'number' || !Number.isFinite(raw.timestamp) || raw.timestamp < 0 || !validRuntimeProjection(raw)) return; const sessionId = raw.session_id; if (!this.state.sessions.some((session) => session.session_id === sessionId)) return; const runtime: SessionSummaryRuntime = { status: raw.status as SessionSummaryRuntime['status'], runtime_state: raw.runtime_state as SessionSummaryRuntime['runtime_state'], ...(raw.usage !== undefined ? { usage: raw.usage as NonNullable<SessionSummaryRuntime['usage']> } : {}), }; this.applySessionModel(applyRuntimeProjection(this.sessionModel(), sessionId, runtime)); }
/** * Apply a `workspace_*` lifecycle event (saga j8dtju.7). These events carry an authoritative * transport-safe {@link WorkspaceSummary} projection for one workspace — created/attached * carry an active summary; retired carries a `WorkspaceDetail` whose `.summary` is a retired * summary; conflict carries a conflict summary. This upserts the summary into the * `workspaces` catalog (preserving label/branch/ownership/lifecycle/repository group) and * coalesces a SESSION-catalog refresh so a newly bound/retired workspace's session tags stay * coherent. Malformed payloads are ignored (no mutation). Only new daemons emit these; * legacy daemons never do, so behavior is unchanged for them. */ private applyWorkspaceLifecycleEvent(raw: Record<string, unknown>): void { const type = raw.type; if (type !== 'workspace_created' && type !== 'workspace_attached' && type !== 'workspace_retired' && type !== 'workspace_conflict') return; const workspace = isRecord(raw.workspace) ? raw.workspace : undefined; if (workspace === undefined) return; // `workspace_retired` wraps a WorkspaceDetail: the summary lives at `workspace.summary`. // The other three carry a WorkspaceSummary directly at `workspace`. A retired event whose // detail has no valid `.summary` record is malformed → ignored (no mutation). const summary = type === 'workspace_retired' ? (isRecord(workspace.summary) ? workspace.summary : undefined) : workspace; if (summary === undefined) return; this.applySessionModel(applyWorkspaceLifecycle(this.sessionModel(), summary as WorkspaceSummary)); // A workspace binding change may alter which sessions are tagged with which workspace_id // — coalesce a session-catalog refresh so the tags stay coherent. The refresh is NOT // forced: a burst of lifecycle events coalesces into one in-flight `session.list` (a forced // refresh would tear down the in-flight request and re-issue on every event → unbounded // duplicate requests). A trailing refresh is issued when a change arrives while a refresh // is already in flight, so the new tags are eventually picked up (bounded: at most one // trailing refresh per coalesced burst). The optimistic `workspaces` upsert above already // reflects the change immediately. this.scheduleSessionCatalogRefresh(); }
/** * Coalesce a session-catalog refresh triggered by a workspace lifecycle event (saga * j8dtju.7). If no refresh is in flight for this generation, issue one (it is issued AFTER * the change, so its response reflects it). If one IS in flight, mark the catalog dirty and * chain a trailing check on the in-flight promise so a SINGLE trailing refresh fires when it * completes — picking up the change that arrived mid-flight. The trailing check is * check-and-clear, so a burst that coalesced into one in-flight refresh fires at most ONE * trailing `session.list` (never one-per-event → unbounded; never a forced tear-down of an * in-flight request → duplicate). Generation-fenced; the dirty flag resets on * (re)connect/close. */ private scheduleSessionCatalogRefresh(): void { const generation = this.generation; const current = this.sessionCatalogRefresh; if (current !== undefined && current.generation === generation) { this.sessionCatalogDirty = true; // Chain a trailing check on the in-flight refresh (whoever started it — bootstrap, a // public refresh, or a prior lifecycle event). Check-and-clear dirty ⇒ one trailing. void current.promise.then(() => this.maybeTrailingSessionCatalogRefresh(generation), () => { /* in-flight failed — next event/reconnect retries */ }); return; } this.sessionCatalogDirty = false; void this.refreshSessionCatalog().catch(() => { /* next event/reconnect retries */ }); }
/** Fire one trailing session-catalog refresh if a workspace change is still pending. */ private maybeTrailingSessionCatalogRefresh(generation: number): void { if (generation !== this.generation || !this.sessionCatalogDirty || !this.isConnected()) return; this.sessionCatalogDirty = false; void this.refreshSessionCatalog().catch(() => { /* next event/reconnect retries */ }); } /** Current session-first slice. */ private sessionModel(): SessionModelState { return sessionSlice(this.state); }
/** Snapshot of the focused session ids (primary + split) for at-issue fencing (saga vr7kgx.14). * A replay/restore response is applied only if its session was focused when the request * was issued. Pure read of current state. */ private focusedNow(): string[] { return focusedSessionIds(this.sessionModel()); }
/** Called once the agent registry is ready + the first sync landed (or empty registry). */ private onRegistryReady(): void { /* hook for reconnect restore — see `maybeBootstrapSessions` */ this.maybeBootstrapSessions(); }
/** * On (re)connect, once the connection is ready, refresh the durable session catalog and * replay every FOCUSED session — the primary and the split (saga vr7kgx.14) — activating a * runtime ONLY if the catalog reports it still active. Stopped sessions stay read-only; * inactive tabs never wake a runtime merely because they are open. Runs at most once per * connection generation. No-op when nothing is durably focused (the UI loads the catalog * itself via `refreshSessionCatalog`). */ private maybeBootstrapSessions(): void { if (this.sessionCatalogBootstrapped || !this.isConnected()) return; this.sessionCatalogBootstrapped = true; const focusedAtIssue = this.focusedNow(); if (focusedAtIssue.length === 0) return; const generation = this.generation; // The in-memory catalog is only a navigation cache. Always refresh it before // reconnect restoration so a runtime stopped/started elsewhere is authoritative. const refresh = this.refreshSessionCatalog(); void refresh.then((sessions) => { if (generation !== this.generation) return; // Restore each focused session whose id is still focused (a pane un-pinned mid-refresh // is skipped — its late replay is fenced by `canContinueSessionRestore`). for (const id of focusedAtIssue) if (this.focusedNow().includes(id)) this.restoreFocusedSession(generation, id, sessions); }, () => { // Keep the cached history visible, but do not use it to wake a runtime. Allow // the next explicit/bootstrap attempt to retry the catalog request. if (generation === this.generation) this.sessionCatalogBootstrapped = false; }); }
/** After (re)connect catalog refresh, replay one focused session without waking runtimes. */ private restoreFocusedSession(generation: number, sessionId: string, refreshedSessions: SessionSummary[] = this.state.sessions): void { if (generation !== this.generation || !this.isConnected()) return; if (!this.focusedNow().includes(sessionId)) return; const summary = refreshedSessions.find((s) => s.session_id === sessionId); if (summary === undefined) return; void this.requestSessionReplay(sessionId, summary.cwd, { kind: 'newest' }).then(() => { if (!this.canContinueSessionRestore(generation, sessionId)) return; return this.pageToSavedAnchor(sessionId, summary.cwd); }).then(() => { // Replay/page continuation must re-check the fence: focus may have changed // while either request was in flight. if (!this.canContinueSessionRestore(generation, sessionId)) return; // Keep the activation decision pinned to the catalog snapshot accepted for this restore. const refreshed = refreshedSessions.find((s) => s.session_id === sessionId); if (refreshed !== undefined && runtimeIsActive(refreshed.runtime)) void this.activateSession(sessionId, refreshed.cwd); }, () => { /* replay failure is retryable and must not hide the session */ }); }
private canContinueSessionRestore(generation: number, sessionId: string): boolean { return generation === this.generation && this.focusedNow().includes(sessionId) && this.state.sessionTabs[sessionId] !== undefined && this.isConnected(); }
/** Route a `sync` event to the session tab whose private route handle matches. */ private routeSessionSync(raw: Record<string, unknown>): void { const agentId = idOf(raw); if (agentId === undefined) return; const sessionId = this.sessionIdForRoute(agentId); if (sessionId === undefined) return; const window = syncMessages(raw); // Validate at the boundary; keep all server-authoritative pending (own + delegated-child). const pending = normalizePendingList(raw.pending_requests); this.applySessionModel(applySyncToSession(this.sessionModel(), sessionId, window, pending, agentId, this.generation)); this.applySyncToRuntimeControls(sessionId, raw); }
/** Route a live event to the session tab whose private route handle matches (if any). */ /** * Apply an ownerless (`permission_resolved`/`ui_resolved` with no `agent_id`) resolution * across ALL session tabs — a legacy broadcast retire. Returns true if handled. Used only * when the resolve carries no owner (the daemon normally includes `agent_id`); the * owner-scoped path routes through `routeSessionLive` (saga vr7kgx.4). */ private applyOwnerlessResolve(raw: Record<string, unknown>): boolean { if ((raw.type !== 'permission_resolved' && raw.type !== 'ui_resolved') || typeof raw.request_id !== 'string') return false; if (idOf(raw) !== undefined) return false; // owner-scoped — handled by routeSessionLive const resolve: { request_id: string; agent_id?: string } = { request_id: raw.request_id }; let model = this.sessionModel(); let changed = false; for (const id in model.tabs) { const tab = model.tabs[id]; if (tab === undefined || tab.pending.length === 0) continue; if (!tab.pending.some((p) => resolvesPending(resolve, p))) continue; model = applyLiveToSession(model, id, [], { resolveIds: [resolve] }); changed = true; } if (changed) this.applySessionModel(model); return true; }
private routeSessionLive(raw: unknown, publish = true): void { if (!isRecord(raw) || typeof raw.type !== 'string') return; if (raw.type === 'model_changed' || raw.type === 'mode_changed') return; // handled by routeRuntimeControlBroadcast const agentId = idOf(raw); if (agentId === undefined) return; // Permission/ui requests (and their resolutions) are broadcast and may originate from a // delegated child whose own tab is not activated; route those to the parent session tab // so the prompt AND its retirement land on the same tab (saga vr7kgx.4). Transcript // deltas route to the direct tab only. const isPrompt = raw.type === 'permission_request' || raw.type === 'ui_request' || raw.type === 'permission_resolved' || raw.type === 'ui_resolved'; const sessionId = isPrompt ? this.sessionIdForRouteOrParent(agentId) : this.sessionIdForRoute(agentId); if (sessionId === undefined) return; const delta = sessionLiveDelta(raw); if (delta.entries.length === 0 && delta.pendingAdd === undefined && delta.pendingResolve === undefined && delta.pendingUsage === undefined) return; const pendingDelta = delta.pendingAdd !== undefined || delta.pendingResolve !== undefined ? { ...(delta.pendingAdd !== undefined ? { add: delta.pendingAdd } : {}), ...(delta.pendingResolve !== undefined ? { resolveIds: delta.pendingResolve } : {}) } : undefined; const model = applyLiveToSession(this.sessionModel(), sessionId, delta.entries, pendingDelta, delta.appendMessageIds, delta.pendingUsage); if (!publish) { // The caller will immediately apply/publish the legacy projection of the same wire // event. Merge the session slice now without notifying twice. this.state = withSessionSlice(this.state, model); } else if (isStreamingFragment(raw)) this.applyStreamingSessionModel(model); else this.applySessionModel(model); }
/** Find the session id whose tab holds the given private routing handle. */ private sessionIdForRoute(agentId: string): string | undefined { for (const id in this.state.sessionTabs) { const tab = this.state.sessionTabs[id]; if (tab?.routeAgentId === agentId && tab.attachedGeneration === this.generation) return id; } return undefined; }
/** * Resolve the session tab for an agent id, falling back to the parent session when the * agent is a delegated child (saga vr7kgx.4). A delegated child's permission/ui prompt * arrives via broadcast with the child's `agent_id`; if the child is not activated as its * own tab, route it to the parent session's tab (the child's `parent_agent_id` resolves to * the parent route). Used only for permission/ui requests — transcript deltas stay routed * to the direct tab. Returns `undefined` when no chain resolves (unknown route → drop). */ private sessionIdForRouteOrParent(agentId: string): string | undefined { const direct = this.sessionIdForRoute(agentId); if (direct !== undefined) return direct; const info = this.state.agents.find((a) => a.agent_id === agentId); const parentId = info?.parent_agent_id; if (parentId !== undefined) return this.sessionIdForRoute(parentId); return undefined; }
// ─── runtime controls: model + mode discovery/switch (saga vr7kgx.6) ────────────── // The selected session's runtime exposes secret-free capabilities (daemon-authored // `sync.capabilities`), an authoritative `mode`, and discovered model/mode catalogs. The // durable `session_id` is the public key; the private `agent_id` route handle is resolved // internally from the session tab and NEVER returned to UI code. Every response is fenced // by { connection generation, sessionId, routeAgentId, request sequence } so a stale/ // replaced/wrong-tab response cannot mutate another runtime. Stopped/unattached sessions // are read-only: these methods reject unless the tab is currently attached, so inspection // never wakes a runtime.
/** In-flight catalog refreshes per session (coalesce duplicates by session/epoch). */ private runtimeModelRefresh: Map<string, { generation: number; seq: number; promise: Promise<ModelCatalog> }> = new Map(); private runtimeModeRefresh: Map<string, { generation: number; seq: number; promise: Promise<ModeCatalog> }> = new Map(); private runtimeReasoningRefresh: Map<string, { generation: number; seq: number; promise: Promise<ReasoningCatalog> }> = new Map();
/** Drop all in-flight runtime-control bookkeeping (reconnect/close). */ private clearRuntimeControlBookkeeping(): void { this.runtimeModelRefresh.clear(); this.runtimeModeRefresh.clear(); this.runtimeReasoningRefresh.clear(); }
/** Resolve the private route handle for an ATTACHED session tab in the current generation. */ private attachedRouteFor(sessionId: string): string | undefined { const tab = this.state.sessionTabs[sessionId]; if (tab === undefined || !tab.attached || tab.routeAgentId === undefined || tab.attachedGeneration !== this.generation) return undefined; return tab.routeAgentId; }
/** The control state for a session (or `undefined` when none has been authored yet). */ runtimeControlsFor(sessionId: string): RuntimeControlState | undefined { return this.state.runtimeControls[sessionId]; }
/** * Apply an authoritative `sync` to a session's runtime-control state: replace capabilities * + mode (clearing stale catalogs when the route epoch changed), then refresh each * advertised catalog once (coalesced per session/epoch). Pure-state reducers do the * projection; this method only issues the correlated catalog requests. Never wakes a * stopped runtime — `sync` only arrives for an already-attached route. */ private applySyncToRuntimeControls(sessionId: string, raw: Record<string, unknown>): void { const capabilities = parseCapabilities(raw.capabilities); const mode = typeof raw.mode === 'string' ? raw.mode : ''; const runtimeEpoch = parseRuntimeEpoch(raw.runtime_epoch); if (capabilities === undefined) return; // malformed capabilities — keep prior state const routeEpoch = this.generation; // A same-route runtime replacement (new runtime_epoch) must clear old catalogs/pending so // the previous runtime's choices/state cannot leak into the new one. The pure reducer // performs the reset; in-flight refreshes are fenced by the issue-time runtime epoch. const prev = this.state.runtimeControls[sessionId]; const runtimeReplaced = runtimeEpoch !== undefined && prev?.runtimeEpoch !== undefined && prev.runtimeEpoch !== runtimeEpoch; if (runtimeReplaced) { this.runtimeModelRefresh.delete(sessionId); this.runtimeModeRefresh.delete(sessionId); this.runtimeReasoningRefresh.delete(sessionId); } const map = applySyncToControls(this.state.runtimeControls, sessionId, { capabilities, mode, ...(runtimeEpoch !== undefined ? { runtimeEpoch } : {}) }, routeEpoch); this.applyRuntimeControls(map); const control = this.state.runtimeControls[sessionId]; if (control === undefined) return; if (hasCapability(control, CAPABILITY.modeCatalog)) void this.refreshRuntimeModes(sessionId).catch(() => { /* error retained on the control state */ }); if (hasCapability(control, CAPABILITY.modelCatalog)) void this.refreshRuntimeModels(sessionId).catch(() => { /* error retained on the control state */ }); if (hasCapability(control, CAPABILITY.reasoningCatalog)) void this.refreshRuntimeReasoning(sessionId).catch(() => { /* error retained on the control state */ }); }
/** Route a `model_changed`/`mode_changed` broadcast to the session whose route matches. */ private routeRuntimeControlBroadcast(raw: Record<string, unknown>): void { const agentId = idOf(raw); if (agentId === undefined) return; const sessionId = this.sessionIdForRoute(agentId); if (sessionId === undefined) return; // unknown route — drop (re-sync/correlated result owns state) if (raw.type === 'model_changed') { const active = parseModelDescriptor(raw.active); const capabilities = raw.capabilities === undefined ? undefined : parseCapabilities(raw.capabilities); if (active === undefined || (raw.capabilities !== undefined && capabilities === undefined)) return; this.applyRuntimeControls(applyModelBroadcast(this.state.runtimeControls, sessionId, active, this.generation, capabilities)); if (hasCapability(this.state.runtimeControls[sessionId], CAPABILITY.reasoningCatalog)) { void this.refreshRuntimeReasoning(sessionId).catch(() => { /* error retained on the control state */ }); } return; } if (raw.type === 'mode_changed') { const mode = typeof raw.mode === 'string' ? raw.mode : ''; if (mode.length === 0) return; this.applyRuntimeControls(applyModeBroadcast(this.state.runtimeControls, sessionId, mode, this.generation)); return; } if (raw.type === 'reasoning_level_changed') { const model = parseModelDescriptor(raw.model); const active = typeof raw.active === 'string' ? raw.active : ''; if (model === undefined || active.length === 0) return; this.applyRuntimeControls(applyReasoningBroadcast(this.state.runtimeControls, sessionId, model, active, this.generation)); } }
/** Refresh the model catalog for the attached session (correlated `list_models`). */ refreshRuntimeModels(sessionId: string): Promise<ModelCatalog> { const routeAgentId = this.attachedRouteFor(sessionId); if (!this.isConnected() || routeAgentId === undefined) return Promise.reject(this.runtimeControlError('list_models', 'CLOSED', 'Session is not attached')); const control = this.state.runtimeControls[sessionId]; if (!hasCapability(control, CAPABILITY.modelCatalog)) return Promise.reject(this.runtimeControlError('list_models', 'UNSUPPORTED', 'Model catalog unavailable')); const generation = this.generation; const existing = this.runtimeModelRefresh.get(sessionId); if (existing !== undefined && existing.generation === generation) return existing.promise; const { map, seq, runtimeEpoch } = markCatalogLoading(this.state.runtimeControls, sessionId, 'model', generation); this.applyRuntimeControls(map); const promise = this.requestCommand<ModelCatalog>('list_models', { agent_id: routeAgentId }, (data) => { const catalog = parseModelCatalog(data); if (catalog === undefined) throw new Error('Invalid list_models response'); return catalog; }).then((catalog) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyModelCatalog(this.state.runtimeControls, sessionId, catalog, generation, seq, runtimeEpoch)); } return catalog; }, (error: LifecycleCommandError) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyCatalogError(this.state.runtimeControls, sessionId, 'model', this.runtimeControlMessage(error), generation, seq, runtimeEpoch)); } throw error; }).finally(() => { const current = this.runtimeModelRefresh.get(sessionId); if (current !== undefined && current.generation === generation && current.seq === seq) this.runtimeModelRefresh.delete(sessionId); }); this.runtimeModelRefresh.set(sessionId, { generation, seq, promise }); return promise; }
/** Refresh server-authored reasoning levels for the active model. */ refreshRuntimeReasoning(sessionId: string): Promise<ReasoningCatalog> { const routeAgentId = this.attachedRouteFor(sessionId); if (!this.isConnected() || routeAgentId === undefined) return Promise.reject(this.runtimeControlError('list_reasoning_levels', 'CLOSED', 'Session is not attached')); const control = this.state.runtimeControls[sessionId]; if (!hasCapability(control, CAPABILITY.reasoningCatalog)) return Promise.reject(this.runtimeControlError('list_reasoning_levels', 'UNSUPPORTED', 'Reasoning controls unavailable')); const generation = this.generation; const existing = this.runtimeReasoningRefresh.get(sessionId); if (existing !== undefined && existing.generation === generation) return existing.promise; const { map, seq, runtimeEpoch } = markReasoningCatalogLoading(this.state.runtimeControls, sessionId, generation); this.applyRuntimeControls(map); const promise = this.requestCommand<ReasoningCatalog>('list_reasoning_levels', { agent_id: routeAgentId }, (data) => { const catalog = parseReasoningCatalog(data); if (catalog === undefined) throw new Error('Invalid list_reasoning_levels response'); return catalog; }).then((catalog) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) this.applyRuntimeControls(applyReasoningCatalog(this.state.runtimeControls, sessionId, catalog, generation, seq, runtimeEpoch)); return catalog; }, (error: LifecycleCommandError) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { const failed = applyReasoningCatalogError(this.state.runtimeControls, sessionId, this.runtimeControlMessage(error), generation, seq, runtimeEpoch); this.applyRuntimeControls(error.code === 'NOT_FOUND' ? disableReasoning(failed, sessionId) : failed); } throw error; }).finally(() => { const current = this.runtimeReasoningRefresh.get(sessionId); if (current?.generation === generation && current.seq === seq) this.runtimeReasoningRefresh.delete(sessionId); }); this.runtimeReasoningRefresh.set(sessionId, { generation, seq, promise }); return promise; }
/** Refresh the mode catalog for the attached session (correlated `list_modes`). */ refreshRuntimeModes(sessionId: string): Promise<ModeCatalog> { const routeAgentId = this.attachedRouteFor(sessionId); if (!this.isConnected() || routeAgentId === undefined) return Promise.reject(this.runtimeControlError('list_modes', 'CLOSED', 'Session is not attached')); const control = this.state.runtimeControls[sessionId]; if (!hasCapability(control, CAPABILITY.modeCatalog)) return Promise.reject(this.runtimeControlError('list_modes', 'UNSUPPORTED', 'Mode catalog unavailable')); const generation = this.generation; const existing = this.runtimeModeRefresh.get(sessionId); if (existing !== undefined && existing.generation === generation) return existing.promise; const { map, seq, runtimeEpoch } = markCatalogLoading(this.state.runtimeControls, sessionId, 'mode', generation); this.applyRuntimeControls(map); const promise = this.requestCommand<ModeCatalog>('list_modes', { agent_id: routeAgentId }, (data) => { const catalog = parseModeCatalog(data); if (catalog === undefined) throw new Error('Invalid list_modes response'); return catalog; }).then((catalog) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyModeCatalog(this.state.runtimeControls, sessionId, catalog, generation, seq, runtimeEpoch)); } return catalog; }, (error: LifecycleCommandError) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyCatalogError(this.state.runtimeControls, sessionId, 'mode', this.runtimeControlMessage(error), generation, seq, runtimeEpoch)); } throw error; }).finally(() => { const current = this.runtimeModeRefresh.get(sessionId); if (current !== undefined && current.generation === generation && current.seq === seq) this.runtimeModeRefresh.delete(sessionId); }); this.runtimeModeRefresh.set(sessionId, { generation, seq, promise }); return promise; }
/** Switch the attached session's active model (correlated `set_model`). */ setRuntimeModel(sessionId: string, modelId: string): Promise<ModelDescriptor> { const routeAgentId = this.attachedRouteFor(sessionId); if (!this.isConnected() || routeAgentId === undefined) return Promise.reject(this.runtimeControlError('set_model', 'CLOSED', 'Session is not attached')); const control = this.state.runtimeControls[sessionId]; if (!hasCapability(control, CAPABILITY.modelSwitch)) return Promise.reject(this.runtimeControlError('set_model', 'UNSUPPORTED', 'Model switching unavailable')); const id = modelId.trim(); if (id.length === 0) return Promise.reject(this.runtimeControlError('set_model', 'INVALID_REQUEST', 'A model id is required')); if (control?.pendingModel) return Promise.reject(this.runtimeControlError('set_model', 'CONFLICT', 'A model switch is already pending')); const generation = this.generation; const { map, seq, runtimeEpoch } = markModelMutationStart(this.state.runtimeControls, sessionId, generation); this.applyRuntimeControls(map); return this.requestCommand<ModelDescriptor>('set_model', { agent_id: routeAgentId, model: id }, (data) => { if (!isRecord(data) || !isRecord(data.active)) throw new Error('Invalid set_model response'); const active = parseModelDescriptor(data.active); if (active === undefined) throw new Error('Invalid set_model response'); return active; }).then((active) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(invalidateReasoning(applyModelMutationSuccess(this.state.runtimeControls, sessionId, active, generation, seq, runtimeEpoch), sessionId)); if (hasCapability(this.state.runtimeControls[sessionId], CAPABILITY.reasoningCatalog)) void this.refreshRuntimeReasoning(sessionId).catch(() => {}); } return active; }, (error: LifecycleCommandError) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyMutationError(this.state.runtimeControls, sessionId, 'model', this.runtimeControlMessage(error), generation, seq, runtimeEpoch)); } throw error; }); }
/** Change reasoning effort for the authoritative active model. */ setRuntimeReasoning(sessionId: string, effort: string): Promise<string> { const routeAgentId = this.attachedRouteFor(sessionId); if (!this.isConnected() || routeAgentId === undefined) return Promise.reject(this.runtimeControlError('set_reasoning_level', 'CLOSED', 'Session is not attached')); const control = this.state.runtimeControls[sessionId]; if (!hasCapability(control, CAPABILITY.reasoningSwitch) || control?.reasoning === undefined) return Promise.reject(this.runtimeControlError('set_reasoning_level', 'UNSUPPORTED', 'Reasoning controls unavailable')); const value = effort.trim(); if (!control.reasoning.levels.some((level) => level.id === value)) return Promise.reject(this.runtimeControlError('set_reasoning_level', 'NOT_FOUND', 'Selected reasoning level is not available')); if (control.pendingReasoning) return Promise.reject(this.runtimeControlError('set_reasoning_level', 'CONFLICT', 'A reasoning change is already pending')); const generation = this.generation; const model = control.reasoning.model.id; const { map, seq, runtimeEpoch } = markReasoningMutationStart(this.state.runtimeControls, sessionId, generation); this.applyRuntimeControls(map); return this.requestCommand<string>('set_reasoning_level', { agent_id: routeAgentId, model, effort: value }, (data) => { if (!isRecord(data) || typeof data.active !== 'string' || data.active.length === 0) throw new Error('Invalid set_reasoning_level response'); return data.active; }).then((active) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) this.applyRuntimeControls(applyReasoningMutationSuccess(this.state.runtimeControls, sessionId, active, generation, seq, runtimeEpoch)); return active; }, (error: LifecycleCommandError) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) this.applyRuntimeControls(applyReasoningMutationError(this.state.runtimeControls, sessionId, this.runtimeControlMessage(error), generation, seq, runtimeEpoch)); if (error.code === 'CONFLICT') void this.refreshRuntimeReasoning(sessionId).catch(() => {}); throw error; }); }
/** Switch the attached session's active mode (correlated `set_mode`). Requires a * successfully loaded current-epoch authoritative mode catalog and membership — no * catalog/loading/error means no command (saga vr7kgx.6). */ setRuntimeMode(sessionId: string, mode: string): Promise<string> { const routeAgentId = this.attachedRouteFor(sessionId); if (!this.isConnected() || routeAgentId === undefined) return Promise.reject(this.runtimeControlError('set_mode', 'CLOSED', 'Session is not attached')); const control = this.state.runtimeControls[sessionId]; if (!hasCapability(control, CAPABILITY.modeSwitch)) return Promise.reject(this.runtimeControlError('set_mode', 'UNSUPPORTED', 'Mode switching unavailable')); // Require a successfully loaded current-epoch authoritative catalog: no catalog / still // loading / a retained error means no command (the runtime is the final authority, but // the browser must not send a blind mutation against an unknown/disputed catalog). if (control?.modes === undefined) return Promise.reject(this.runtimeControlError('set_mode', 'UNAVAILABLE', 'Mode catalog is not available')); if (control.loadingModes) return Promise.reject(this.runtimeControlError('set_mode', 'UNAVAILABLE', 'Mode catalog is still loading')); if (control.modeError !== undefined) return Promise.reject(this.runtimeControlError('set_mode', 'UNAVAILABLE', 'Mode catalog is unavailable')); const name = mode.trim(); if (name.length === 0) return Promise.reject(this.runtimeControlError('set_mode', 'INVALID_REQUEST', 'A mode is required')); if (!control.modes.modes.some((m) => m.name === name)) return Promise.reject(this.runtimeControlError('set_mode', 'NOT_FOUND', 'Selected mode is not in the catalog')); if (control.pendingMode) return Promise.reject(this.runtimeControlError('set_mode', 'CONFLICT', 'A mode switch is already pending')); const generation = this.generation; const { map, seq, runtimeEpoch } = markModeMutationStart(this.state.runtimeControls, sessionId, generation); this.applyRuntimeControls(map); return this.requestCommand<string>('set_mode', { agent_id: routeAgentId, mode: name }, (data) => { if (!isRecord(data) || typeof data.mode !== 'string' || data.mode.length === 0) throw new Error('Invalid set_mode response'); return data.mode; }).then((resultMode) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyModeMutationSuccess(this.state.runtimeControls, sessionId, resultMode, generation, seq, runtimeEpoch)); } return resultMode; }, (error: LifecycleCommandError) => { if (generation === this.generation && routeAgentId === this.attachedRouteFor(sessionId)) { this.applyRuntimeControls(applyMutationError(this.state.runtimeControls, sessionId, 'mode', this.runtimeControlMessage(error), generation, seq, runtimeEpoch)); } throw error; }); }
/** Build a scoped runtime-control error (does NOT touch the global lifecycle notice). */ private runtimeControlError(command: string, code: string, message: string): LifecycleCommandError { return new LifecycleCommandError(command, code, message); } /** Extract a human message from a correlated error for inline control errors. */ private runtimeControlMessage(error: LifecycleCommandError): string { return error.message; }
// ─── activation sync-ordering buffer (saga vr7kgx.2 race fix) ───────────────────── // The daemon's broker emits `sync` (and possibly buffered live events) for a freshly // activated route BEFORE the rewritten `session.activate` response resolves. The client // cannot know/store the returned private `agent_id` route handle until the response, so // pre-response `sync`/live events for the as-yet-unknown route are buffered keyed by route // id (never routed heuristically to a session) and flushed in wire order once the response // stores the handle. Buffering is strictly bounded and only while an activation is pending; // records for unsolicited routes (no pending activation) are dropped. Generation + // selection fencing and activation-before-send exactly-once semantics are preserved.
/** Route a `sync` event, or buffer it if its route handle is not yet known. */ private routeOrBufferSync(raw: Record<string, unknown>): void { const agentId = idOf(raw); if (agentId === undefined) return; if (this.sessionIdForRoute(agentId) !== undefined) { this.routeSessionSync(raw); return; } this.bufferUnknownRoute(raw, agentId); }
/** Route a live event, or buffer it if its route handle is not yet known. Global * lifecycle events (agent_activity / agent_status_changed / agent_spawned / agent_exited) * are handled by `reduceConnection` and are NEVER buffered here. Permission/ui requests * (and their resolutions) additionally fall back to the parent session tab for a delegated * child whose own tab is not activated (saga vr7kgx.4). */ private routeOrBufferLive(raw: unknown, publish = true): void { if (!isRecord(raw) || typeof raw.type !== 'string') return; const agentId = idOf(raw); if (agentId === undefined || isGlobalLifecycle(raw)) return; const isPrompt = raw.type === 'permission_request' || raw.type === 'ui_request' || raw.type === 'permission_resolved' || raw.type === 'ui_resolved'; const known = isPrompt ? this.sessionIdForRouteOrParent(agentId) !== undefined : this.sessionIdForRoute(agentId) !== undefined; if (known) { this.routeSessionLive(raw, publish); return; } this.bufferUnknownRoute(raw, agentId); }
/** Buffer a record for an unknown route while an activation is pending; drop otherwise. */ private bufferUnknownRoute(raw: Record<string, unknown>, routeAgentId: string): void { if (this.pendingActivations === 0) return; // unsolicited unknown route — drop if (this.bufferedRecordCount >= MAX_ACTIVATION_BUFFERED_RECORDS) return; // record cap — drop const bytes = JSON.stringify(raw).length; if (this.bufferedBytes + bytes > MAX_ACTIVATION_BUFFERED_BYTES) return; // byte cap — drop const list = this.activationBuffer.get(routeAgentId); if (list === undefined) this.activationBuffer.set(routeAgentId, [{ raw, bytes }]); else list.push({ raw, bytes }); this.bufferedRecordCount++; this.bufferedBytes += bytes; }
/** Flush a now-known route's buffered records through the normal session routing in wire * order, with `sync` applied before buffered live events (per the confirmed daemon order). */ private flushActivationBuffer(routeAgentId: string): void { const list = this.activationBuffer.get(routeAgentId); if (list === undefined) return; this.activationBuffer.delete(routeAgentId); for (const record of list) { if (isRecord(record.raw) && record.raw.type === 'sync') this.routeSessionSync(record.raw); } for (const record of list) { if (!(isRecord(record.raw) && record.raw.type === 'sync')) this.routeSessionLive(record.raw); } this.discardBufferCounts(list); }
/** Discard a route's buffered records (stale generation / activation failure / tab close / replacement). */ private discardActivationBuffer(routeAgentId: string): void { const list = this.activationBuffer.get(routeAgentId); if (list === undefined) return; this.activationBuffer.delete(routeAgentId); this.discardBufferCounts(list); }
private discardBufferCounts(list: BufferedActivationRecord[]): void { this.bufferedRecordCount = Math.max(0, this.bufferedRecordCount - list.length); for (const record of list) this.bufferedBytes = Math.max(0, this.bufferedBytes - record.bytes); }
/** Decrement the pending count; when none remain, discard any uncorrelated records. */ private finishActivation(): void { if (this.pendingActivations > 0) this.pendingActivations--; if (this.pendingActivations === 0) this.clearActivationBuffer(); }
private clearActivationBuffer(): void { this.activationBuffer.clear(); this.bufferedRecordCount = 0; this.bufferedBytes = 0; }
/** * Best-effort `agent.detach` for a private route handle whose owning tab was closed while * `session.activate` was in flight. The daemon has already attached an interactive route, * but no tab remains to surface its permission/UI events — detach it so the browser is not an * invisible interactive attachment. The close path detaches the currently owned attachment * epoch, while the late response path detaches the fresh attachment established by the * in-flight operation. These are intentionally separate operation/epoch obligations: a route * id may be legitimately reattached after the first detach. Strictly generation-fenced (never * send on a replaced/closed socket), and the route id is never written to UI state: it is * consumed solely for this wire command. Send/socket errors are swallowed (best-effort). */ private detachOrphanedRoute(routeAgentId: string, issueGeneration: number): void { if (issueGeneration !== this.generation) return; // socket replaced/closed — never send if (!this.isConnected()) return; // not connected — nothing to detach try { this.socket!.send(createCommand('agent.detach', { id: this.id(), agent_id: routeAgentId })); } catch { /* best-effort: never throw on a send error here */ } }
/** Refresh the global session catalog (durable navigation); resolves once the list lands. */ refreshSessionCatalog(force = false): Promise<SessionSummary[]> { if (!this.isConnected()) return Promise.reject(new LifecycleCommandError('session.list', 'CLOSED', 'Not connected')); const generation = this.generation; const current = this.sessionCatalogRefresh; if (!force && current?.generation === generation) return current.promise; if (force) this.sessionCatalogRefresh = undefined; const requestSequence = ++this.sessionCatalogRequestSequence; const request = this.requestCommand<SessionSummary[]>('session.list', {}, (data) => { if (!isRecord(data) || !Array.isArray(data.sessions)) throw new Error('Invalid session.list response'); const sessions = data.sessions.filter(validSessionSummary); if (generation === this.generation && requestSequence === this.sessionCatalogRequestSequence) { this.applySessionModel(applySessionList(this.sessionModel(), sessions)); } return sessions; }); const promise = request.finally(() => { if (generation === this.generation && this.sessionCatalogRefresh?.generation === generation && this.sessionCatalogRefresh.promise === promise) { this.sessionCatalogRefresh = undefined; } }); this.sessionCatalogRefresh = { generation, promise }; return promise; }
/** * Refresh the workspace catalog — the repository-scoped changes-in-flight view (saga * j8dtju.7). Sends `workspace.list` over the existing correlated-command infrastructure * ({@link requestCommand}) and stores the validated {@link WorkspaceSummary} array via * {@link applyWorkspaceList}, preserving label/branch/ownership/lifecycle/repository * group fields for the UI layer (j8dtju.11). Generation-fenced like * `refreshSessionCatalog`. Rejects with a `LifecycleCommandError` on transport/protocol * failure; a legacy daemon that does not implement `workspace.list` returns an error which * the caller may swallow (the workspace catalog simply stays empty — identical to * pre-worktree behavior). This is NOT called automatically: workspace lifecycle events * optimistically upsert the `workspaces` catalog in place (no `workspace.list` round trip) * and only coalesce a `session.list` refresh; the UI layer requests an explicit * `refreshWorkspaceCatalog` when it mounts the workspace view (no automatic visual UI). */ refreshWorkspaceCatalog(): Promise<WorkspaceSummary[]> { if (!this.isConnected()) return Promise.reject(new LifecycleCommandError('workspace.list', 'CLOSED', 'Not connected')); const generation = this.generation; const request = this.requestCommand<WorkspaceSummary[]>('workspace.list', {}, (data) => { if (!isRecord(data) || !Array.isArray(data.workspaces)) throw new Error('Invalid workspace.list response'); const workspaces = data.workspaces.filter(validWorkspaceSummary); if (generation === this.generation) { this.applySessionModel(applyWorkspaceList(this.sessionModel(), workspaces)); } return workspaces; }); return request; }
/** * `workspace.create` (saga j8dtju.6/11) — create a managed linked worktree and launch a * fresh daemon-generated Agent + Session. Sends the correlated `workspace.create` * command and validates the response strictly against {@link WorkspaceCreateResponseSchema} * (reused from {@link WorkspaceResponseSchemas}); the browser never invokes Git. The * daemon emits `workspace_created` (event-before-response), which already optimistically * upserts the `workspaces` catalog via {@link applyWorkspaceLifecycleEvent}; this method * ALSO applies the response's authoritative summary immediately (generation-fenced) so the * catalog reflects the created workspace even if the event was missed/malformed, then forces * a `session.list` refresh so the new session's workspace tags land. Rejects with a * {@link LifecycleCommandError} on transport/protocol failure. Operation-pending fenced so a * duplicate create cannot race. */ createWorkspace(payload: { cwd: string; label?: string; branch?: string; base_ref?: string; integration_target?: string; title?: string; model?: string; provider?: string; mode?: string; }): Promise<WorkspaceCreateResponse> { const cwd = payload.cwd.trim(); if (cwd.length === 0) return Promise.reject(new LifecycleCommandError('workspace.create', 'INVALID_REQUEST', 'cwd is required')); const commandPayload: Record<string, unknown> = { cwd }; const launchFields: Array<readonly [string, string | undefined]> = [ ['label', payload.label], ['branch', payload.branch], ['base_ref', payload.base_ref], ['integration_target', payload.integration_target], ['title', payload.title], ['model', payload.model], ['provider', payload.provider], ['mode', payload.mode], ]; for (const [key, value] of launchFields) { if (typeof value === 'string' && value.trim().length > 0) commandPayload[key] = value.trim(); } const command = 'workspace.create'; const key = command; if (this.state.operationPending[key]) return Promise.reject(new LifecycleCommandError(command, 'CONFLICT', 'Create already pending')); this.setOperationPending(key); const generation = this.generation; return this.requestCommand<WorkspaceCreateResponse>(command, commandPayload, (data) => { if (!Value.Check(WorkspaceCreateResponseSchema, data)) throw new Error('Invalid workspace.create response'); return data; }, WORKSPACE_CREATE_COMMAND_TIMEOUT_MS).then((result) => { this.clearOperationPending(key); this.dismissLifecycleNotice(); // State catalog updates immediately (generation-fenced): upsert the authoritative // summary from the response, then refresh the session catalog so tags land. if (generation === this.generation) this.applySessionModel(applyWorkspaceLifecycle(this.sessionModel(), result.workspace.summary)); if (generation === this.generation) void this.refreshSessionCatalog(true).catch(() => { /* next event/reconnect retries */ }); return result; }, (error: LifecycleCommandError) => { this.clearOperationPending(key); this.notice(command, undefined, error); throw error; }); }
/** * `workspace.attach` (saga j8dtju.6/11) — bind a fresh daemon-generated Agent + Session to * an EXISTING workspace. Sends the correlated `workspace.attach` command (workspace id + * launch opts) and validates the response strictly against {@link WorkspaceAttachResponseSchema} * (reused from {@link WorkspaceResponseSchemas}). Mirrors {@link createWorkspace}: the daemon * emits `workspace_attached` (event-before-response), this method also applies the * response's authoritative summary immediately (generation-fenced), then forces a * `session.list` refresh so the new session's workspace tags land. Rejects with a * {@link LifecycleCommandError} on transport/protocol failure. Operation-pending fenced. */ attachWorkspace(workspaceId: string, opts: { title?: string; name?: string; model?: string; provider?: string; mode?: string; } = {}): Promise<WorkspaceAttachResponse> { const workspace_id = workspaceId.trim(); if (workspace_id.length === 0) return Promise.reject(new LifecycleCommandError('workspace.attach', 'INVALID_REQUEST', 'workspace_id is required')); const commandPayload: Record<string, unknown> = { workspace_id }; const launchFields: Array<readonly [string, string | undefined]> = [ ['title', opts.title], ['name', opts.name], ['model', opts.model], ['provider', opts.provider], ['mode', opts.mode], ]; for (const [key, value] of launchFields) { if (typeof value === 'string' && value.trim().length > 0) commandPayload[key] = value.trim(); } const command = 'workspace.attach'; const key = command; if (this.state.operationPending[key]) return Promise.reject(new LifecycleCommandError(command, 'CONFLICT', 'Attach already pending')); this.setOperationPending(key); const generation = this.generation; return this.requestCommand<WorkspaceAttachResponse>(command, commandPayload, (data) => { if (!Value.Check(WorkspaceAttachResponseSchema, data)) throw new Error('Invalid workspace.attach response'); return data; }).then((result) => { this.clearOperationPending(key); this.dismissLifecycleNotice(); if (generation === this.generation) this.applySessionModel(applyWorkspaceLifecycle(this.sessionModel(), result.workspace.summary)); if (generation === this.generation) void this.refreshSessionCatalog(true).catch(() => { /* next event/reconnect retries */ }); return result; }, (error: LifecycleCommandError) => { this.clearOperationPending(key); this.notice(command, undefined, error); throw error; }); }
/** * `workspace.status` (saga j8dtju.6/11) — fetch the authoritative {@link WorkspaceDetail} * for one workspace (summary + bindings + last commit/probe + tombstone/conflict). Sends the * correlated `workspace.status` command and validates the response strictly against * {@link WorkspaceStatusResponseSchema} (reused from {@link WorkspaceResponseSchemas}). This * is a read-only inspection: it does NOT mutate the `workspaces` catalog or refresh the * session catalog (the detail carries transient binding/commit metadata not present on the * summary catalog). Rejects with a {@link LifecycleCommandError} on transport/protocol * failure; a legacy daemon that does not implement `workspace.status` returns an error the * caller may swallow. */ inspectWorkspace(workspaceId: string): Promise<WorkspaceDetail> { const workspace_id = workspaceId.trim(); if (workspace_id.length === 0) return Promise.reject(new LifecycleCommandError('workspace.status', 'INVALID_REQUEST', 'workspace_id is required')); const command = 'workspace.status'; return this.requestCommand<WorkspaceDetail>(command, { workspace_id }, (data) => { if (!Value.Check(WorkspaceStatusResponseSchema, data)) throw new Error('Invalid workspace.status response'); return data; }); }
/** * `workspace.retire` (saga j8dtju.6/11) — explicitly retire a managed workspace after its * change lifecycle. Sends the correlated `workspace.retire` command with a branch * disposition and validates the response strictly against {@link WorkspaceRetireResponseSchema} * (reused from {@link WorkspaceResponseSchemas}). The daemon emits `workspace_retired` * (event-before-response), which already optimistically upserts the retired summary; this * method also applies the response's authoritative retired summary immediately * (generation-fenced), then forces a `session.list` refresh so the session tags stay * coherent. On a refused retirement, the daemon returns `success:false` with a structured * {@link WorkspaceRetireRefusal} on `error.data` (kind `confirmation_required` for an * unproven/squash-merged branch that needs an explicit `delete_confirmed` retry, or `refused` * for live/dirty/conflict/authority blocks). The refusal is validated against * {@link WorkspaceRetireRefusalSchema} and preserved on the re-thrown * {@link LifecycleCommandError}.data so the typed `confirmation_required` survives — the * caller branches on the refusal kind to surface an actionable confirmation or a terminal * refusal; a malformed/non-refusal data payload is dropped (fail closed). Rejects with a * {@link LifecycleCommandError} on transport/protocol failure. Operation-pending fenced. */ retireWorkspace(workspaceId: string, disposition: BranchDisposition): Promise<WorkspaceRetireResponse> { const workspace_id = workspaceId.trim(); if (workspace_id.length === 0) return Promise.reject(new LifecycleCommandError('workspace.retire', 'INVALID_REQUEST', 'workspace_id is required')); const command = 'workspace.retire'; const commandPayload: Record<string, unknown> = { workspace_id, branch_disposition: disposition }; const key = command; if (this.state.operationPending[key]) return Promise.reject(new LifecycleCommandError(command, 'CONFLICT', 'Retire already pending')); this.setOperationPending(key); const generation = this.generation; return this.requestCommand<WorkspaceRetireResponse>(command, commandPayload, (data) => { if (!Value.Check(WorkspaceRetireResponseSchema, data)) throw new Error('Invalid workspace.retire response'); return data; }).then((result) => { this.clearOperationPending(key); this.dismissLifecycleNotice(); if (generation === this.generation) this.applySessionModel(applyWorkspaceLifecycle(this.sessionModel(), result.workspace.summary)); if (generation === this.generation) void this.refreshSessionCatalog(true).catch(() => { /* next event/reconnect retries */ }); return result; }, (error: LifecycleCommandError) => { this.clearOperationPending(key); this.notice(command, undefined, error); // Preserve the typed refusal (validated) so `confirmation_required` survives for the // caller to branch on; drop malformed/non-refusal data (fail closed). const refusal = Value.Check(WorkspaceRetireRefusalSchema, error.data) ? (error.data as WorkspaceRetireRefusal) : undefined; throw new LifecycleCommandError(error.command, error.code, error.message, refusal); }); }
/** * Select a session by durable id. Opens the newest replay page FIRST without waking a * stopped runtime; if the catalog reports an active runtime, subscribes (activates) after * replay so `sync` + buffered live events follow history. Generation + selection fenced. */ selectSession(sessionId: string, savedViewport: ViewportState | null = null): void { const summary = this.state.sessions.find((s) => s.session_id === sessionId); if (summary === undefined) return; this.applySessionModel(selectSessionPure(this.sessionModel(), sessionId)); if (savedViewport !== null) this.savedViewports[sessionId] = savedViewport; if (!this.isConnected()) return; // replay/activate run on (re)connect via `restoreFocusedSession` const generation = this.generation; void this.requestSessionReplay(sessionId, summary.cwd, { kind: 'newest' }).then(() => { if (!this.canContinueSessionRestore(generation, sessionId)) return; return this.pageToSavedAnchor(sessionId, summary.cwd); }).then(() => { if (!this.canContinueSessionRestore(generation, sessionId)) return; const refreshed = this.state.sessions.find((s) => s.session_id === sessionId); if (refreshed !== undefined && runtimeIsActive(refreshed.runtime)) void this.activateSession(sessionId, refreshed.cwd); }, (error: LifecycleCommandError) => { if (generation === this.generation && this.focusedNow().includes(sessionId)) this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); }); }
/** * Open or replace the split-pane session (saga vr7kgx.14). Replays the newest history page * first (without waking a stopped runtime), then activates if the catalog reports the * runtime active — the same replay-then-activate sequence as `selectSession`, but it sets * the split slot and never changes the primary selection. Generation + focus fenced: a * late replay for the split lands on its own session-keyed tab. */ setSplitSession(sessionId: string, savedViewport: ViewportState | null = null): void { const summary = this.state.sessions.find((s) => s.session_id === sessionId); if (summary === undefined) return; this.applySessionModel(setSplitSessionPure(this.sessionModel(), sessionId)); if (savedViewport !== null) this.savedViewports[sessionId] = savedViewport; if (!this.isConnected()) return; // replay/activate run on (re)connect via `restoreFocusedSessions` const generation = this.generation; void this.requestSessionReplay(sessionId, summary.cwd, { kind: 'newest' }).then(() => { if (!this.canContinueSessionRestore(generation, sessionId)) return; return this.pageToSavedAnchor(sessionId, summary.cwd); }).then(() => { if (!this.canContinueSessionRestore(generation, sessionId)) return; const refreshed = this.state.sessions.find((s) => s.session_id === sessionId); if (refreshed !== undefined && runtimeIsActive(refreshed.runtime)) void this.activateSession(sessionId, refreshed.cwd); }, (error: LifecycleCommandError) => { if (generation === this.generation && this.focusedNow().includes(sessionId)) this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); }); }
/** Close the split pane (keep the tab/route). Clears the split slot only. */ clearSplitSession(): void { this.applySessionModel(setSplitSessionPure(this.sessionModel(), null)); }
/** Swap the primary and split panes. Both routes are already alive on their own tabs, so * this is a pure model swap with no replay/activation. */ swapPanes(): void { this.applySessionModel(swapPanesPure(this.sessionModel())); }
/** * Close a session tab: drop this connection's per-session transcript/pending/viewport * state for the session (the durable catalog is untouched). Pure-reducer-backed; the * route owns the persisted open-tab list and per-session viewport anchors in localStorage. * If the closed session was the selected one, selection clears (the route selects the * next open tab or returns home). Pure-equivalent of `closeSessionTab` in `./session-state`. */ closeSessionTab(sessionId: string): void { const tab = this.state.sessionTabs[sessionId]; const routeAgentId = tab?.attached === true && tab.attachedGeneration === this.generation ? tab.routeAgentId : undefined; delete this.savedViewports[sessionId]; this.applySessionModel(closeSessionTab(this.sessionModel(), sessionId)); this.applyRuntimeControls(clearRuntimeControls(this.state.runtimeControls, sessionId)); this.runtimeModelRefresh.delete(sessionId); this.runtimeModeRefresh.delete(sessionId); if (routeAgentId !== undefined) this.detachOrphanedRoute(routeAgentId, this.generation); }
/** Page older history until the persisted anchor is present, with cursor/no-progress fences. */ private async pageToSavedAnchor(sessionId: string, cwd: string): Promise<void> { const anchor = this.savedViewports[sessionId]?.anchorEntryId; if (anchor === undefined || anchor === null) return; const generation = this.generation; for (let page = 0; page < 256; page++) { if (generation !== this.generation || !this.focusedNow().includes(sessionId)) return; const tab = this.state.sessionTabs[sessionId]; if (tab === undefined || tab.transcript.some((entry) => transcriptAnchorKey(entry) === anchor) || !tab.replay.hasOlder) return; const tailId = tab.replay.tailId; const beforeEntryId = tab.replay.beforeEntryId; if (tailId === undefined || beforeEntryId === undefined) return; const previousBefore = beforeEntryId; try { await this.requestSessionReplay(sessionId, cwd, { kind: 'older', tailId, beforeEntryId }); } catch { return; } const next = this.state.sessionTabs[sessionId]?.replay; if (next === undefined || next.beforeEntryId === previousBefore) return; } }
/** Fetch the next older replay page (infinite scroll-up); no-op while one is in flight. */ loadOlderSessionPage(sessionId: string): Promise<void> { const tab = this.state.sessionTabs[sessionId]; const summary = this.state.sessions.find((s) => s.session_id === sessionId); if (tab === undefined || summary === undefined || !this.isConnected()) return Promise.resolve(); if (tab.replay.loading || !tab.replay.hasOlder) return Promise.resolve(); const tailId = tab.replay.tailId; if (tailId === undefined) return Promise.resolve(); const beforeEntryId = tab.replay.beforeEntryId; this.applySessionModel(markPageLoading(this.sessionModel(), sessionId)); return this.requestSessionReplay(sessionId, summary.cwd, { kind: 'older', tailId, ...(beforeEntryId !== undefined ? { beforeEntryId } : {}) }) .then(() => undefined, (error: LifecycleCommandError) => { this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); throw error; }); }
/** * `session.activate`: idempotently ensure one runtime + subscribe. Stores the private route * handle; the daemon then emits `sync`. Fenced by connection generation + session identity * (the session selected at issue), so a reconnect/close or a response for a different * session cannot mutate the wrong tab. Selection switching alone does NOT discard a * background-tab activation (it lands on its own session-keyed tab); a tab closed mid- * activation is not re-created and its buffered records are discarded. */ activateSession(sessionId: string, cwd: string): Promise<string> { if (!this.isConnected()) return Promise.reject(new LifecycleCommandError('session.activate', 'CLOSED', 'Not connected')); const cwdTrim = cwd.trim(); if (sessionId.length === 0 || cwdTrim.length === 0) return Promise.reject(new LifecycleCommandError('session.activate', 'INVALID_REQUEST', 'session_id and cwd are required')); const generation = this.generation; const activationKey = `${generation}:${sessionId}`; const existing = this.activationRequests.get(activationKey); if (existing !== undefined) return existing; const tab = this.state.sessionTabs[sessionId]; if (tab?.attached === true && tab.routeAgentId !== undefined && tab.attachedGeneration === generation) return Promise.resolve(tab.routeAgentId); this.pendingActivations++; // Mark the activation pending so the UI (runtime panel + compact controls' "Relaunch // runtime" affordance) can show a "Relaunching…" state and fence a double click. Cleared // in both resolve/reject handlers below. Keyed by session (no generation) so it tracks // the user-facing session, mirroring the other lifecycle commands' pending keys. const activatePendingKey = `session.activate:${sessionId}`; this.setOperationPending(activatePendingKey); let activation!: Promise<string>; activation = this.requestCommand<{ agent_id: string }>('session.activate', { session_id: sessionId, cwd: cwdTrim }, (data) => { if (!isRecord(data) || typeof data.session_id !== 'string' || data.session_id !== sessionId || typeof data.agent_id !== 'string' || data.agent_id.length === 0) throw new Error('Invalid session.activate response'); return { agent_id: data.agent_id }; }).then((result) => { // Selection changes do not invalidate an activation that was explicitly issued // for this still-open session: its route is session-keyed and remains a valid // background attachment. Only a replaced generation invalidates its response. const stale = generation !== this.generation; if (!stale) { // The activating tab must still exist — `closeSessionTab` mid-activation removes it; // a late response must NOT re-create a closed tab (acceptance: tab close discards // relevant records). The tab always exists at issue time (replay/markActivating created // it), so an absent tab here means it was closed in flight. if (this.state.sessionTabs[sessionId] === undefined) { // Tab closed in flight: the daemon has already attached a private interactive route // for this session, but its owning tab is gone. Send a best-effort `agent.detach` for // the EXACT private route id returned by the response so the browser does not remain // an invisible interactive attachment receiving permission/UI events with no tab to // surface them. This is a separate detach obligation from the close-time detach: // the response may establish a fresh attachment even when it returns the same route. // Generation-fenced (never send on a replaced/closed socket), and the route id is never exposed to UI // state — it is consumed only for this wire command, then discarded with the buffer. this.detachOrphanedRoute(result.agent_id, generation); this.discardActivationBuffer(result.agent_id); // discard this route's records, do not re-create the tab } else { this.applySessionModel(applyActivateResult(this.sessionModel(), sessionId, result.agent_id, { active: true, attached: true }, generation)); this.flushActivationBuffer(result.agent_id); // correlated route known — replay buffered sync/live in order } } else { this.discardActivationBuffer(result.agent_id); // wrong tab/generation/restore target this.detachOrphanedRoute(result.agent_id, generation); } if (generation === this.generation) this.clearOperationPending(activatePendingKey); if (generation === this.generation) this.finishActivation(); if (this.activationRequests.get(activationKey) === activation) this.activationRequests.delete(activationKey); return result.agent_id; }, (error: LifecycleCommandError) => { // Only record the error on a still-open tab; never re-create a closed tab to surface it. if (generation === this.generation && this.state.sessionTabs[sessionId] !== undefined) this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); if (generation === this.generation) this.clearOperationPending(activatePendingKey); if (generation === this.generation) this.finishActivation(); // failure: no route to correlate; drain + discard when last if (this.activationRequests.get(activationKey) === activation) this.activationRequests.delete(activationKey); throw error; }); this.activationRequests.set(activationKey, activation); return activation; }
/** * Send a prompt to a session EXACTLY ONCE. If the runtime is attached, sends immediately * through the private route handle. If stopped, queues the message, runs `session.activate`, * then sends once on success (and clears the queue). A duplicate call while activating is * coalesced (the queued message is replaced, never duplicated). */ sendSessionPrompt(sessionId: string, message: string): Promise<boolean> { const trimmed = message.trim(); if (trimmed.length === 0) return Promise.resolve(false); const summary = this.state.sessions.find((s) => s.session_id === sessionId); if (summary === undefined) return Promise.resolve(false); const tab = this.state.sessionTabs[sessionId]; if (tab !== undefined && tab.attached && tab.routeAgentId !== undefined && tab.attachedGeneration === this.generation && this.isConnected()) { return this.sendAcceptedSessionInput('prompt', sessionId, tab.routeAgentId, trimmed); } if (tab !== undefined && tab.activating) { // The first prompt owns the activation. Later prompts are rejected and cannot // replace the queued payload or create a hidden send. return Promise.resolve(false); } if (!this.isConnected()) return Promise.resolve(false); this.applySessionModel(markActivating(this.sessionModel(), sessionId, trimmed)); return this.activateSession(sessionId, summary.cwd).then((routeAgentId) => { // If the tab was closed while activation/send was in flight, the queued prompt must NOT // be sent through the route, the tab must not be restored, and no route state may leak // (acceptance: tab close during activation/send discards the queued prompt + buffered // records). `activateSession` already discarded the buffer and did not re-create the // tab; mirror that here by bailing before `dequeueSend`/send so the prompt never goes // out on a route whose owning tab no longer exists. if (this.state.sessionTabs[sessionId] === undefined) return false; const dequeued = dequeueSend(this.sessionModel(), sessionId); this.applySessionModel(dequeued.state); const prompt = dequeued.prompt ?? trimmed; if (!this.isConnected()) return false; return this.sendAcceptedSessionInput('prompt', sessionId, routeAgentId, prompt); }, (error: LifecycleCommandError) => { this.applySessionModel(clearActivating(this.sessionModel(), sessionId)); this.notice('session.activate', sessionId, error); throw error; }); }
/** Send prompt/steer through a private route, then echo only after daemon acceptance. */ private sendAcceptedSessionInput(command: 'prompt' | 'steer', sessionId: string, routeAgentId: string, message: string): Promise<boolean> { const generation = this.generation; return this.requestCommand<void>(command, { agent_id: routeAgentId, message }, () => undefined) .then(() => { const current = this.state.sessionTabs[sessionId]; if (generation !== this.generation || current === undefined || current.routeAgentId !== routeAgentId || current.attachedGeneration !== generation) return false; this.applySessionModel(appendLocalUserMessage(this.sessionModel(), sessionId, `local-user:${this.id()}`, message)); return true; }, (error: LifecycleCommandError) => { if (generation === this.generation && this.state.sessionTabs[sessionId] !== undefined) { this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); } throw error; }); }
/** Typed `session.replay` request (newest page or older page); fenced by generation+selection. */ private requestSessionReplay(sessionId: string, cwd: string, page: { kind: 'newest' } | { kind: 'older'; tailId: string; beforeEntryId?: string }): Promise<ReplayPageMeta> { if (!this.isConnected()) return Promise.reject(new LifecycleCommandError('session.replay', 'CLOSED', 'Not connected')); const cwdTrim = cwd.trim(); if (sessionId.length === 0 || cwdTrim.length === 0) return Promise.reject(new LifecycleCommandError('session.replay', 'INVALID_REQUEST', 'session_id and cwd are required')); const generation = this.generation; const focusedAtIssue = this.focusedNow(); const payload: Record<string, unknown> = { session_id: sessionId, cwd: cwdTrim }; if (page.kind === 'older') { payload.tail_id = page.tailId; if (page.beforeEntryId !== undefined) payload.before_entry_id = page.beforeEntryId; } return this.requestCommand('session.replay', payload, (data) => { if (!isRecord(data) || !Array.isArray(data.entries)) throw new Error('Invalid session.replay response'); const entries = data.entries as SessionEntry[]; const tailId = typeof data.tail_id === 'string' ? data.tail_id : undefined; const beforeEntryId = typeof data.before_entry_id === 'string' ? data.before_entry_id : undefined; const hasOlder = data.has_older === true; return { entries, tailId, beforeEntryId, hasOlder }; }).then((result) => { if (isStale({ generation, focusedSessionIds: focusedAtIssue }, { generation: this.generation, focusedSessionIds: this.focusedNow() }, sessionId)) return { hasOlder: result.hasOlder, loading: false }; const model = this.sessionModel(); const slice = page.kind === 'newest' ? applyReplayPage(model, sessionId, result.entries, { ...(result.tailId !== undefined ? { tailId: result.tailId } : {}), ...(result.beforeEntryId !== undefined ? { beforeEntryId: result.beforeEntryId } : {}), hasOlder: result.hasOlder }) : applyOlderPage(model, sessionId, result.entries, { ...(result.beforeEntryId !== undefined ? { beforeEntryId: result.beforeEntryId } : {}), hasOlder: result.hasOlder }); this.applySessionModel(slice); return { ...(result.tailId !== undefined ? { tailId: result.tailId } : {}), ...(result.beforeEntryId !== undefined ? { beforeEntryId: result.beforeEntryId } : {}), hasOlder: result.hasOlder, loading: false }; }, (error: LifecycleCommandError) => { if (!isStale({ generation, focusedSessionIds: focusedAtIssue }, { generation: this.generation, focusedSessionIds: this.focusedNow() }, sessionId)) this.applySessionModel(applyTabError(this.sessionModel(), sessionId, error.message)); throw error; }); }
close() { ++this.generation; this.sessionCatalogRefresh = undefined; this.sessionCatalogDirty = false; this.activationRequests.clear(); this.commands.replace(); const timers = this.deps.timers ?? defaultConnectionTimers; if (this.timer) timers.clearTimeout(this.timer); if (this.helloTimer) timers.clearTimeout(this.helloTimer); this.socket?.close(); this.timer = undefined; this.helloTimer = undefined; this.helloReady = false; this.registryReady = false; this.liveBuffer = []; this.sessionListRequestId = ''; this.sessionListResolver = undefined; this.pendingActivations = 0; this.clearActivationBuffer(); this.clearRuntimeControlBookkeeping(); const next = { ...this.state, phase: 'offline' as const, runtimeControls: {} }; delete next.error; delete next.errorCode; this.set(next); }}