| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { randomUUID } from 'node:crypto'; |
| import { join } from 'node:path'; |
| import type { CreateSessionInput } from '@maka/core/runtime-inputs'; |
| import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; |
| import { |
| decodeStoredMessage as decodePersistedStoredMessage, |
| deriveTurnRecords, |
| userFacingText, |
| type SessionSummary, |
| type StoredMessage, |
| } from '@maka/core/session'; |
| import { projectSessionTodoItemsForDisplay, type SessionTodoItem } from '@maka/core/session-todo'; |
| import { markPersisted } from '@maka/core/persisted-value'; |
| import { |
| type ActiveInteractionRequestEvent, |
| type SessionEvent, |
| type ShellRunSnapshotResult, |
| type ShellRunUpdate, |
| } from '@maka/core/events'; |
| import { isSideConversationSession } from '@maka/core/side-conversation'; |
| import { |
| createSessionCopyCleanupAuthority, |
| type SessionCopyCleanupAuthority, |
| } from '@maka/storage/session-copy-cleanup'; |
| import type { ProcessLifetimeOwner } from '@maka/storage/process-lifetime-owner'; |
| |
| import type { OrchestrationMode } from '@maka/core/orchestration'; |
| import type { PermissionMode } from '@maka/core/permission'; |
| |
| import { mergeShellRunUpdate } from '@maka/core/shell-run-result'; |
| import { isActiveShellRunStatus } from '@maka/core/shell-run'; |
| import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; |
| import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; |
| import type { ThinkingLevel } from '@maka/core/model-thinking'; |
| import type { SkillInvocationResult } from '@maka/core/skill-invocation'; |
| import type { UserQuestionResponse } from '@maka/core/user-question'; |
| import type { InteractionFormResponse } from '@maka/core/interaction'; |
| import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; |
| import { isRuntimeHostTerminalTurn as isTerminalTurn } from '@maka/runtime-host/adapter'; |
| import type { DirectRequestOperationKey, RuntimeHostConnection } from '@maka/runtime-host/client'; |
| import { |
| projectSessionCatalogSummary, |
| readRuntimeHostResources, |
| readRuntimeHostSessions, |
| RuntimeHostOperationError, |
| RuntimeHostRequestInterruptedError, |
| } from '@maka/runtime-host/client'; |
| import { |
| InteractionPendingSnapshot, |
| OperationInput, |
| OperationOutput, |
| SessionCatalogItem, |
| SessionCatalogProjection, |
| SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, |
| WorkspaceTarget, |
| type GoalControlAction, |
| type GoalProjection, |
| type SessionContinuitySnapshot, |
| type SessionDomainChangedFrame, |
| type TurnResumeParkReason, |
| } from '@maka/runtime-host/protocol'; |
| import { RuntimeHostSessionChannel } from './runtime-host-session-channel.js'; |
| import type { RuntimeHostSessionChannelOpenResult } from './runtime-host-session-channel.js'; |
| import { |
| getRuntimeHostSession, |
| requireRuntimeHostSessionProjection as requireSession, |
| updateRuntimeHostSession, |
| } from './runtime-host-session-update.js'; |
| import type { |
| InspectCwdChanges, |
| MakaAttachedSessionTurn, |
| MakaSideConversationCloseResult, |
| MakaSideConversationOpenResult, |
| MakaSideConversationParentStatus, |
| MakaRetractedMessages, |
| MakaPreparePromptOptions, |
| MakaPreparedSessionTurn, |
| MakaSessionDriver, |
| MakaSessionMoveResult, |
| MakaSessionRewindResult, |
| MakaSessionSwitchOptions, |
| MakaSessionSwitchResult, |
| MakaTranscriptReplacementReason, |
| MakaSubmitMessageOptions, |
| CreateSessionRequest, |
| RewindTarget, |
| SessionResumeAvailability, |
| } from './session-driver.js'; |
| import { |
| inspectSessionResumeAvailability, |
| skillInvocationBlockedMessage, |
| } from './session-driver.js'; |
| import { |
| cwdRank, |
| firstLine, |
| inspectGitCwdChanges, |
| resolveMoveCwd, |
| } from './session-driver-policy.js'; |
| const decodeStoredMessage = (value: unknown): StoredMessage => |
| decodePersistedStoredMessage(markPersisted<StoredMessage>(value)); |
| const MAX_CATALOG_ATTEMPTS = 3; |
| |
| /** |
| * The host declined to start a safe-boundary continuation and explained why. |
| * `reason` is the durable park reason from the `turn.resume` protocol, so |
| * surfaces can tell "nothing to resume" from a real failure. |
| */ |
| export class SafeBoundaryResumeParkedError extends Error { |
| readonly reason: TurnResumeParkReason; |
| |
| constructor(reason: TurnResumeParkReason) { |
| super(`Safe-boundary resume parked: ${reason}`); |
| this.name = 'SafeBoundaryResumeParkedError'; |
| this.reason = reason; |
| } |
| } |
| |
| /** Optimistic-control retries for goal pause/resume/clear (mirrors the desktop client). */ |
| const GOAL_CONTROL_MAX_ATTEMPTS = 3; |
| |
| export interface RuntimeHostMakaSessionDriverInput { |
| connection: RuntimeHostSessionDriverConnection; |
| cwd: string; |
| workspace?: WorkspaceTarget; |
| llmConnectionId?: string; |
| llmConnectionSlug: string; |
| model: string; |
| /** |
| * The Host's configured chat default at launch, for display only. |
| * |
| * It is never sent on create — omitting the field is what lets the Host stay |
| * the authority — but a client that shows "the mode the next Session will |
| * start in" needs a value before any Session exists. |
| */ |
| prospectivePermissionMode?: PermissionMode; |
| orchestrationMode?: OrchestrationMode; |
| newId?: () => string; |
| now?: () => number; |
| inspectCwdChanges?: InspectCwdChanges; |
| executionLocation?: { readonly kind: 'client_path' } | { readonly kind: 'host' }; |
| /** Client-local durable lease parent for temporary TUI conversation copies. */ |
| sessionCopyCleanupRoot?: string; |
| /** Process-incarnation owner for temporary TUI conversation copies. */ |
| sessionCopyCleanupOwner?: ProcessLifetimeOwner; |
| } |
| |
| type RuntimeHostSessionDriverConnection = Pick< |
| RuntimeHostConnection, |
| 'rootId' | 'hostEpoch' | 'openSessionSubscription' | 'request' |
| >; |
| |
| export interface RuntimeHostMakaSessionDriver extends MakaSessionDriver { |
| createSession(input: CreateSessionRequest): Promise<SessionSummary>; |
| readMessages(): Promise<StoredMessage[]>; |
| resumeLatest(): AsyncIterable<SessionEvent>; |
| subscribePendingInteractions(listener: (pending: InteractionPendingSnapshot) => void): () => void; |
| subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void; |
| subscribeResolvedInteractions( |
| listener: (sessionId: string, requestId: string) => void, |
| ): () => void; |
| subscribeTranscriptReplacements( |
| listener: ( |
| sessionId: string, |
| turnId: string, |
| messages: readonly StoredMessage[], |
| reason: MakaTranscriptReplacementReason, |
| ) => void, |
| ): () => void; |
| listShellRunUpdates(sessionId: string): Promise<ShellRunUpdate[]>; |
| subscribeShellRunUpdates(listener: (update: ShellRunUpdate) => void): () => void; |
| recoverSideConversations(): Promise<void>; |
| cleanupOwnedSideConversations(): Promise<void>; |
| } |
| |
| export function createRuntimeHostMakaSessionDriver( |
| input: RuntimeHostMakaSessionDriverInput, |
| ): RuntimeHostMakaSessionDriver { |
| return new RuntimeHostMakaSessionDriverImpl(input); |
| } |
| |
| class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { |
| readonly #connection: RuntimeHostSessionDriverConnection; |
| readonly #newId: () => string; |
| readonly #now: () => number; |
| readonly #inspectCwdChanges: InspectCwdChanges; |
| readonly #executionLocation: NonNullable<RuntimeHostMakaSessionDriverInput['executionLocation']>; |
| readonly #sessionCopyCleanup: SessionCopyCleanupAuthority | undefined; |
| readonly moveSession: MakaSessionDriver['moveSession']; |
| #sessionId: string | null = null; |
| #workspace: { target?: WorkspaceTarget; hostCwd: string }; |
| #model: string; |
| #llmConnectionId: string | undefined; |
| #llmConnectionSlug: string; |
| #thinkingLevel: ThinkingLevel | undefined; |
| // What a Session created right now would start in, for display only. Never |
| // sent on create: an omitted field is what makes the Host's `chatDefaults` |
| // the authority. Refreshed on `/new` because that default can change — and |
| // showing the previous Session's mode there is the one direction that can |
| // report Auto while the Host creates with full access. |
| #prospectivePermissionMode: PermissionMode | undefined; |
| // The user's explicit choice for the Session being created, before it |
| // exists. Cleared by `startNewSession` so a previous Session's elevation |
| // cannot leak into a fresh one (#3020). |
| #permissionMode: PermissionMode | undefined; |
| #activeBoundaryDisplayMode: PermissionMode | undefined; |
| #orchestrationMode: OrchestrationMode; |
| #channel: RuntimeHostSessionChannel | undefined; |
| #hiddenTranscriptThroughTurnId: string | undefined; |
| #channelOpening: { sessionId: string; promise: Promise<RuntimeHostSessionChannel> } | undefined; |
| readonly #startedTurnReattachTails = new Map<number, Promise<void>>(); |
| #sessionGeneration = 0; |
| #channelGeneration = 0; |
| #transcriptRefreshSequence = 0; |
| readonly #startedTurnListeners = new Set<(turn: MakaAttachedSessionTurn) => void>(); |
| readonly #goalListeners = new Set<(goal: GoalProjection | null) => void>(); |
| readonly #todoChangeListeners = new Set<(sessionId: string) => void>(); |
| readonly #pendingInteractionListeners = new Set<(pending: InteractionPendingSnapshot) => void>(); |
| readonly #claimedTurnIds = new Set<string>(); |
| readonly #shellRunListeners = new Set<(update: ShellRunUpdate) => void>(); |
| readonly #activeUserCommands = new Map< |
| string, |
| { readonly sessionId: string; readonly commandId: string } |
| >(); |
| readonly #userCommandStartBarriers = new Set<Promise<void>>(); |
| #userCommandStopGeneration = 0; |
| #userCommandStopsPending = 0; |
| #userCommandStopTail = Promise.resolve(); |
| readonly #resolvedInteractionListeners = new Set< |
| (sessionId: string, requestId: string) => void |
| >(); |
| readonly #transcriptListeners = new Set< |
| ( |
| sessionId: string, |
| turnId: string, |
| messages: readonly StoredMessage[], |
| reason: MakaTranscriptReplacementReason, |
| ) => void |
| >(); |
| |
| constructor(input: RuntimeHostMakaSessionDriverInput) { |
| this.#connection = input.connection; |
| this.#newId = input.newId ?? randomUUID; |
| this.#now = input.now ?? Date.now; |
| this.#inspectCwdChanges = input.inspectCwdChanges ?? inspectGitCwdChanges; |
| this.#executionLocation = input.executionLocation ?? { kind: 'client_path' }; |
| this.#sessionCopyCleanup = input.sessionCopyCleanupRoot |
| ? createSessionCopyCleanupAuthority({ |
| // rootId is the durable Host authority identity. HostEpoch would |
| // strand cleanup across an ordinary Host restart, while one shared |
| // Client root lets a different Host erase this Host's recovery lease. |
| workspaceRoot: join(input.sessionCopyCleanupRoot, this.#connection.rootId), |
| removeSession: (sessionId) => this.#removeSessionCopy(sessionId), |
| resumeSessionCopy: (creation) => this.#resumeSessionCopy(creation), |
| processId: `tui:${process.pid}`, |
| isOwnerProcessActive: isTuiProcessActive, |
| processLifetimeOwner: input.sessionCopyCleanupOwner, |
| }) |
| : undefined; |
| this.moveSession = |
| this.#executionLocation.kind === 'host' ? undefined : (cwd) => this.#moveSession(cwd); |
| this.#workspace = { |
| ...(input.workspace |
| ? { target: input.workspace } |
| : this.#executionLocation.kind === 'client_path' |
| ? { target: { kind: 'host_path' as const, path: input.cwd } } |
| : {}), |
| hostCwd: input.cwd, |
| }; |
| this.#model = input.model; |
| this.#llmConnectionId = input.llmConnectionId; |
| this.#llmConnectionSlug = input.llmConnectionSlug; |
| this.#prospectivePermissionMode = input.prospectivePermissionMode; |
| this.#orchestrationMode = input.orchestrationMode ?? 'default'; |
| } |
| |
| readMessages(): Promise<StoredMessage[]> { |
| const hiddenThroughTurnId = this.#hiddenTranscriptThroughTurnId; |
| return loadCurrentMessages(this.#connection, this.#requireSession('read messages')).then( |
| (messages) => visibleTranscriptMessages(messages, hiddenThroughTurnId), |
| ); |
| } |
| |
| async createSession(input: CreateSessionRequest): Promise<SessionSummary> { |
| if (this.#sessionId) throw new Error('Cannot create a Session while another is active.'); |
| if (!input.model) throw new Error('Runtime Host Session creation requires an explicit model'); |
| this.#workspace = { |
| target: workspaceTargetForCreate(this.#workspace, input, this.#executionLocation), |
| hostCwd: input.cwd, |
| }; |
| if (input.llmConnectionId) this.#llmConnectionId = input.llmConnectionId; |
| this.#llmConnectionSlug = input.llmConnectionSlug; |
| this.#model = input.model; |
| this.#thinkingLevel = input.thinkingLevel; |
| // An omitted mode stays omitted: the Host applies its configured default. |
| // Substituting a literal `ask` here would make the CLI a second authority |
| // over the starting boundary and silently override that default. |
| this.#permissionMode = input.permissionMode; |
| const session = await this.#createSession(input.name ?? DEFAULT_SESSION_NAME); |
| return projectSessionCatalogSummary(session); |
| } |
| |
| async listSessions(): Promise<SessionSummary[]> { |
| const sessions = (await readRuntimeHostSessions(this.#connection)) |
| .flatMap(representableSession) |
| .filter((session) => !isSideConversationSession(session.labels)) |
| .map(projectSessionCatalogSummary); |
| if (this.#executionLocation.kind === 'host') return sessions; |
| return sessions |
| .map((session, index) => ({ session, index })) |
| .sort((left, right) => { |
| const cwdDelta = |
| cwdRank(left.session, this.#workspace.hostCwd) - |
| cwdRank(right.session, this.#workspace.hostCwd); |
| return cwdDelta !== 0 ? cwdDelta : left.index - right.index; |
| }) |
| .map(({ session }) => session); |
| } |
| |
| getSessionResumeAvailability(session: SessionSummary): Promise<SessionResumeAvailability> { |
| return inspectRuntimeHostSessionResumeAvailability(session, this.#executionLocation); |
| } |
| |
| async preparePrompt( |
| prompt: string, |
| options: MakaPreparePromptOptions = {}, |
| ): Promise<MakaPreparedSessionTurn> { |
| const sessionId = await this.#ensureSession(); |
| const sessionGeneration = this.#sessionGeneration; |
| const configuration = await this.#loadConfiguration(sessionId); |
| this.#assertCurrentSession(sessionId, sessionGeneration); |
| const channel = await this.#ensureChannel(sessionId); |
| this.#assertCurrentSession(sessionId, sessionGeneration); |
| this.#adoptLoadedConfiguration(configuration); |
| const turnId = options.turnId ?? this.#newId(); |
| this.#claimedTurnIds.add(turnId); |
| const events = channel.eventsForTurn(turnId); |
| const modelText = options.modelText ?? prompt; |
| try { |
| const startInput = { |
| sessionId, |
| turnId, |
| content: { |
| text: modelText, |
| ...(modelText === prompt ? {} : { displayText: prompt }), |
| }, |
| ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), |
| ...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}), |
| }; |
| const result = await this.#connection.request('turn.start', startInput); |
| if (result.kind === 'blocked') { |
| throw new Error(skillInvocationBlockedMessage(result.skillInvocation)); |
| } |
| const started = result.turn; |
| const skillInvocation = |
| result.skillInvocation.loaded.length > 0 || result.skillInvocation.failed.length > 0 |
| ? result.skillInvocation |
| : undefined; |
| return { |
| sessionId, |
| turnId, |
| runId: started.runId, |
| events, |
| summary: projectSessionCatalogSummary(configuration.session), |
| ...(skillInvocation ? { skillInvocation } : {}), |
| }; |
| } catch (error) { |
| channel.failTurn(turnId, error); |
| throw error; |
| } |
| } |
| |
| async runUserCommand(command: string): Promise<{ |
| commandId: string; |
| result: ShellRunSnapshotResult; |
| takeRacedUpdate(): ShellRunUpdate['result'] | undefined; |
| }> { |
| const stopGeneration = this.#userCommandStopGeneration; |
| const stopAlreadyPending = this.#userCommandStopsPending > 0; |
| let releaseStartBarrier: (() => void) | undefined; |
| const startBarrier = new Promise<void>((resolve) => { |
| releaseStartBarrier = resolve; |
| }); |
| this.#userCommandStartBarriers.add(startBarrier); |
| let capture: ((update: ShellRunUpdate) => void) | undefined; |
| try { |
| const sessionId = await this.#ensureSession(); |
| await this.#ensureChannel(sessionId); |
| const commandId = `user-command-${this.#newId()}`; |
| let latest: ShellRunUpdate | undefined; |
| capture = (update: ShellRunUpdate) => { |
| if (update.sessionId === sessionId && update.sourceToolCallId === commandId) { |
| latest = mergeShellRunUpdate(latest, update, 'cli.user-command-start').update; |
| } |
| }; |
| this.#shellRunListeners.add(capture); |
| const started = await this.#request('runtime.resource.start', { |
| sessionId, |
| launchId: commandId, |
| command, |
| }); |
| if (started.resource.mode !== 'pipes') { |
| throw new Error('Runtime Host did not start a one-shot user command'); |
| } |
| const newestResult = |
| latest && latest.result.revision > started.resource.revision |
| ? latest.result |
| : started.resource; |
| if (isActiveShellRunStatus(newestResult.status)) { |
| const owner = { sessionId, commandId }; |
| this.#activeUserCommands.set(newestResult.ref, owner); |
| if ( |
| stopAlreadyPending || |
| this.#userCommandStopGeneration !== stopGeneration || |
| this.#userCommandStopsPending > 0 |
| ) { |
| await this.#stopUserCommand(newestResult.ref, owner); |
| } |
| } |
| let activated = false; |
| return { |
| commandId, |
| result: started.resource, |
| takeRacedUpdate: () => { |
| if (activated) return undefined; |
| activated = true; |
| this.#shellRunListeners.delete(capture!); |
| return latest && latest.result.revision > started.resource.revision |
| ? latest.result |
| : undefined; |
| }, |
| }; |
| } catch (error) { |
| if (capture) this.#shellRunListeners.delete(capture); |
| throw error; |
| } finally { |
| releaseStartBarrier?.(); |
| this.#userCommandStartBarriers.delete(startBarrier); |
| } |
| } |
| |
| stopUserCommands(): Promise<void> { |
| this.#userCommandStopGeneration += 1; |
| this.#userCommandStopsPending += 1; |
| const stop = this.#userCommandStopTail.then(async () => { |
| try { |
| await Promise.all([...this.#userCommandStartBarriers]); |
| await Promise.all( |
| [...this.#activeUserCommands].map(([ref, owner]) => this.#stopUserCommand(ref, owner)), |
| ); |
| } finally { |
| this.#userCommandStopsPending -= 1; |
| } |
| }); |
| this.#userCommandStopTail = stop.catch(() => undefined); |
| return stop; |
| } |
| |
| async *compactSession(): AsyncIterable<SessionEvent> { |
| const sessionId = this.#requireSession('compact'); |
| const channel = await this.#ensureChannel(sessionId); |
| const turnId = this.#newId(); |
| this.#claimedTurnIds.add(turnId); |
| const events = channel.eventsForTurn(turnId); |
| try { |
| await this.#request('context.compact', { sessionId, turnId }); |
| } catch (error) { |
| channel.failTurn(turnId, error); |
| throw error; |
| } |
| yield* events; |
| } |
| |
| async *resumeLatest(): AsyncIterable<SessionEvent> { |
| const sessionId = this.#requireSession('resume'); |
| const plan = await this.#request('turn.resume.query', { sessionId }); |
| if (plan.disposition !== 'ready') { |
| throw new SafeBoundaryResumeParkedError(plan.reason); |
| } |
| const channel = await this.#ensureChannel(sessionId); |
| const turnId = this.#newId(); |
| this.#claimedTurnIds.add(turnId); |
| const events = channel.eventsForTurn(turnId); |
| try { |
| const result = await this.#request('turn.resume.start', { |
| sessionId, |
| turnId, |
| sourceRunId: plan.sourceRunId, |
| sourceRuntimeEventHighWater: plan.sourceRuntimeEventHighWater, |
| }); |
| if (result.kind !== 'started') { |
| channel.failTurn(turnId, new SafeBoundaryResumeParkedError(result.plan.reason)); |
| } |
| } catch (error) { |
| channel.failTurn(turnId, error); |
| throw error; |
| } |
| yield* events; |
| } |
| |
| submitMessage( |
| text: string, |
| options: MakaSubmitMessageOptions, |
| ): Promise<OperationOutput<'turn.message.submit'> | undefined> { |
| return this.#admit(() => this.#submitMessage(text, options)); |
| } |
| |
| async #submitMessage( |
| text: string, |
| options: MakaSubmitMessageOptions, |
| ): Promise<OperationOutput<'turn.message.submit'> | undefined> { |
| const sessionId = await this.#ensureSession(); |
| const sessionGeneration = this.#sessionGeneration; |
| const configuration = await this.#loadConfiguration(sessionId); |
| this.#assertCurrentSession(sessionId, sessionGeneration); |
| await this.#ensureChannel(sessionId); |
| this.#assertCurrentSession(sessionId, sessionGeneration); |
| this.#adoptLoadedConfiguration(configuration); |
| const modelText = options.modelText ?? text; |
| try { |
| return await this.#request('turn.message.submit', { |
| originHostEpoch: this.#connection.hostEpoch, |
| sessionId, |
| messageId: options.messageId, |
| content: { |
| text: modelText, |
| ...(modelText === text ? {} : { displayText: text }), |
| }, |
| placement: options.placement, |
| ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), |
| }); |
| } catch (error) { |
| if ( |
| (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') || |
| (error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched') |
| ) { |
| return undefined; |
| } |
| throw error; |
| } |
| } |
| |
| async queryCancelledMessages( |
| messageIds: readonly string[], |
| ): Promise<OperationOutput<'turn.message.query'>> { |
| const sessionId = await this.#ensureSession(); |
| return this.#request('turn.message.query', { sessionId, messageIds }); |
| } |
| |
| async queryTodo(sessionId: string): Promise<{ sessionId: string; items: SessionTodoItem[] }> { |
| const currentSessionId = this.#requireSession('query Todo'); |
| if (sessionId !== currentSessionId) { |
| throw new Error(`Cannot query Todo for a non-current Session: ${sessionId}`); |
| } |
| const sessionGeneration = this.#sessionGeneration; |
| const result = await this.#request('session.todo.query', { sessionId }); |
| this.#assertCurrentSession(sessionId, sessionGeneration); |
| if (result.sessionId !== sessionId) { |
| throw new Error(`Runtime Host returned Todo for an unexpected Session: ${result.sessionId}`); |
| } |
| return { |
| sessionId, |
| items: projectSessionTodoItemsForDisplay(result.items), |
| }; |
| } |
| |
| async retractQueued(): Promise<MakaRetractedMessages> { |
| if (!this.#sessionId) return { text: '', messageIds: [] }; |
| const result = await this.#request('queue.retract', { |
| originHostEpoch: this.#connection.hostEpoch, |
| sessionId: this.#sessionId, |
| retractId: this.#newId(), |
| }); |
| return { |
| text: result.retracted.map((entry) => entry.content.text).join('\n\n'), |
| messageIds: result.retracted.map((entry) => entry.messageId), |
| }; |
| } |
| |
| async respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise<void> { |
| const sessionId = this.#requireSession('respond to permission'); |
| const pending = this.#channel?.pendingInteraction(response.requestId); |
| const answered = await this.#request('interaction.answer', { |
| sessionId, |
| interactionId: response.requestId, |
| answer: { kind: 'sandbox_boundary', decision: response.decision }, |
| }); |
| if (pending) this.#channel?.publishInteractionAnswer(answered, pending); |
| } |
| |
| async respondToUserQuestion(response: UserQuestionResponse): Promise<void> { |
| const sessionId = this.#requireSession('respond to a user question'); |
| const pending = this.#channel?.pendingInteraction(response.requestId); |
| const answered = await this.#request('interaction.answer', { |
| sessionId, |
| interactionId: response.requestId, |
| answer: { kind: 'question', answers: response.answers }, |
| }); |
| if (pending) this.#channel?.publishInteractionAnswer(answered, pending); |
| } |
| |
| async respondToUserForm(response: InteractionFormResponse): Promise<void> { |
| const sessionId = this.#requireSession('respond to a user form'); |
| const pending = this.#channel?.pendingInteraction(response.requestId); |
| if (pending && pending.request.kind !== 'form') { |
| throw new Error('Interaction is not a form request'); |
| } |
| const answered = await this.#request('interaction.answer', { |
| sessionId, |
| interactionId: response.requestId, |
| answer: |
| response.action === 'accept' |
| ? { kind: 'form', action: 'accept', values: response.values } |
| : { kind: 'form', action: response.action }, |
| }); |
| if (pending) this.#channel?.publishInteractionAnswer(answered, pending); |
| } |
| |
| setModel(model: string, connectionSlug?: string, connectionId?: string): Promise<void> { |
| return this.#admit(() => this.#setModel(model, connectionSlug, connectionId)); |
| } |
| |
| async #setModel(model: string, connectionSlug?: string, connectionId?: string): Promise<void> { |
| if (connectionSlug !== undefined && connectionId === undefined) { |
| throw new Error('Cross-account model selection requires an exact Connection identity'); |
| } |
| const nextConnectionId = connectionId ?? this.#llmConnectionId; |
| const nextConnectionSlug = connectionSlug ?? this.#llmConnectionSlug; |
| if (!nextConnectionId) { |
| throw new Error('Model selection requires an exact Connection identity'); |
| } |
| if (this.#sessionId) { |
| const session = await this.#updateConfiguration(this.#sessionId, { |
| modelTarget: { |
| kind: 'explicit', |
| connectionId: nextConnectionId, |
| connectionSlug: nextConnectionSlug, |
| model, |
| }, |
| thinkingLevel: null, |
| }); |
| this.#adoptConfiguration(session); |
| return; |
| } |
| this.#model = model; |
| this.#llmConnectionId = nextConnectionId; |
| this.#llmConnectionSlug = nextConnectionSlug; |
| this.#thinkingLevel = undefined; |
| } |
| |
| setThinkingLevel(level: ThinkingLevel | undefined): Promise<void> { |
| return this.#admit(() => this.#setThinkingLevel(level)); |
| } |
| |
| async #setThinkingLevel(level: ThinkingLevel | undefined): Promise<void> { |
| if (this.#sessionId) { |
| this.#adoptConfiguration( |
| await this.#updateConfiguration(this.#sessionId, { thinkingLevel: level ?? null }), |
| ); |
| return; |
| } |
| this.#thinkingLevel = level; |
| } |
| |
| setPermissionMode(mode: PermissionMode): Promise<void> { |
| return this.#admit(() => this.#setPermissionMode(mode)); |
| } |
| |
| async #setPermissionMode(mode: PermissionMode): Promise<void> { |
| if (this.#sessionId) { |
| const session = await this.#updateConfiguration(this.#sessionId, { permissionMode: mode }); |
| this.#permissionMode = session.permissionMode; |
| const boundary = await this.#request('session.execution_boundary.query', { |
| sessionId: this.#sessionId, |
| }); |
| this.#activeBoundaryDisplayMode = executionBoundaryDisplayMode(boundary); |
| return; |
| } |
| this.#permissionMode = mode; |
| } |
| |
| setOrchestrationMode(mode: OrchestrationMode): Promise<void> { |
| return this.#admit(() => this.#setOrchestrationMode(mode)); |
| } |
| |
| async #setOrchestrationMode(mode: OrchestrationMode): Promise<void> { |
| if (this.#sessionId) { |
| this.#adoptConfiguration( |
| await this.#updateConfiguration(this.#sessionId, { orchestrationMode: mode }), |
| ); |
| return; |
| } |
| this.#orchestrationMode = mode; |
| } |
| |
| async renameSession(name: string): Promise<string> { |
| const sessionId = this.#requireSession('rename'); |
| const session = await updateRuntimeHostSession( |
| this.#connection, |
| sessionId, |
| (current) => |
| this.#request('session.metadata.update', { |
| sessionId, |
| expectedRevision: current.revision, |
| patch: { name }, |
| }), |
| { operation: 'session.metadata.update' }, |
| ); |
| return session.name; |
| } |
| |
| async #moveSession(rawCwd: string): Promise<MakaSessionMoveResult> { |
| const sessionId = this.#requireSession('move'); |
| const nextCwd = await resolveMoveCwd(rawCwd, this.#workspace.hostCwd); |
| const previousCwd = this.#workspace.hostCwd; |
| if (nextCwd === previousCwd) { |
| return { previousCwd, cwd: nextCwd, changed: false, oldCwdDirty: false }; |
| } |
| const oldCwdDirty = await this.#inspectCwdChanges(previousCwd).catch(() => undefined); |
| const session = await this.#commitCwdRelocation(sessionId, nextCwd); |
| this.#workspace = session.workspace; |
| return { previousCwd, cwd: this.#workspace.hostCwd, changed: true, oldCwdDirty }; |
| } |
| |
| async switchSession( |
| sessionId: string, |
| options: MakaSessionSwitchOptions = {}, |
| ): Promise<MakaSessionSwitchResult> { |
| if (options.relocateCwd !== undefined && this.#executionLocation.kind === 'host') { |
| throw new Error('A remote Runtime Host Session cannot be relocated by this Client'); |
| } |
| let session = await getRuntimeHostSession(this.#connection, sessionId); |
| if (!session) throw new Error(`Session not found: ${sessionId}`); |
| let summary = projectSessionCatalogSummary(session); |
| if (options.relocateCwd === undefined) { |
| await assertSessionResumeAvailable(summary, this.#executionLocation); |
| } |
| const boundary = await this.#request('session.execution_boundary.query', { sessionId }); |
| if (boundary.kind === 'external') { |
| throw new Error( |
| `Cannot resume externally isolated session ${sessionId} outside its owning harness.`, |
| ); |
| } |
| // Leaving the current Session must not orphan its live user commands: |
| // the switch replaces the transcript, so their cards and the Ctrl+C stop |
| // affordance would disappear while the commands keep running. Await the |
| // start-barrier-aware stop path before changing Session identity so an |
| // in-flight start cannot land after the switch (#3210). This runs before |
| // the durable cwd relocation below: if a stop rejects, the switch aborts |
| // with nothing committed rather than stranding a half-switched Session. |
| await this.stopUserCommands(); |
| let relocation: MakaSessionMoveResult | undefined; |
| if (options.relocateCwd !== undefined) { |
| const nextCwd = await resolveMoveCwd(options.relocateCwd, this.#workspace.hostCwd); |
| const previousCwd = session.workspace.hostCwd; |
| if (nextCwd === previousCwd) { |
| relocation = { previousCwd, cwd: nextCwd, changed: false, oldCwdDirty: false }; |
| } else { |
| const oldCwdDirty = await this.#inspectCwdChanges(previousCwd).catch(() => undefined); |
| session = await this.#commitCwdRelocation(sessionId, nextCwd); |
| relocation = { |
| previousCwd, |
| cwd: session.workspace.hostCwd, |
| changed: true, |
| oldCwdDirty, |
| }; |
| } |
| summary = projectSessionCatalogSummary(session); |
| await assertSessionResumeAvailable(summary, this.#executionLocation); |
| } |
| const expectedChannelGeneration = this.#channelGeneration; |
| const nextSessionGeneration = this.#sessionGeneration + 1; |
| const opened = await this.#openSessionChannel(sessionId, nextSessionGeneration); |
| if (this.#channelGeneration !== expectedChannelGeneration) { |
| await opened.channel.close().catch(() => undefined); |
| throw new Error(`Session changed while opening Runtime Host channel: ${sessionId}`); |
| } |
| this.#sessionGeneration = nextSessionGeneration; |
| this.#channelGeneration += 1; |
| this.#sessionId = sessionId; |
| await this.#replaceChannel(opened.channel); |
| this.#workspace = session.workspace; |
| this.#adoptConfiguration(session); |
| this.#hiddenTranscriptThroughTurnId = isSideConversationSession(session.labels) |
| ? session.branchOfTurnId |
| : undefined; |
| this.#activeBoundaryDisplayMode = executionBoundaryDisplayMode(boundary); |
| const attachedTurnId = opened.attachedTurnId ?? opened.channel.firstObservedTurnId; |
| opened.channel.activate(attachedTurnId); |
| return { |
| summary, |
| messages: visibleTranscriptMessages(opened.messages, this.#hiddenTranscriptThroughTurnId), |
| ...(relocation === undefined ? {} : { relocation }), |
| ...(attachedTurnId |
| ? { |
| activeTurn: { |
| sessionId, |
| turnId: attachedTurnId, |
| ...(opened.channel.snapshot.rootTurn?.turnId === attachedTurnId |
| ? { runId: opened.channel.snapshot.rootTurn.runId } |
| : {}), |
| events: opened.channel.eventsForTurn(attachedTurnId), |
| }, |
| } |
| : {}), |
| }; |
| } |
| |
| #commitCwdRelocation(sessionId: string, cwd: string): Promise<SessionCatalogProjection> { |
| return updateRuntimeHostSession( |
| this.#connection, |
| sessionId, |
| (current) => |
| this.#request('session.workspace.relocate', { |
| sessionId, |
| expectedRevision: current.revision, |
| workspace: { kind: 'host_path', path: cwd }, |
| }), |
| { operation: 'session.workspace.relocate' }, |
| ); |
| } |
| |
| async listRewindTargets(): Promise<RewindTarget[]> { |
| if (!this.#sessionId) return []; |
| const messages = await this.readMessages(); |
| const seenTurnIds = new Set<string>(); |
| const targets: RewindTarget[] = []; |
| for (const message of messages) { |
| if (message.type !== 'user' || seenTurnIds.has(message.turnId)) continue; |
| seenTurnIds.add(message.turnId); |
| if (message.origin) continue; |
| targets.push({ turnId: message.turnId, label: firstLine(userFacingText(message)) }); |
| } |
| return targets.reverse(); |
| } |
| |
| async rewindToTurn(turnId: string): Promise<MakaSessionRewindResult> { |
| const sourceSessionId = this.#requireSession('rewind'); |
| const messages = await this.readMessages(); |
| const promptMessage = messages.find( |
| (message): message is Extract<StoredMessage, { type: 'user' }> => |
| message.type === 'user' && message.turnId === turnId, |
| ); |
| if (!promptMessage) throw new Error(`Cannot rewind to turn ${turnId}: no user prompt.`); |
| if (promptMessage.origin) { |
| throw new Error(`Cannot rewind to turn ${turnId}: Host-triggered prompts are read-only.`); |
| } |
| const unsupported = |
| (promptMessage.quotes?.length ?? 0) > 0 |
| ? 'rewind_unsupported_quotes' |
| : (promptMessage.attachments?.length ?? 0) > 0 |
| ? 'rewind_unsupported_attachments' |
| : (promptMessage.directoryReferences?.length ?? 0) > 0 |
| ? 'rewind_unsupported_directory_references' |
| : null; |
| if (unsupported) { |
| // Refilling only the human-facing text would silently drop the turn's |
| // structured context from the replacement submit (#5109). Fail closed |
| // until the TUI can carry it. The machine code lets the runner render |
| // a localized notice naming the carrier; the message text is the |
| // depth-of-defence fallback and deliberately promises nothing about |
| // other surfaces. |
| const error = new Error( |
| `Cannot rewind to turn ${turnId}: it carries structured context the TUI cannot restore into the replacement prompt.`, |
| ) as Error & { code?: string }; |
| error.code = unsupported; |
| throw error; |
| } |
| const targetSessionId = this.#newId(); |
| for (let attempt = 0; attempt < MAX_CATALOG_ATTEMPTS; attempt += 1) { |
| const current = await getRuntimeHostSession(this.#connection, sourceSessionId); |
| if (!current) throw new Error(`Session not found: ${sourceSessionId}`); |
| const result = await this.#request('session.revision.create', { |
| sourceSessionId, |
| targetSessionId, |
| sourceTurnId: turnId, |
| expectedSourceRevision: current.revision, |
| }); |
| if (result.kind === 'committed') { |
| return { |
| ...(await this.switchSession(requireSession(result.session).id)), |
| prompt: userFacingText(promptMessage), |
| }; |
| } |
| } |
| throw new Error(`Session kept changing while rewinding: ${sourceSessionId}`); |
| } |
| |
| async recoverSideConversations(): Promise<void> { |
| await this.#requireSessionCopyCleanup().recover(); |
| } |
| |
| async openSideConversation(): Promise<MakaSideConversationOpenResult> { |
| const parentSessionId = this.#requireSession('open a side conversation'); |
| const messages = await loadCurrentMessages(this.#connection, parentSessionId); |
| // Absent when the parent has no completed turn yet: fork with an empty |
| // context instead of failing, matching the desktop side conversation. |
| const sourceTurnId = deriveTurnRecords(messages) |
| .reverse() |
| .find((turn) => turn.status === 'completed')?.turnId; |
| const sideSessionId = this.#newId(); |
| const cleanup = this.#requireSessionCopyCleanup(); |
| await cleanup.ownCreation( |
| { |
| sessionId: sideSessionId, |
| kind: 'branch', |
| sourceSessionId: parentSessionId, |
| ...(sourceTurnId === undefined ? {} : { sourceTurnId }), |
| intent: 'side_conversation', |
| ownerId: 'tui-side', |
| }, |
| () => |
| this.#resumeSessionCopy({ |
| sessionId: sideSessionId, |
| kind: 'branch', |
| sourceSessionId: parentSessionId, |
| ...(sourceTurnId === undefined ? {} : { sourceTurnId }), |
| intent: 'side_conversation', |
| }), |
| ); |
| try { |
| return { |
| ...(await this.switchSession(sideSessionId)), |
| parentSessionId, |
| sideSessionId, |
| }; |
| } catch (error) { |
| await cleanup.schedule(sideSessionId).catch(() => undefined); |
| throw error; |
| } |
| } |
| |
| async closeSideConversation( |
| sideSessionId: string, |
| parentSessionId: string, |
| ): Promise<MakaSideConversationCloseResult> { |
| if (this.#sessionId !== sideSessionId) { |
| throw new Error('The active Session is not the side conversation being closed.'); |
| } |
| const parent = await this.switchSession(parentSessionId); |
| const cleanup = await this.#cleanupSideConversation(sideSessionId); |
| return { ...parent, cleanup }; |
| } |
| |
| async observeSideConversationParent( |
| parentSessionId: string, |
| listener: (status: MakaSideConversationParentStatus | undefined) => void, |
| ): Promise<() => Promise<void>> { |
| let closed = false; |
| let observedLiveRunId: string | undefined; |
| const drains = new Set<Promise<void>>(); |
| const publish = (snapshot: SessionContinuitySnapshot): void => { |
| if (isLiveSessionTurn(snapshot.rootTurn)) observedLiveRunId = snapshot.rootTurn.runId; |
| if (!closed) listener(sideConversationParentStatus(snapshot, observedLiveRunId)); |
| }; |
| const drain = (turn: MakaPreparedSessionTurn): void => { |
| const task = (async () => { |
| try { |
| for await (const _event of turn.events) { |
| // The observer consumes the channel queue only to keep its Host |
| // projection live; the active Session remains the transcript owner. |
| } |
| } catch { |
| // onFailed clears the user-visible state once recovery is exhausted. |
| } |
| })().finally(() => drains.delete(task)); |
| drains.add(task); |
| }; |
| const opened = await RuntimeHostSessionChannel.open({ |
| connection: this.#connection, |
| sessionId: parentSessionId, |
| now: this.#now, |
| onTurnStarted: drain, |
| onRuntimeResourceChanged: () => undefined, |
| onInteractionPending: () => undefined, |
| onInteractionResolved: () => undefined, |
| onTranscriptSettlement: () => undefined, |
| onTranscriptReplaced: () => undefined, |
| onGoalChanged: () => undefined, |
| onSnapshotChanged: publish, |
| onFailed: () => { |
| if (!closed) listener(undefined); |
| }, |
| onRecovered: () => undefined, |
| }); |
| if (opened.attachedTurnId) { |
| drain({ |
| sessionId: parentSessionId, |
| turnId: opened.attachedTurnId, |
| events: opened.channel.eventsForTurn(opened.attachedTurnId), |
| }); |
| opened.channel.activate(opened.attachedTurnId); |
| } else { |
| opened.channel.activate(); |
| } |
| return async () => { |
| if (closed) return; |
| closed = true; |
| await opened.channel.close(); |
| await Promise.allSettled(drains); |
| }; |
| } |
| |
| async discardSideConversation(sideSessionId: string): Promise<'removed' | 'pending'> { |
| await this.#stopSessionTurn(sideSessionId).catch(() => undefined); |
| return this.#cleanupSideConversation(sideSessionId); |
| } |
| |
| async #cleanupSideConversation(sideSessionId: string): Promise<'removed' | 'pending'> { |
| const cleanup = this.#requireSessionCopyCleanup(); |
| try { |
| await cleanup.cleanup(sideSessionId); |
| return 'removed'; |
| } catch { |
| await cleanup.schedule(sideSessionId).catch(() => undefined); |
| return 'pending'; |
| } |
| } |
| |
| async cleanupOwnedSideConversations(): Promise<void> { |
| await this.#requireSessionCopyCleanup().abandonOwner('tui-side'); |
| } |
| |
| async startNewSession(): Promise<void> { |
| // `/new` replaces the transcript without preserving user-command cards, |
| // so a still-running command would lose both its projection and its |
| // Ctrl+C stop affordance. Await the barrier-aware stop path before any |
| // identity change: if a stop rejects, `/new` aborts with nothing |
| // committed rather than stranding a running command without its card or |
| // stop affordance (#3210 review). |
| await this.stopUserCommands(); |
| this.#sessionGeneration += 1; |
| this.#channelGeneration += 1; |
| this.#sessionId = null; |
| this.#hiddenTranscriptThroughTurnId = undefined; |
| // A fresh Session carries no client claim on its mode: leaving a previous |
| // Session's elevation here would both misreport the mode and create the |
| // next Session with it (#3020). Full access stays an explicit per-session |
| // opt-in; `setPermissionMode` can still raise it before the first prompt |
| // creates the Session. |
| this.#permissionMode = undefined; |
| this.#activeBoundaryDisplayMode = undefined; |
| void this.#refreshProspectivePermissionMode(); |
| void this.#replaceChannel(undefined); |
| } |
| |
| subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { |
| this.#startedTurnListeners.add(listener); |
| return () => this.#startedTurnListeners.delete(listener); |
| } |
| |
| subscribePendingInteractions( |
| listener: (pending: InteractionPendingSnapshot) => void, |
| ): () => void { |
| this.#pendingInteractionListeners.add(listener); |
| return () => this.#pendingInteractionListeners.delete(listener); |
| } |
| |
| subscribeResolvedInteractions( |
| listener: (sessionId: string, requestId: string) => void, |
| ): () => void { |
| this.#resolvedInteractionListeners.add(listener); |
| return () => this.#resolvedInteractionListeners.delete(listener); |
| } |
| |
| subscribeTranscriptReplacements( |
| listener: ( |
| sessionId: string, |
| turnId: string, |
| messages: readonly StoredMessage[], |
| reason: MakaTranscriptReplacementReason, |
| ) => void, |
| ): () => void { |
| this.#transcriptListeners.add(listener); |
| return () => this.#transcriptListeners.delete(listener); |
| } |
| |
| listShellRunUpdates(sessionId: string): Promise<ShellRunUpdate[]> { |
| return readRuntimeHostResources(this.#connection, sessionId); |
| } |
| |
| subscribeShellRunUpdates(listener: (update: ShellRunUpdate) => void): () => void { |
| this.#shellRunListeners.add(listener); |
| return () => this.#shellRunListeners.delete(listener); |
| } |
| |
| async stop(): Promise<void> { |
| const turn = this.#channel?.snapshot.rootTurn; |
| // A user command is not part of the turn, so its stop must never be |
| // reported as a failed turn interrupt: a rejecting runtime.resource.stop |
| // (host draining, transport failure) would otherwise reset the caller's |
| // interrupt affordance even though turn.stop succeeded. Stop the commands |
| // best-effort here — this is also the close authority — while the callers |
| // that own their lifecycle (Ctrl+C, Session switch) await |
| // stopUserCommands() directly and surface its errors themselves (#3210). |
| const stops: Promise<unknown>[] = [this.stopUserCommands().catch(() => undefined)]; |
| if (turn && !isTerminalTurn(turn)) { |
| stops.push( |
| this.#request('turn.stop', { |
| sessionId: turn.sessionId, |
| turnId: turn.turnId, |
| runId: turn.runId, |
| }), |
| ); |
| } |
| const results = await Promise.allSettled(stops); |
| const failed = results.find( |
| (result): result is PromiseRejectedResult => result.status === 'rejected', |
| ); |
| if (failed) throw failed.reason; |
| } |
| |
| getSessionId(): string | null { |
| return this.#sessionId; |
| } |
| |
| getGoal(): GoalProjection | null { |
| // The session subscription's continuity snapshot carries the goal |
| // projection and is folded on every pushed frame, so this read is as |
| // fresh as the host's last broadcast — no RPC, no staleness window. |
| return this.#channel?.snapshot.goal ?? null; |
| } |
| |
| subscribeGoalChanges(listener: (goal: GoalProjection | null) => void): () => void { |
| this.#goalListeners.add(listener); |
| return () => this.#goalListeners.delete(listener); |
| } |
| |
| subscribeTodoChanges(listener: (sessionId: string) => void): () => void { |
| this.#todoChangeListeners.add(listener); |
| return () => this.#todoChangeListeners.delete(listener); |
| } |
| |
| async controlGoal(action: GoalControlAction): Promise<GoalProjection | null> { |
| const sessionId = this.#sessionId; |
| if (!sessionId) return null; |
| let goal = this.getGoal(); |
| if (!goal) return null; |
| // Optimistic concurrency with the same shape as the desktop client's |
| // clearGoal: expectedRevision guards against a concurrent controller, and |
| // an operation_conflict retries against a freshly queried projection — |
| // the pushed snapshot may lag the conflicting mutation by a frame. |
| const goalId = goal.goalId; |
| for (let attempt = 0; attempt < GOAL_CONTROL_MAX_ATTEMPTS; attempt += 1) { |
| try { |
| const result = await this.#request('goal.control', { |
| sessionId, |
| goalId, |
| expectedRevision: goal.revision, |
| action, |
| }); |
| return result.goal; |
| } catch (error) { |
| if (!(error instanceof RuntimeHostOperationError) || error.code !== 'operation_conflict') { |
| throw error; |
| } |
| if (attempt === GOAL_CONTROL_MAX_ATTEMPTS - 1) throw error; |
| const current = (await this.#request('goal.query', { sessionId })).goal; |
| if (!current || current.goalId !== goalId) return null; |
| if (current.revision === goal.revision) { |
| // The host folds invalid transitions into operation_conflict too |
| // ("Goal cannot pause from status paused"). Every accepted transition |
| // bumps the revision, so a conflict at an unchanged revision is a |
| // status refusal, not a race — retrying is futile. Surface the host's |
| // reason instead of a misleading "revision conflict" exhaustion error. |
| throw error; |
| } |
| goal = current; |
| } |
| } |
| throw new Error(`Goal ${action} failed without a result`); |
| } |
| |
| async getContextDiagnostics(): Promise<ContextDiagnostics> { |
| if (!this.#sessionId) return { status: 'unavailable', reason: 'no_completed_request' }; |
| const diagnostics = await this.#request('context.diagnostics.query', { |
| sessionId: this.#sessionId, |
| }); |
| if (diagnostics.status === 'unavailable') return diagnostics; |
| // The protocol frame is readonly; the CLI's own type is not. Copied field |
| // by field rather than spread so a future protocol field cannot arrive |
| // here unnoticed. |
| const { composition, compaction, ...rest } = diagnostics; |
| return { |
| ...rest, |
| ...(composition |
| ? { |
| composition: { |
| segments: composition.segments.map((segment) => ({ ...segment })), |
| ...(composition.tools |
| ? { tools: composition.tools.map((tool) => ({ ...tool })) } |
| : {}), |
| ...(composition.remainingTools |
| ? { remainingTools: { ...composition.remainingTools } } |
| : {}), |
| ...(composition.unlabelledToolBytes !== undefined |
| ? { unlabelledToolBytes: composition.unlabelledToolBytes } |
| : {}), |
| }, |
| } |
| : {}), |
| ...(compaction ? { compaction: { ...compaction } } : {}), |
| }; |
| } |
| |
| getOrchestrationMode(): OrchestrationMode { |
| return this.#orchestrationMode; |
| } |
| |
| getPermissionMode(): PermissionMode | undefined { |
| return ( |
| this.#activeBoundaryDisplayMode ?? this.#permissionMode ?? this.#prospectivePermissionMode |
| ); |
| } |
| |
| /** |
| * Re-read the Host's chat default after the Session it described is gone. |
| * |
| * Best effort on purpose: this only moves a label, and creation omits the |
| * field either way, so a failed refresh keeps the last authoritative reading |
| * rather than inventing one. |
| */ |
| async #refreshProspectivePermissionMode(): Promise<void> { |
| try { |
| const policy = await this.#request('runtime.policy.query', {}); |
| this.#prospectivePermissionMode = policy.policy.chatDefaults.permissionMode; |
| } catch { |
| // Keep the previous reading. |
| } |
| } |
| |
| /** |
| * The ordered client → Host operation stream. |
| * |
| * Runtime Host decides what a Message becomes, and it decides from the state |
| * it holds when the Message arrives. That makes arrival order part of the |
| * meaning: two Enters typed before the first round trip resolves must not |
| * race into two Sessions, and a `/model` typed after a Message must not |
| * overtake it and change the Turn that Message opens. |
| * |
| * Session identity changes deliberately stay off this tail. `/session` and |
| * `/new` are how a user leaves a Session whose admission is stuck, so |
| * queueing them behind it would remove the only exit; `#assertCurrentSession` |
| * fences them instead, by failing an admission whose Session moved under it. |
| */ |
| #admissionTail: Promise<unknown> = Promise.resolve(); |
| |
| #admit<T>(operation: () => Promise<T>): Promise<T> { |
| const admitted = this.#admissionTail.then(operation, operation); |
| // A failed operation must not poison the tail: the next Message is a new |
| // intent, not a retry of the one that failed. |
| this.#admissionTail = admitted.then( |
| () => undefined, |
| () => undefined, |
| ); |
| return admitted; |
| } |
| |
| #sessionCreation: Promise<string> | undefined; |
| |
| /** |
| * One in-flight creation, shared. Reads outside the admission tail |
| * (`queryCancelledMessages`) can reach this concurrently with an admission, |
| * and a second `session.create` would leave the first Message in a Session |
| * the TUI has already stopped displaying. |
| */ |
| async #ensureSession(): Promise<string> { |
| if (this.#sessionId) return this.#sessionId; |
| if (this.#sessionCreation) return this.#sessionCreation; |
| const creation = this.#createSession(DEFAULT_SESSION_NAME).then((session) => session.id); |
| this.#sessionCreation = creation; |
| try { |
| return await creation; |
| } finally { |
| if (this.#sessionCreation === creation) this.#sessionCreation = undefined; |
| } |
| } |
| |
| async #createSession(name: string): Promise<SessionCatalogProjection> { |
| const workspace = this.#workspace.target; |
| if (!workspace) { |
| throw new Error('A remote Runtime Host Session requires an explicit Project'); |
| } |
| const sessionId = this.#newId(); |
| if (!this.#llmConnectionId) { |
| throw new Error('Runtime Host Session creation requires an exact Connection identity'); |
| } |
| const session = requireSession( |
| await this.#request('session.create', { |
| sessionId, |
| workspace, |
| name, |
| modelTarget: { |
| kind: 'explicit', |
| connectionId: this.#llmConnectionId, |
| connectionSlug: this.#llmConnectionSlug, |
| model: this.#model, |
| }, |
| ...(this.#permissionMode === undefined ? {} : { permissionMode: this.#permissionMode }), |
| ...(this.#orchestrationMode === 'default' |
| ? {} |
| : { orchestrationMode: this.#orchestrationMode }), |
| ...(this.#thinkingLevel === undefined ? {} : { thinkingLevel: this.#thinkingLevel }), |
| }), |
| ); |
| this.#sessionGeneration += 1; |
| this.#sessionId = sessionId; |
| this.#workspace = session.workspace; |
| this.#adoptConfiguration(session); |
| await this.#ensureChannel(sessionId); |
| return session; |
| } |
| |
| async #ensureChannel(sessionId: string): Promise<RuntimeHostSessionChannel> { |
| if (this.#channel?.sessionId === sessionId && !this.#channel.failed) return this.#channel; |
| if (this.#channelOpening?.sessionId === sessionId) return this.#channelOpening.promise; |
| const promise = this.#openChannel(sessionId); |
| this.#channelOpening = { sessionId, promise }; |
| try { |
| return await promise; |
| } finally { |
| if (this.#channelOpening?.promise === promise) this.#channelOpening = undefined; |
| } |
| } |
| |
| async #openChannel(sessionId: string): Promise<RuntimeHostSessionChannel> { |
| const sessionGeneration = this.#sessionGeneration; |
| const expectedChannelGeneration = this.#channelGeneration; |
| const opened = await this.#openSessionChannel(sessionId, sessionGeneration); |
| if ( |
| this.#sessionId !== sessionId || |
| this.#sessionGeneration !== sessionGeneration || |
| this.#channelGeneration !== expectedChannelGeneration |
| ) { |
| await opened.channel.close().catch(() => undefined); |
| throw new Error(`Session changed while opening Runtime Host channel: ${sessionId}`); |
| } |
| this.#channelGeneration += 1; |
| await this.#replaceChannel(opened.channel); |
| opened.channel.activate(); |
| return opened.channel; |
| } |
| |
| async #replaceChannel(next: RuntimeHostSessionChannel | undefined): Promise<void> { |
| const previous = this.#channel; |
| this.#channel = next; |
| const goal = next?.snapshot.goal ?? null; |
| for (const listener of this.#goalListeners) listener(goal); |
| await previous?.close().catch(() => undefined); |
| } |
| |
| async #updateConfiguration( |
| sessionId: string, |
| patch: { |
| modelTarget?: { |
| kind: 'explicit'; |
| connectionId: string; |
| connectionSlug: string; |
| model: string; |
| }; |
| thinkingLevel?: ThinkingLevel | null; |
| permissionMode?: PermissionMode; |
| orchestrationMode?: OrchestrationMode; |
| }, |
| ): Promise<SessionCatalogProjection> { |
| return updateRuntimeHostSession( |
| this.#connection, |
| sessionId, |
| (current) => |
| this.#request('session.configuration.update', { |
| sessionId, |
| expectedRevision: current.revision, |
| patch, |
| }), |
| { operation: 'session.configuration.update' }, |
| ); |
| } |
| |
| #adoptConfiguration(session: SessionCatalogProjection): void { |
| this.#model = session.model; |
| this.#llmConnectionId = session.llmConnectionId ?? undefined; |
| this.#llmConnectionSlug = session.llmConnectionSlug; |
| this.#thinkingLevel = session.thinkingLevel; |
| this.#permissionMode = session.permissionMode; |
| this.#orchestrationMode = session.orchestrationMode; |
| } |
| |
| async #loadConfiguration(sessionId: string): Promise<LoadedSessionConfiguration> { |
| const [session, boundary] = await Promise.all([ |
| getRuntimeHostSession(this.#connection, sessionId), |
| this.#request('session.execution_boundary.query', { sessionId }), |
| ]); |
| if (!session) throw new Error(`Session not found: ${sessionId}`); |
| return { |
| session, |
| boundaryDisplayMode: |
| boundary.kind === 'external' ? undefined : executionBoundaryDisplayMode(boundary), |
| }; |
| } |
| |
| #adoptLoadedConfiguration(configuration: LoadedSessionConfiguration): void { |
| this.#adoptConfiguration(configuration.session); |
| this.#activeBoundaryDisplayMode = configuration.boundaryDisplayMode; |
| } |
| |
| #assertCurrentSession(sessionId: string, sessionGeneration: number): void { |
| if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) { |
| throw new Error(`Session changed while loading Runtime Host state: ${sessionId}`); |
| } |
| } |
| |
| #requireSession(action: string): string { |
| if (!this.#sessionId) throw new Error(`Cannot ${action} before a session starts.`); |
| return this.#sessionId; |
| } |
| |
| #requireSessionCopyCleanup(): SessionCopyCleanupAuthority { |
| if (!this.#sessionCopyCleanup) { |
| throw new Error('Side conversations are unavailable without durable cleanup storage.'); |
| } |
| return this.#sessionCopyCleanup; |
| } |
| |
| async #resumeSessionCopy(input: { |
| sessionId: string; |
| kind: 'branch' | 'revision'; |
| sourceSessionId: string; |
| sourceTurnId?: string; |
| intent?: 'side_conversation'; |
| }): Promise<void> { |
| if (input.kind !== 'branch') { |
| throw new Error(`TUI side cleanup cannot resume a ${input.kind} copy.`); |
| } |
| for (let attempt = 0; attempt < MAX_CATALOG_ATTEMPTS; attempt += 1) { |
| const source = await getRuntimeHostSession(this.#connection, input.sourceSessionId); |
| if (!source) throw new Error(`Session not found: ${input.sourceSessionId}`); |
| const result = await this.#request('session.branch.create', { |
| sourceSessionId: input.sourceSessionId, |
| targetSessionId: input.sessionId, |
| ...(input.sourceTurnId === undefined ? {} : { sourceTurnId: input.sourceTurnId }), |
| expectedSourceRevision: source.revision, |
| ...(input.intent ? { intent: input.intent } : {}), |
| }); |
| if (result.kind === 'committed') return; |
| } |
| throw new Error(`Session kept changing while copying: ${input.sourceSessionId}`); |
| } |
| |
| async #removeSessionCopy(sessionId: string): Promise<'removed'> { |
| for (let attempt = 0; attempt < MAX_CATALOG_ATTEMPTS; attempt += 1) { |
| const session = await getRuntimeHostSession(this.#connection, sessionId); |
| if (!session) return 'removed'; |
| const result = await this.#request('session.remove', { |
| sessionId, |
| expectedRevision: session.revision, |
| }); |
| if (result.kind === 'removed') return 'removed'; |
| } |
| throw new Error(`Session kept changing while removing: ${sessionId}`); |
| } |
| |
| async #stopSessionTurn(sessionId: string): Promise<void> { |
| const subscription = await this.#connection.openSessionSubscription({ |
| sessionId, |
| transcript: { kind: 'none' }, |
| }); |
| const draining = (async () => { |
| for await (const _frame of subscription) { |
| // Keep the bounded subscription healthy until turn.stop settles. |
| } |
| })(); |
| try { |
| const turn = subscription.snapshot.rootTurn; |
| if (!turn || isTerminalTurn(turn)) return; |
| await this.#request('turn.stop', { |
| sessionId, |
| turnId: turn.turnId, |
| runId: turn.runId, |
| }); |
| } finally { |
| await subscription.close().catch(() => undefined); |
| await draining.catch(() => undefined); |
| } |
| } |
| |
| #publishStartedTurn(turn: MakaPreparedSessionTurn, sessionGeneration: number): void { |
| if (this.#claimedTurnIds.delete(turn.turnId)) return; |
| const sourceChannel = this.#channel; |
| const tail = this.#startedTurnReattachTails.get(sessionGeneration) ?? Promise.resolve(); |
| const attempt = tail.then(() => |
| this.#reattachStartedTurn(sessionGeneration, turn.sessionId, turn.turnId), |
| ); |
| const settled = attempt.catch(() => undefined); |
| this.#startedTurnReattachTails.set(sessionGeneration, settled); |
| void settled.then(() => { |
| if (this.#startedTurnReattachTails.get(sessionGeneration) === settled) { |
| this.#startedTurnReattachTails.delete(sessionGeneration); |
| } |
| }); |
| void attempt.catch((error) => sourceChannel?.failTurn(turn.turnId, error)); |
| } |
| |
| async #reattachStartedTurn( |
| sessionGeneration: number, |
| sessionId: string, |
| turnId: string, |
| ): Promise<void> { |
| if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) return; |
| const expectedChannelGeneration = this.#channelGeneration; |
| const opened = await this.#openSessionChannel(sessionId, sessionGeneration); |
| if ( |
| this.#sessionId !== sessionId || |
| this.#sessionGeneration !== sessionGeneration || |
| this.#channelGeneration !== expectedChannelGeneration |
| ) { |
| await opened.channel.close().catch(() => undefined); |
| return; |
| } |
| if (opened.attachedTurnId !== turnId) { |
| if (opened.terminalTurn?.turnId !== turnId) { |
| await opened.channel.close().catch(() => undefined); |
| return; |
| } |
| opened.channel.seedTerminalCut(opened.terminalTurn); |
| } |
| let configuration: LoadedSessionConfiguration; |
| try { |
| configuration = await this.#loadConfiguration(sessionId); |
| } catch { |
| await opened.channel.close().catch(() => undefined); |
| return; |
| } |
| if ( |
| this.#sessionId !== sessionId || |
| this.#sessionGeneration !== sessionGeneration || |
| this.#channelGeneration !== expectedChannelGeneration |
| ) { |
| await opened.channel.close().catch(() => undefined); |
| return; |
| } |
| this.#adoptLoadedConfiguration(configuration); |
| this.#channelGeneration += 1; |
| await this.#replaceChannel(opened.channel); |
| const turn = { |
| sessionId, |
| turnId, |
| ...(opened.channel.snapshot.rootTurn?.turnId === turnId |
| ? { runId: opened.channel.snapshot.rootTurn.runId } |
| : {}), |
| events: opened.channel.eventsForTurn(turnId), |
| messages: visibleTranscriptMessages(opened.messages, this.#hiddenTranscriptThroughTurnId), |
| summary: projectSessionCatalogSummary(configuration.session), |
| } satisfies MakaAttachedSessionTurn; |
| for (const listener of this.#startedTurnListeners) listener(turn); |
| opened.channel.activate(turnId); |
| } |
| |
| #openSessionChannel( |
| sessionId: string, |
| sessionGeneration: number, |
| ): Promise<RuntimeHostSessionChannelOpenResult> { |
| return RuntimeHostSessionChannel.open({ |
| connection: this.#connection, |
| sessionId, |
| now: this.#now, |
| onTurnStarted: (turn) => this.#publishStartedTurn(turn, sessionGeneration), |
| onRuntimeResourceChanged: (sourceSessionId, ref) => |
| this.#publishRuntimeResource(sourceSessionId, ref), |
| onSessionDomainChanged: (frame) => |
| this.#publishSessionDomainChanged(frame, sessionId, sessionGeneration), |
| onInteractionPending: (pending) => { |
| for (const listener of this.#pendingInteractionListeners) listener(pending); |
| }, |
| onInteractionResolved: (pending) => this.#resolveExternalInteraction(pending), |
| onTranscriptSettlement: (turnId) => |
| this.#refreshTranscript(sessionId, sessionGeneration, turnId), |
| onTranscriptReplaced: (turnId, messages) => |
| this.#publishTranscriptReplacement( |
| sessionId, |
| sessionGeneration, |
| turnId, |
| messages, |
| 'reconnect', |
| ), |
| onGoalChanged: (goal) => { |
| // A closing channel from a previous session can still be draining a |
| // frame when the swap happens; only the live session may publish. |
| if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) return; |
| for (const listener of this.#goalListeners) listener(goal); |
| }, |
| onRecovered: () => { |
| this.#refreshRuntimeResources(sessionId); |
| this.#publishTodoChanged(sessionId, sessionGeneration); |
| }, |
| }); |
| } |
| |
| #publishSessionDomainChanged( |
| frame: SessionDomainChangedFrame, |
| sessionId: string, |
| sessionGeneration: number, |
| ): void { |
| if ( |
| frame.domain !== 'todo' || |
| frame.sessionId !== sessionId || |
| this.#sessionId !== sessionId || |
| this.#sessionGeneration !== sessionGeneration |
| ) { |
| return; |
| } |
| this.#publishTodoChanged(sessionId, sessionGeneration); |
| } |
| |
| #publishTodoChanged(sessionId: string, sessionGeneration: number): void { |
| if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) return; |
| for (const listener of this.#todoChangeListeners) listener(sessionId); |
| } |
| |
| #publishRuntimeResource(sourceSessionId: string, ref: string): void { |
| void this.#request('runtime.resource.query', { |
| kind: 'get', |
| sessionId: sourceSessionId, |
| ref, |
| }) |
| .then((result) => { |
| if (result.kind !== 'resource' || !result.resource) return; |
| this.#publishShellRunUpdate(result.resource); |
| }) |
| .catch(() => undefined); |
| } |
| |
| #resolveExternalInteraction(pending: InteractionPendingSnapshot): void { |
| void this.#request('interaction.query', { |
| sessionId: pending.sessionId, |
| interactionId: pending.interactionId, |
| }) |
| .then((resolved) => { |
| if (resolved.status === 'answered') { |
| this.#channel?.publishInteractionAnswer(resolved, pending); |
| } |
| if (resolved.status === 'pending') return; |
| for (const listener of this.#resolvedInteractionListeners) { |
| listener(pending.sessionId, pending.interactionId); |
| } |
| }) |
| .catch(() => undefined); |
| } |
| |
| #refreshTranscript(sessionId: string, sessionGeneration: number, turnId: string): void { |
| const refreshSequence = ++this.#transcriptRefreshSequence; |
| const hiddenThroughTurnId = this.#hiddenTranscriptThroughTurnId; |
| void loadCurrentMessages(this.#connection, sessionId) |
| .then((messages) => { |
| if ( |
| this.#sessionId !== sessionId || |
| this.#sessionGeneration !== sessionGeneration || |
| refreshSequence !== this.#transcriptRefreshSequence |
| ) { |
| return; |
| } |
| this.#publishTranscriptReplacement( |
| sessionId, |
| sessionGeneration, |
| turnId, |
| visibleTranscriptMessages(messages, hiddenThroughTurnId), |
| 'reconcile', |
| ); |
| }) |
| .catch(() => undefined); |
| } |
| |
| #publishTranscriptReplacement( |
| sessionId: string, |
| sessionGeneration: number, |
| turnId: string, |
| messages: readonly StoredMessage[], |
| reason: MakaTranscriptReplacementReason, |
| ): void { |
| if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) return; |
| this.#transcriptRefreshSequence += 1; |
| for (const listener of this.#transcriptListeners) { |
| listener(sessionId, turnId, messages, reason); |
| } |
| } |
| |
| #refreshRuntimeResources(sessionId: string): void { |
| void readRuntimeHostResources(this.#connection, sessionId) |
| .then((resources) => { |
| if (this.#sessionId !== sessionId) return; |
| for (const resource of resources) { |
| this.#publishShellRunUpdate(resource); |
| } |
| }) |
| .catch(() => undefined); |
| } |
| |
| async #stopUserCommand( |
| ref: string, |
| owner: { readonly sessionId: string; readonly commandId: string }, |
| ): Promise<void> { |
| if (this.#activeUserCommands.get(ref) !== owner) return; |
| const stopped = await this.#request('runtime.resource.stop', { |
| sessionId: owner.sessionId, |
| ref, |
| }); |
| this.#publishShellRunUpdate({ |
| sessionId: owner.sessionId, |
| ownership: { kind: 'local' }, |
| sourceTurnId: owner.commandId, |
| sourceToolCallId: owner.commandId, |
| result: stopped.resource, |
| }); |
| } |
| |
| #publishShellRunUpdate(update: ShellRunUpdate): void { |
| const owner = this.#activeUserCommands.get(update.result.ref); |
| if ( |
| owner?.sessionId === update.sessionId && |
| owner.commandId === update.sourceToolCallId && |
| !isActiveShellRunStatus(update.result.status) |
| ) { |
| this.#activeUserCommands.delete(update.result.ref); |
| } |
| for (const listener of this.#shellRunListeners) listener(update); |
| } |
| |
| #request<K extends DirectRequestOperationKey>( |
| operation: K, |
| input: OperationInput<K>, |
| ): Promise<OperationOutput<K>> { |
| return this.#connection.request(operation, input); |
| } |
| } |
| |
| function workspaceTargetForCreate( |
| current: { readonly target?: WorkspaceTarget; readonly hostCwd: string }, |
| input: Pick<CreateSessionInput, 'cwd' | 'projectId'>, |
| location: NonNullable<RuntimeHostMakaSessionDriverInput['executionLocation']>, |
| ): WorkspaceTarget { |
| if (typeof input.projectId === 'string') { |
| return { kind: 'project', projectId: input.projectId }; |
| } |
| if (location.kind === 'host') { |
| if (current.target) return current.target; |
| throw new Error('A remote Runtime Host Session requires an explicit Project'); |
| } |
| if (input.projectId === null || input.cwd !== current.hostCwd) { |
| return { kind: 'host_path', path: input.cwd }; |
| } |
| return current.target!; |
| } |
| |
| interface LoadedSessionConfiguration { |
| session: SessionCatalogProjection; |
| boundaryDisplayMode: PermissionMode | undefined; |
| } |
| |
| function representableSession(item: SessionCatalogItem): SessionCatalogProjection[] { |
| return 'kind' in item ? [] : [item]; |
| } |
| |
| function sideConversationParentStatus( |
| snapshot: SessionContinuitySnapshot, |
| observedLiveRunId: string | undefined, |
| ): MakaSideConversationParentStatus | undefined { |
| if (snapshot.session.isArchived) return 'closed'; |
| const pendingKinds = new Set( |
| snapshot.interactions.pending.map((interaction) => interaction.request.kind), |
| ); |
| if (pendingKinds.has('permission') || pendingKinds.has('sandbox_boundary')) { |
| return 'needs_approval'; |
| } |
| if (pendingKinds.has('question')) return 'needs_input'; |
| if (!snapshot.rootTurn || snapshot.rootTurn.runId !== observedLiveRunId) return undefined; |
| if (snapshot.rootTurn.status === 'failed') return 'failed'; |
| if (snapshot.rootTurn.status === 'cancelled') return 'interrupted'; |
| if (snapshot.rootTurn.status === 'completed') return 'finished'; |
| return undefined; |
| } |
| |
| function isLiveSessionTurn( |
| turn: SessionContinuitySnapshot['rootTurn'], |
| ): turn is Exclude< |
| NonNullable<SessionContinuitySnapshot['rootTurn']>, |
| { status: 'completed' | 'failed' | 'cancelled' } |
| > { |
| return turn !== null && !isTerminalTurn(turn); |
| } |
| |
| function isTuiProcessActive(ownerProcessId: string): boolean { |
| const match = /^tui:(\d+)$/.exec(ownerProcessId); |
| if (!match) return false; |
| const processId = Number(match[1]); |
| if (!Number.isSafeInteger(processId) || processId <= 0) return false; |
| try { |
| process.kill(processId, 0); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
| |
| function visibleTranscriptMessages( |
| messages: StoredMessage[], |
| hiddenThroughTurnId: string | undefined, |
| ): StoredMessage[] { |
| if (!hiddenThroughTurnId) return messages; |
| let boundary = -1; |
| for (let index = 0; index < messages.length; index += 1) { |
| if ('turnId' in messages[index]! && messages[index]!.turnId === hiddenThroughTurnId) { |
| boundary = index; |
| } |
| } |
| return boundary < 0 ? messages : messages.slice(boundary + 1); |
| } |
| |
| function inspectRuntimeHostSessionResumeAvailability( |
| summary: SessionSummary, |
| location: NonNullable<RuntimeHostMakaSessionDriverInput['executionLocation']>, |
| ): Promise<SessionResumeAvailability> { |
| if (!summary.cwd) { |
| return Promise.resolve({ available: false, reason: 'Missing working directory' }); |
| } |
| return location.kind === 'host' |
| ? Promise.resolve({ available: true }) |
| : inspectSessionResumeAvailability(summary); |
| } |
| |
| async function assertSessionResumeAvailable( |
| summary: SessionSummary, |
| location: NonNullable<RuntimeHostMakaSessionDriverInput['executionLocation']>, |
| ): Promise<void> { |
| const availability = await inspectRuntimeHostSessionResumeAvailability(summary, location); |
| if (!availability.available) { |
| throw new Error( |
| summary.cwd ? `Session cwd no longer exists: ${summary.cwd}` : availability.reason, |
| ); |
| } |
| } |
| |
| async function loadCurrentMessages( |
| connection: RuntimeHostSessionDriverConnection, |
| sessionId: string, |
| ): Promise<StoredMessage[]> { |
| const subscription = await connection.openSessionSubscription({ |
| sessionId, |
| transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, |
| }); |
| const draining = (async () => { |
| for await (const _frame of subscription) { |
| // The transcript is pinned to the subscription snapshot. Drain newer |
| // frames only to preserve the bounded transport while the read runs. |
| } |
| })(); |
| try { |
| return await subscription.loadTranscript(decodeStoredMessage); |
| } finally { |
| await subscription.close().catch(() => undefined); |
| await draining.catch(() => undefined); |
| } |
| } |