/** * Sync Engine for HAPI Telegram Bot (Direct Connect) * * In the direct-connect architecture: * - hapi-hub is the hub (Socket.IO + REST) * - hapi CLI connects directly to the hub (no relay) * - No E2E encryption; data is stored as JSON in SQLite */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import type { Store, CancelQueuedMessageResult } from '../store' import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport' import type { RpcRegistry } from '../socket/rpcRegistry' import type { SSEManager } from '../sse/sseManager' import { CursorLegacyMigrator, type CursorLegacyMigratorOptions } from '../cursor/cursorLegacyMigrator' import { EventPublisher, type SyncEventListener } from './eventPublisher' import { MachineCache, type Machine } from './machineCache' import { MessageService } from './messageService' import { RpcGateway, RpcTargetMissingError, type RpcCodexModel, type RpcCommandResponse, type RpcDeleteUploadResponse, type RpcGeneratedImageResponse, type RpcListDirectoryResponse, type RpcStatFilesResponse, type RpcListCodexModelsResponse, type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, type RpcListGrokModelsResponse, type RpcListGrokReasoningEffortOptionsResponse, type RpcListOpencodeReasoningEffortOptionsResponse, type RpcCursorModel, type RpcCursorChatStoreStatus, type RpcOpencodeModel, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse } from './rpcGateway' import { SessionCache } from './sessionCache' type PiResumeAttempt = NonNullable['piResumeAttempt']> export type { Session, SyncEvent } from '@hapi/protocol/types' export type { Machine } from './machineCache' export type { SyncEventListener } from './eventPublisher' export type { RpcCodexModel, RpcCommandResponse, RpcDeleteUploadResponse, RpcGeneratedImageResponse, RpcListDirectoryResponse, RpcStatFilesResponse, RpcListCodexModelsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, RpcListGrokModelsResponse, RpcListGrokReasoningEffortOptionsResponse, RpcListOpencodeReasoningEffortOptionsResponse, RpcCursorModel, RpcCursorChatStoreStatus, RpcOpencodeModel, RpcPathExistsResponse, RpcReadFileResponse, RpcUploadFileResponse } from './rpcGateway' export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed'; rollbackSafe?: boolean } export type ReopenSessionResult = | { type: 'success'; sessionId: string; resumed: boolean; cursorSessionProtocol?: 'acp' | 'stream-json' } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' | 'metadata_conflict' } | { type: 'incomplete'; message: string; missing: [string, ...string[]] } export type LocalResumeTargetResult = | { type: 'success'; target: LocalResumeTarget } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' } export type LocalHandoffResult = | { type: 'success' } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } export type CursorChatStoreStatusResult = | { type: 'success'; status: CursorChatStoreStatus } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' | 'no_machine_online' | 'probe_failed' } function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null } function normalizeUserMessageText(value: string): string | undefined { const text = value.trim().replace(/\s+/g, ' ') return text.length > 0 ? text : undefined } function extractUserMessageText(content: unknown): string | undefined { if (typeof content === 'string') { return normalizeUserMessageText(content) } if (Array.isArray(content)) { const parts = content .map((block) => { const record = asRecord(block) return record?.type === 'text' && typeof record.text === 'string' ? record.text : null }) .filter((text): text is string => text !== null) return normalizeUserMessageText(parts.join(' ')) } const record = asRecord(content) if (record?.type === 'text' && typeof record.text === 'string') { return normalizeUserMessageText(record.text) } return undefined } function extractClaudeUserMessageTextFromAgentOutput(content: unknown): string | undefined { const record = asRecord(content) if (record?.type !== 'output') return undefined const data = asRecord(record.data) if (data?.type !== 'user') return undefined const message = asRecord(data.message) if (message?.role !== 'user') return undefined return extractUserMessageText(message.content) } export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache private readonly machineCache: MachineCache private readonly messageService: MessageService private readonly rpcGateway: RpcGateway private inactivityTimer: NodeJS.Timeout | null = null /** Sessions that emitted `session-ready` (Cursor ACP or validated Pi get_state). */ private readonly sessionReadyIds = new Set() /** Original Pi rows with a native resume currently in flight. */ private readonly piResumeInFlightIds = new Set() /** Pi rows whose runner child could not be confirmed terminated. */ private readonly piResumeQuarantinedIds = new Set() /** Unexpected version-skew temp child -> original row whose retry is blocked until child ends. */ private readonly piUnexpectedTempOriginalIds = new Map() /** Serialize scratchlist uploads per session so disk-byte caps cannot race. */ private readonly scratchlistUploadTails = new Map>() constructor( private readonly store: Store, io: Server, rpcRegistry: RpcRegistry, sseManager: SSEManager ) { this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) this.machineCache = new MachineCache(store, this.eventPublisher) this.messageService = new MessageService( store, io, this.eventPublisher, (sessionId, updatedAt) => this.recordSessionActivity(sessionId, updatedAt) ) this.rpcGateway = new RpcGateway(io, rpcRegistry) this.reloadAll() this.inactivityTimer = setInterval(() => this.expireInactive(), 5_000) } stop(): void { if (this.inactivityTimer) { clearInterval(this.inactivityTimer) this.inactivityTimer = null } } subscribe(listener: SyncEventListener): () => void { return this.eventPublisher.subscribe(listener) } private resolveNamespace(event: SyncEvent): string | undefined { if (event.namespace) { return event.namespace } if ('sessionId' in event) { return this.getSession(event.sessionId)?.namespace } if ('machineId' in event) { return this.machineCache.getMachine(event.machineId)?.namespace } return undefined } getSessions(): Session[] { return this.sessionCache.getSessions() } private resolveOnlineMachineForSession( session: Session, namespace: string, options?: { strictMachineId?: boolean } ): Machine | null { const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) if (session.metadata?.machineId) { const exact = onlineMachines.find((machine) => machine.id === session.metadata?.machineId) if (exact) return exact if (options?.strictMachineId) return null } if (session.metadata?.host) { const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === session.metadata?.host) if (hostMatch) return hostMatch } return null } async getCursorChatStoreStatus(sessionId: string, namespace: string): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } } const metadata = access.session.metadata if (metadata?.flavor !== 'cursor' || !metadata.path || !metadata.cursorSessionId) { return { type: 'error', message: 'Cursor resume metadata is unavailable', code: 'resume_unavailable' } } const targetMachine = this.resolveOnlineMachineForSession( access.session, namespace, { strictMachineId: true } ) if (!targetMachine) { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } try { const status = await this.rpcGateway.getCursorChatStoreStatus( targetMachine.id, metadata.path, metadata.cursorSessionId, metadata.homeDir ) return { type: 'success', status } } catch (error) { return { type: 'error', message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', code: 'probe_failed' } } } getSessionsByNamespace(namespace: string): Session[] { return this.sessionCache.getSessionsByNamespace(namespace) } getFutureScheduledMessageCounts(sessionIds: string[], now: number = Date.now()): Map { return this.store.messages.countFutureScheduledBySessionIds(sessionIds, now) } getNextScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map { return this.store.messages.minFutureScheduledAtBySessionIds(sessionIds, now) } getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } getSessionByNamespace(sessionId: string, namespace: string): Session | undefined { const session = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!session || session.namespace !== namespace) { return undefined } return session } resolveSessionAccess( sessionId: string, namespace: string ): { ok: true; sessionId: string; session: Session } | { ok: false; reason: 'not-found' | 'access-denied' } { return this.sessionCache.resolveSessionAccess(sessionId, namespace) } getActiveSessions(): Session[] { return this.sessionCache.getActiveSessions() } getMachines(): Machine[] { return this.machineCache.getMachines() } getMachinesByNamespace(namespace: string): Machine[] { return this.machineCache.getMachinesByNamespace(namespace) } getMachine(machineId: string): Machine | undefined { return this.machineCache.getMachine(machineId) } getMachineByNamespace(machineId: string, namespace: string): Machine | undefined { return this.machineCache.getMachineByNamespace(machineId, namespace) } getOnlineMachines(): Machine[] { return this.machineCache.getOnlineMachines() } getOnlineMachinesByNamespace(namespace: string): Machine[] { return this.machineCache.getOnlineMachinesByNamespace(namespace) } async renameMachine(machineId: string, displayName: string): Promise { return this.machineCache.renameMachine(machineId, displayName) } getMessagesPage( sessionId: string, options: { limit: number before?: { at: number; seq: number } | null after?: { at: number; seq: number } | null until?: { at: number; seq: number } | null epoch?: number | null } ): MessagesResponse { return this.messageService.getMessagesPage(sessionId, options) } getQueuedState(sessionId: string, localIds: string[]): QueuedStateResponse { return this.messageService.getQueuedState(sessionId, localIds) } getSessionExport(sessionId: string, session: Session): HapiSessionExportResult { return this.messageService.getSessionExport(sessionId, session) } getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] { return this.messageService.getDeliverableMessagesAfter(sessionId, options) } handleRealtimeEvent(event: SyncEvent): void { if (event.type === 'session-updated' && event.sessionId) { // Snapshot agent session IDs before refresh — safe because JS is single-threaded // and refreshSession replaces the Map entry with a new object. const before = this.sessionCache.getSession(event.sessionId) this.sessionCache.refreshSession(event.sessionId) const after = this.sessionCache.getSession(event.sessionId) if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) { if (!this.canRunCursorDedup(after)) { return } void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => { // best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates }) } return } if (event.type === 'machine-updated' && event.machineId) { this.machineCache.refreshMachine(event.machineId) return } if (event.type === 'message-received' && event.sessionId) { if (!this.getSession(event.sessionId)) { this.sessionCache.refreshSession(event.sessionId) } } this.eventPublisher.emit(event) } handleSessionAlive(payload: { sid: string time: number thinking?: boolean mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null modelReasoningEffort?: string | null effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode }): void { this.sessionCache.handleSessionAlive(payload) this.triggerDedupIfNeeded(payload.sid) } handleSessionReady(payload: { sid: string; time: number }): void { this.sessionReadyIds.add(payload.sid) const session = this.sessionCache.getSession(payload.sid) if (session?.metadata?.piResumeAttempt) { void this.writePiResumeAttempt(payload.sid, session.namespace, null) .then(() => { this.piResumeQuarantinedIds.delete(payload.sid) this.triggerDedupIfNeeded(payload.sid) }) .catch(() => {}) } this.triggerDedupIfNeeded(payload.sid) } clearQueuedThinkingGrace(sessionId: string): void { this.sessionCache.clearQueuedThinkingGrace(sessionId) } handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void { const before = this.sessionCache.getSession(payload.sid) const ownsPiAttempt = before?.metadata?.piResumeAttempt !== undefined const isPiAttemptChild = this.sessionCache.getSessions().some( (session) => session.metadata?.piResumeAttempt?.childSessionId === payload.sid ) const restorePiArchive = ownsPiAttempt && !this.sessionReadyIds.has(payload.sid) const isCursorAcp = before?.metadata?.flavor === 'cursor' && before.metadata.cursorSessionProtocol === 'acp' const shouldRetryDedup = !ownsPiAttempt && !isPiAttemptChild && (!isCursorAcp || this.sessionReadyIds.has(payload.sid)) this.sessionCache.handleSessionEnd(payload) this.eventPublisher.emit({ type: 'session-ended', sessionId: payload.sid, reason: payload.reason }) // Retry dedup now that this session is inactive — a prior dedup may have // skipped it because it was still active at the time. Cursor ACP rows that // never reached session-ready must not dedup-merge the original on failure. if (shouldRetryDedup) { this.triggerDedupIfNeeded(payload.sid) } this.sessionReadyIds.delete(payload.sid) this.piResumeQuarantinedIds.delete(payload.sid) this.piUnexpectedTempOriginalIds.delete(payload.sid) if (ownsPiAttempt || isPiAttemptChild) { void this.clearPiAttemptForEndedSession(payload.sid, restorePiArchive) } } handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void { this.sessionCache.applyBackgroundTaskDelta(sessionId, delta) } recordSessionActivity(sessionId: string, updatedAt: number): void { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } /** * tiann/hapi#893 (scratchlist v2). Read-side: list entries for a * session. Auth / namespace check is the route layer's job (via * `requireSessionFromParam`); by the time we get here the caller * already proved access. */ listScratchlistEntries(sessionId: string): Array<{ entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] }> { return this.store.scratchlist.list(sessionId).map((row) => ({ entryId: row.entryId, text: row.text, createdAt: row.createdAt, updatedAt: row.updatedAt, attachments: row.attachments, })) } countScratchlistEntries(sessionId: string): number { return this.store.scratchlist.count(sessionId) } sumScratchlistAttachmentBytes(sessionId: string): number { return this.store.scratchlist.sumAttachmentBytes(sessionId) } /** * Read a single entry by id. The route layer uses this to short- * circuit duplicate POSTs (migration retry) BEFORE running the * server-side cap check; otherwise an idempotent retry against a * session that has hit `SCRATCHLIST_MAX_ENTRIES` would 409 when it * should 200 with the existing row. */ getScratchlistEntry( sessionId: string, entryId: string ): { entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } | null { const row = this.store.scratchlist.get(sessionId, entryId) if (!row) return null return { entryId: row.entryId, text: row.text, createdAt: row.createdAt, updatedAt: row.updatedAt, attachments: row.attachments, } } /** * Insert a scratchlist entry. Returns the canonical row on success * (so the route layer can serialise it without a follow-up read). * Emits a `session-updated` SSE patch carrying `scratchlistUpdatedAt` * so other clients viewing the same session refetch. * * `outcome: 'duplicate'` covers the migration path's idempotency: * the web client may retry pushing a localStorage entry after a * partial failure; the second attempt should be a no-op rather than * a hard error. Route layer maps duplicate → 200/conflict per its * own contract; this layer just reports it. */ createScratchlistEntry( sessionId: string, text: string, options?: { entryId?: string createdAt?: number attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } ): { outcome: 'created' | 'duplicate' entry: { entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } } | { outcome: 'session-not-found' } { const result = this.store.scratchlist.create(sessionId, text, options) if (result.outcome === 'session-not-found') { return result } if (result.outcome === 'created') { this.sessionCache.emitScratchlistChanged(sessionId, result.entry.updatedAt) } return { outcome: result.outcome, entry: { entryId: result.entry.entryId, text: result.entry.text, createdAt: result.entry.createdAt, updatedAt: result.entry.updatedAt, attachments: result.entry.attachments, } } } updateScratchlistEntry( sessionId: string, entryId: string, patch: { text?: string attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } ): { entryId: string text: string createdAt: number updatedAt: number attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } | null { const updated = this.store.scratchlist.update(sessionId, entryId, patch) if (!updated) return null this.sessionCache.emitScratchlistChanged(sessionId, updated.updatedAt) return { entryId: updated.entryId, text: updated.text, createdAt: updated.createdAt, updatedAt: updated.updatedAt, attachments: updated.attachments, } } deleteScratchlistEntry(sessionId: string, entryId: string): boolean { const existing = this.store.scratchlist.get(sessionId, entryId) const removed = this.store.scratchlist.delete(sessionId, entryId) if (removed && existing) { // Attachment ids may be shared across entries (direct REST). // Only delete blobs that no remaining entry still references. const remainingIds = new Set( this.store.scratchlist .list(sessionId) .flatMap((entry) => entry.attachments.map((att) => att.id)) ) const orphaned = existing.attachments.filter((att) => !remainingIds.has(att.id)) if (orphaned.length > 0) { void import('../scratchlistAttachments/storage').then(({ deleteScratchlistAttachmentFiles, getHapiHomeDir }) => deleteScratchlistAttachmentFiles(getHapiHomeDir(), orphaned) ) } this.sessionCache.emitScratchlistChanged(sessionId, Date.now()) } return removed } private async withScratchlistUploadLock( namespace: string, sessionId: string, fn: () => Promise ): Promise { const key = `${namespace}:${sessionId}` const previous = this.scratchlistUploadTails.get(key) ?? Promise.resolve() let release!: () => void const gate = new Promise((resolve) => { release = resolve }) const tail = previous.catch(() => undefined).then(() => gate) this.scratchlistUploadTails.set(key, tail) await previous.catch(() => undefined) try { return await fn() } finally { release() if (this.scratchlistUploadTails.get(key) === tail) { this.scratchlistUploadTails.delete(key) } } } async uploadScratchlistAttachment( sessionId: string, namespace: string, filename: string, contentBase64: string, mimeType: string ): Promise<{ success: true; attachment: import('@hapi/protocol').ScratchlistAttachmentMetadata } | { success: false; error: string; code?: string }> { const { loadScratchlistAttachmentLimitsFromEnv, isAllowedScratchlistMime } = await import('../config/scratchlistAttachmentLimits') const { estimateBase64Bytes, writeScratchlistAttachmentFile, getHapiHomeDir, sumScratchlistAttachmentBytesOnDisk, } = await import('../scratchlistAttachments/storage') const { validateScratchlistAttachmentsForWrite } = await import('../scratchlistAttachments/validate') const limits = loadScratchlistAttachmentLimitsFromEnv() const estimated = estimateBase64Bytes(contentBase64) if (estimated > limits.maxBytesPerFile) { return { success: false, error: 'File too large', code: 'scratchlist_attachment_too_large' } } if (!isAllowedScratchlistMime(mimeType, limits)) { return { success: false, error: 'Mime type not allowed', code: 'scratchlist_attachment_mime' } } const hapiHome = getHapiHomeDir() const buffer = Buffer.from(contentBase64, 'base64') return await this.withScratchlistUploadLock(namespace, sessionId, async () => { const sessionBytes = await sumScratchlistAttachmentBytesOnDisk(hapiHome, namespace, sessionId) const provisional = { id: 'pending', filename, mimeType, size: buffer.length, path: 'pending', } const validation = validateScratchlistAttachmentsForWrite([provisional], limits, sessionBytes) if (!validation.ok) { return { success: false, error: validation.error, code: validation.code } } const attachment = await writeScratchlistAttachmentFile( hapiHome, namespace, sessionId, filename, mimeType, buffer ) return { success: true, attachment } }) } async resolveScratchlistAttachmentsForSession( sessionId: string, namespace: string, claimed: import('@hapi/protocol').ScratchlistAttachmentMetadata[] ): Promise< | { ok: true; attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } | { ok: false; error: string } > { const { resolveScratchlistAttachmentsForSession: resolveAttachments, getHapiHomeDir, } = await import('../scratchlistAttachments/storage') return resolveAttachments(getHapiHomeDir(), namespace, sessionId, claimed) } async sumScratchlistAttachmentBytesOnDisk(sessionId: string, namespace: string): Promise { const { sumScratchlistAttachmentBytesOnDisk: sumOnDisk, getHapiHomeDir, } = await import('../scratchlistAttachments/storage') return sumOnDisk(getHapiHomeDir(), namespace, sessionId) } async deleteScratchlistAttachmentById( sessionId: string, namespace: string, attachmentId: string ): Promise { const { deleteScratchlistAttachmentById: deleteById, getHapiHomeDir, } = await import('../scratchlistAttachments/storage') return deleteById(getHapiHomeDir(), namespace, sessionId, attachmentId) } async readScratchlistAttachment( hubPath: string ): Promise<{ buffer: Buffer; mimeType: string; filename: string } | null> { const { readScratchlistAttachmentFile, getHapiHomeDir } = await import('../scratchlistAttachments/storage') const read = await readScratchlistAttachmentFile(getHapiHomeDir(), hubPath) if (!read) return null return { buffer: read.buffer, mimeType: 'application/octet-stream', filename: 'attachment' } } handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void { this.machineCache.handleMachineAlive(payload) } private expireInactive(): void { const expired = this.sessionCache.expireInactive() // Sort by most recent first so dedup keeps the newest session when multiple // duplicates for the same agent thread expire in the same sweep. const sorted = expired .map((id) => this.sessionCache.getSession(id)) .filter((s): s is NonNullable => s != null) .sort((a, b) => (b.activeAt - a.activeAt) || (b.updatedAt - a.updatedAt)) for (const session of sorted) { this.triggerDedupIfNeeded(session.id) } this.machineCache.expireInactive() // Piggybacked on the inactivity tick; not a logical part of expireInactive // but shares its 5s cadence (avoids a second timer). this.messageService.releaseMatureScheduledMessages(Date.now()) } private reloadAll(): void { this.sessionCache.reloadAll() this.machineCache.reloadAll() } getOrCreateSession( tag: string, metadata: unknown, agentState: unknown, namespace: string, model?: string, effort?: string, modelReasoningEffort?: string, requestedId?: string ): Session { return this.sessionCache.getOrCreateSession( tag, metadata, agentState, namespace, model, effort, modelReasoningEffort, requestedId ) } getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { return this.machineCache.getOrCreateMachine(id, metadata, runnerState, namespace) } async sendMessage( sessionId: string, payload: { text: string localId?: string | null attachments?: Array<{ id: string filename: string mimeType: string size: number path: string previewUrl?: string }> sentFrom?: 'telegram-bot' | 'webapp' scheduledAt?: number | null } ): Promise { await this.messageService.sendMessage(sessionId, payload) this.sessionCache.markMessageQueued(sessionId) this.sessionCache.recordSessionActivity(sessionId, Date.now()) } async cancelQueuedMessage( sessionId: string, messageId: string ): Promise { return this.messageService.cancelQueuedMessage(sessionId, messageId) } sweepImmediateQueuedOnSessionEnd(sessionId: string, invokedAt: number): void { this.messageService.sweepImmediateQueuedOnSessionEnd(sessionId, invokedAt) } async approvePermission( sessionId: string, requestId: string, mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', answers?: Record | Record ): Promise { await this.rpcGateway.approvePermission(sessionId, requestId, mode, allowTools, decision, answers) } async denyPermission( sessionId: string, requestId: string, decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' ): Promise { await this.rpcGateway.denyPermission(sessionId, requestId, decision) } async abortSession(sessionId: string): Promise { await this.rpcGateway.abortSession(sessionId) } async archiveSession(sessionId: string): Promise { // tiann/hapi#916: when the CLI is already gone (e.g. after a // hub-restart cascade SIGTERMed the runner but the in-memory // `active` flag has not been reconciled yet) the kill-RPC throws // and the route used to surface that as HTTP 500. Treat the // missing target as a benign condition: still flip the session's // lifecycleState to `archived` in the hub-side metadata so the // UI does not see a half-cleaned zombie, and continue to mark // it inactive in the cache. Real RPC errors (timeout, protocol // failure) still propagate as 5xx. try { await this.rpcGateway.killSession(sessionId) } catch (error) { if (error instanceof RpcTargetMissingError) { this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)') } else { throw error } } this.handleSessionEnd({ sid: sessionId, time: Date.now() }) } /** * Apply the post-migration metadata flip in hapi.db: * - metadata.cursorSessionProtocol = 'acp' * - session.model = lastUsedModel (if provided) * * Returns 'success' on a clean write, 'version-mismatch' if the metadata * version moved underneath us (caller retries) or 'not-found' if the row * is gone. * * Used by CursorLegacyMigrator after the on-disk transplant + verify * succeeds. Kept on the engine (not on the migrator) so that all hapi.db * writes funnel through the existing cache-refresh path. */ flipCursorSessionProtocolToAcp( sessionId: string, namespace: string, lastUsedModel: string | null ): { result: 'success' | 'version-mismatch' | 'not-found' | 'session-active' } { for (let attempt = 0; attempt < 2; attempt += 1) { const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!latest?.metadata) { return { result: 'not-found' } } // Combined SSE-event payload contract (UX A++): clear the // `cursorMigrationState='in_progress'` flag in the SAME metadata // write that flips `cursorSessionProtocol` to 'acp'. The web // banner keys off `cursorMigrationState`, so a single SSE // session-updated event swaps both atomically — banner gone, // protocol flipped — preventing a flicker window where the // banner has already disappeared but the chat hasn't re-rendered // to the ACP transport yet. const carriedMigrationState = latest.metadata.cursorMigrationState // Atomic active-check inside the same synchronous flip op so // that a resume cannot land between the migrator's recheck // and the actual DB update. Bun is single-threaded — once // we've read `latest` and the row is inactive, no other JS // can mutate active=true until this method returns. Codex // review #34 P1 v2: the migrator's recheck is best-effort; // this is the authoritative gate. // // Codex review #34 P2 v5: only block on `active === true`, // NOT on lifecycleState === 'running'. After a force-archive // flow archiveSession() synchronously sets active=false but // the cleanup metadata write that flips lifecycleState // 'running' → 'archived' may still be in-flight, and that // is OUR archive completing, not a resume race. The active // flag is the authoritative live-runner signal. if (latest.active === true) { return { result: 'session-active' } } // Codex review #34 P2 v7: ALSO clear a stale lifecycleState // value if it still says 'running'. The migrator now skips // archiveSession() for stale-running rows (active=false but // lifecycle=running with --force-archive-running) because // there's no live runner to archive. Without this fixup, // successfully migrated stale rows would retain lifecycle= // running forever and any downstream code that filters by // lifecycleState (not the cache active flag) would keep // treating archived ACP sessions as live. const oldLifecycle = typeof latest.metadata.lifecycleState === 'string' ? latest.metadata.lifecycleState : undefined const nextMetadata: typeof latest.metadata = { ...latest.metadata, cursorSessionProtocol: 'acp' as const, ...(oldLifecycle === 'running' ? { lifecycleState: 'archived' as const } : {}) } // Drop the migration-in-progress flag in the same write (see // header comment). Safe whether or not it was set. if (carriedMigrationState !== undefined) { delete nextMetadata.cursorMigrationState } const result = this.store.sessions.updateSessionMetadata( sessionId, nextMetadata, latest.metadataVersion, namespace, { touchUpdatedAt: false } ) if (result.result === 'version-mismatch') { this.sessionCache.refreshSession(sessionId) continue } if (result.result !== 'success') { return { result: 'not-found' } } this.sessionCache.refreshSession(sessionId) if (lastUsedModel && lastUsedModel.trim().length > 0) { this.store.sessions.setSessionModel(sessionId, lastUsedModel.trim(), namespace, { touchUpdatedAt: false }) this.sessionCache.refreshSession(sessionId) } return { result: 'success' } } return { result: 'version-mismatch' } } /** * Migrate a single legacy cursor session to ACP. Hub-side; runs on the * operator's machine (the hub host); see tiann/hapi#824 design. * Returns a structured outcome (ok or refusal); does not throw. */ async migrateLegacyCursorSession( sessionId: string, namespace: string, request: CursorMigrateToAcpRequest ): Promise { const session = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!session) { return { ok: false, sessionId, reason: 'internal_error', message: 'session not found in namespace', durationMs: 0 } } const migrator = this.buildMigratorForRequest(request) return migrator.migrateOne(session, { keepSource: request.keepSource, forceArchiveRunning: request.forceArchiveRunning, skipVerify: request.skipVerify }) } private buildMigratorForRequest(_request: CursorMigrateToAcpRequest): CursorLegacyMigrator { const migratorOpts: CursorLegacyMigratorOptions = {} return new CursorLegacyMigrator(migratorOpts, { archiveSession: async (sessionId) => { await this.archiveSession(sessionId) }, // NOTE: no awaitSessionInactive injection — handleSessionEnd() // synchronously sets cache.active=false inside archiveSession, // so any cache-based poll would return immediately and provide // false reassurance. The migrator now relies on // awaitLockRelease's minimum-dwell + SQLite busy-probe + // size-stability combination instead. Codex review #34 P1 v3. getCurrentSession: (sessionId, namespace) => { const s = this.sessionCache.getSessionByNamespace(sessionId, namespace) if (!s) return null return { active: s.active === true, lifecycleState: typeof s.metadata?.lifecycleState === 'string' ? s.metadata.lifecycleState : undefined, cursorSessionProtocol: typeof s.metadata?.cursorSessionProtocol === 'string' ? s.metadata.cursorSessionProtocol : undefined } }, updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => { const result = this.flipCursorSessionProtocolToAcp(sessionId, namespace, lastUsedModel) if (result.result === 'success') return { ok: true } if (result.result === 'session-active') return { ok: false, reason: 'session_active' as const } return { ok: false, reason: 'version_mismatch_or_missing' as const } }, // tiann/hapi#872: size sanity check needs to compare HAPI's known // message history against the candidate legacy store's blob // count. The store-handle stays on the engine; we only thread // the count through so the migrator stays free of a direct // hub.Store dependency. getHapiMessageCount: (sessionId, _namespace) => { try { return this.store.messages.countMessages(sessionId) } catch (err) { // tiann/hapi#873 cold review: a silent 0 here trips // the migrator's "skip sanity" branch and chronically // disables the floor. Warn so a broken countMessages // (lock contention pattern, schema drift) is visible // in journalctl. console.warn('[auto-migrate] countMessages threw; size sanity skipped', { sessionId, err: err instanceof Error ? err.message : String(err) }) return 0 } } }) } async switchSession(sessionId: string, to: 'remote' | 'local'): Promise { await this.rpcGateway.switchSession(sessionId, to) } async renameSession(sessionId: string, name: string): Promise { await this.sessionCache.renameSession(sessionId, name) } async deleteSession(sessionId: string): Promise { await this.sessionCache.deleteSession(sessionId) } async applySessionConfig( sessionId: string, config: { permissionMode?: PermissionMode model?: { provider: string; modelId: string } | string | null modelReasoningEffort?: string | null effort?: string | null serviceTier?: string | null collaborationMode?: CodexCollaborationMode } ): Promise { const session = this.sessionCache.getSession(sessionId) if (!session?.active) { // For inactive sessions, update the in-memory cache directly without // an RPC call — the CLI is not running yet. The updated value will be // passed to the spawned process when the session is resumed. this.sessionCache.applySessionConfig(sessionId, config) return } const result = await this.rpcGateway.requestSessionConfig(sessionId, config) as Record if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } const obj = result as { error?: string applied?: { permissionMode?: Session['permissionMode'] model?: Session['model'] modelReasoningEffort?: Session['modelReasoningEffort'] effort?: Session['effort'] serviceTier?: Session['serviceTier'] collaborationMode?: Session['collaborationMode'] } } if (typeof obj.error === 'string' && obj.error.trim().length > 0) { throw new Error(obj.error) } const applied = obj.applied if (!applied || typeof applied !== 'object') { throw new Error(`Missing applied session config, got: ${JSON.stringify(result)}`) } const requestedKeys = Object.keys(config) as Array for (const key of requestedKeys) { if (!(key in applied)) { throw new Error(`Session did not apply ${key}`) } } this.sessionCache.applySessionConfig(sessionId, applied) } async spawnSession( machineId: string, directory: string, agent: AgentFlavor = 'claude', model?: string, modelReasoningEffort?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, effort?: string, permissionMode?: PermissionMode, serviceTier?: string, existingSessionId?: string, collaborationMode?: CodexCollaborationMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier, existingSessionId, collaborationMode ) } private resolveFlavor(session: Session): AgentFlavor { const flavor = session.metadata?.flavor return isKnownFlavor(flavor) ? flavor : 'claude' } private resolveAgentResumeId(session: Session, namespace: string): string | null { const metadata = session.metadata if (!metadata) { return null } const flavor = this.resolveFlavor(session) if (flavor === 'codex') { return metadata.codexSessionId ?? this.recoverCodexSessionIdFromMessages(session.id, namespace) } if (flavor === 'gemini') return metadata.geminiSessionId ?? null if (flavor === 'opencode') return metadata.opencodeSessionId ?? null if (flavor === 'grok') return metadata.grokSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null if (flavor === 'pi') return metadata.piSessionId ?? null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) } resolveLocalResumeTarget(sessionId: string, namespace: string): LocalResumeTargetResult { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } } const session = access.session const metadata = session.metadata if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) { return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } } const agentSessionId = this.resolveAgentResumeId(session, namespace) if (!agentSessionId) { return { type: 'error', message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.', code: 'resume_unavailable' } } return { type: 'success', target: { sessionId: access.sessionId, flavor: this.resolveFlavor(session), directory: metadata.path, machineId: metadata.machineId, host: metadata.host, active: session.active, thinking: session.thinking, controlledByUser: session.agentState?.controlledByUser === true, agentSessionId, model: session.model ?? null, effort: session.effort ?? null, modelReasoningEffort: session.modelReasoningEffort ?? null, permissionMode: session.permissionMode, collaborationMode: session.collaborationMode } } } listLocalResumableSessions(namespace: string, opts?: { machineId?: string }): ResumableSession[] { return this.getSessionsByNamespace(namespace) .map((session) => this.resolveLocalResumeTarget(session.id, namespace)) .filter((result): result is { type: 'success'; target: LocalResumeTarget } => result.type === 'success') .map(({ target }) => { const session = this.getSessionByNamespace(target.sessionId, namespace) return { sessionId: target.sessionId, flavor: target.flavor, directory: target.directory, machineId: target.machineId, host: target.host, active: target.active, thinking: target.thinking, controlledByUser: target.controlledByUser, agentSessionId: target.agentSessionId, model: target.model, effort: target.effort, modelReasoningEffort: target.modelReasoningEffort, permissionMode: target.permissionMode, collaborationMode: target.collaborationMode, updatedAt: session?.updatedAt ?? 0, name: session?.metadata?.name, summary: session?.metadata?.summary?.text, firstUserMessage: this.resolveFirstUserMessage(target.sessionId) } }) .filter((session) => !opts?.machineId || session.machineId === opts.machineId) .sort((a, b) => b.updatedAt - a.updatedAt) } private resolveFirstUserMessage(sessionId: string): string | undefined { for (const message of this.store.messages.getFirstMessages(sessionId, 50)) { const roleWrapped = unwrapRoleWrappedRecordEnvelope(message.content) const text = roleWrapped?.role === 'user' ? extractUserMessageText(roleWrapped.content) : roleWrapped?.role === 'agent' ? extractClaudeUserMessageTextFromAgentOutput(roleWrapped.content) : undefined if (text) return text } return undefined } /** * tiann/hapi#824 — sync-on-open auto-migration. Returns the (possibly * refreshed-from-cache) session. If the session is a legacy stream-json * Cursor session AND the env flag is on, attempts a transplant migration * synchronously before the caller spawns the runner. * * The migrator's verify probe runs in an isolated HAPI_HOME (see * verifyInTempHome), so this method is safe to call even when other ACP * transports are alive on the host: per tiann/hapi#832, two `agent acp` * processes coexist on the same host without conflict, and swear01's * tiann/hapi#835 refactors the agent-acp-active lock into a cross-process * refcount that explicitly supports this. We rely on #835 landing before * this PR — see manifest layer ordering and the dependency note in * PR #34's body. * * Failure modes are all soft — the session is returned unchanged and the * caller proceeds with the legacy launcher. */ private async maybeAutoMigrateLegacyCursorSession(session: Session, namespace: string): Promise { const md = session.metadata const flagRaw = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE?.trim().toLowerCase() ?? '' console.info('[auto-migrate] considering', { sessionId: session.id, flavor: md?.flavor ?? null, proto: md?.cursorSessionProtocol ?? null, hasCursorId: typeof md?.cursorSessionId === 'string' && md.cursorSessionId.length > 0, envFlag: flagRaw === '' ? '(unset; default on)' : flagRaw }) if (flagRaw === '0' || flagRaw === 'false' || flagRaw === 'no' || flagRaw === 'off') { console.info('[auto-migrate] skipped: env flag disabled', { sessionId: session.id }) return session } if (!md || md.flavor !== 'cursor') { console.info('[auto-migrate] skipped: not a cursor session', { sessionId: session.id, flavor: md?.flavor ?? null }) return session } if (md.cursorSessionProtocol === 'acp') { console.info('[auto-migrate] skipped: already ACP', { sessionId: session.id }) return session } if (typeof md.cursorSessionId !== 'string' || md.cursorSessionId.length === 0) { console.info('[auto-migrate] skipped: no cursorSessionId', { sessionId: session.id }) return session } console.info('[auto-migrate] starting transplant', { sessionId: session.id, cursorSessionId: md.cursorSessionId }) // UX A++: surface the migration to the user via a banner in the // web UI. We set `cursorMigrationState='in_progress'` on the row // BEFORE the long-running transplant; the sessionCache.refresh() // call emits a `session-updated` SSE event the web client uses to // render the banner. The flag is cleared on success by the same // metadata write that flips cursorSessionProtocol to 'acp' (see // flipCursorSessionProtocolToAcp) so the banner disappears in the // same render tick the chat re-renders as ACP — no flicker. On // failure we clear the flag explicitly in the catch path below. const flagSet = this.setCursorMigrationStateInProgress(session.id, namespace) let bannerCleanupNeeded = flagSet try { const migrator = this.buildMigratorForRequest({}) // Codex #34 P2 (round 13): for inactive rows whose metadata // still reads `lifecycleState === 'running'` (e.g. orphaned by // a hub crash where the lifecycle transition didn't land), the // migrator's preflight refuses with `running_refused` unless // `forceArchiveRunning` is true. resumeSession's caller-side // guard already ensured `session.active === false` (see the // early-return above), so we know there's no runner to yank; // the `running` lifecycle is stale metadata, not a live agent. // This is exactly the stale-row case the sync-on-open path is // meant to clean up — refusing here would defeat the whole // point and silently fall back to the legacy launcher forever. const outcome = await migrator.migrateOne(session, { forceArchiveRunning: true }) if (outcome.ok) { console.info('[auto-migrate] success', { sessionId: session.id, cursorSessionId: md.cursorSessionId, acpSessionId: outcome.acpSessionId, durationMs: outcome.durationMs, sourceRemoved: outcome.sourceRemoved, replayNotifications: outcome.replayNotifications, lastUsedModelPreserved: outcome.lastUsedModelPreserved }) // Successful migration already cleared the flag atomically // in flipCursorSessionProtocolToAcp; skip the cleanup write. bannerCleanupNeeded = false const refreshed = this.sessionCache.getSessionByNamespace(session.id, namespace) if (refreshed) return refreshed return session } // tiann/hapi#872: ambiguous source store OR size-mismatch // means the migrator refused to transplant likely-alien // content. Surface this to the UI banner instead of silently // clearing the in-progress flag, so the operator can act // (verify which workspace-hash drawer holds the real history, // delete the stale siblings, retry) rather than have us // silently fall back to the legacy launcher and pretend the // ambiguity never happened. if (outcome.reason === 'ambiguous_legacy_store' || outcome.reason === 'size_mismatch') { console.warn('[auto-migrate] refusing to transplant; surfacing ambiguous banner', { sessionId: session.id, reason: outcome.reason, message: outcome.message }) const promoted = this.setCursorMigrationStateAmbiguous(session.id, namespace) if (promoted) { // We replaced the in-progress flag with the // ambiguous flag; the cleanup write below would // wipe both, so suppress it. bannerCleanupNeeded = false } else { // Promotion to 'ambiguous' failed (cache miss, repeated // version-mismatch, or non-version write failure). The // operator-facing warning above already fired; the // finally{} block will fall through to clear the // in-progress flag so the user is not left with a // permanent "Upgrading..." banner. Log so the gap is // diagnosable from journalctl. tiann/hapi#872. console.warn('[auto-migrate] failed to promote cursorMigrationState to "ambiguous"; banner will clear via cleanup', { sessionId: session.id, reason: outcome.reason }) } return session } // Soft fail — log and let the legacy launcher handle it. console.info('[auto-migrate] legacy cursor session left as stream-json', { sessionId: session.id, reason: outcome.reason, message: outcome.message }) } catch (err) { console.warn('[auto-migrate] unexpected error; falling back to legacy launcher', { sessionId: session.id, err: err instanceof Error ? err.message : String(err) }) } finally { // Failure or exception path: clear the in-progress banner flag // so the user isn't left with a permanent "Upgrading..." banner // even though we silently fell back to the legacy launcher. if (bannerCleanupNeeded) { this.clearCursorMigrationState(session.id, namespace) } } return session } /** * Replace `cursorMigrationState='in_progress'` with `'ambiguous'` so * the web banner can switch from "Upgrading..." to "Manual resolution * needed". Returns true if the new flag persisted. tiann/hapi#872. */ private setCursorMigrationStateAmbiguous(sessionId: string, namespace: string): boolean { for (let attempt = 0; attempt < 2; attempt += 1) { const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!latest?.metadata) return false if (latest.metadata.cursorMigrationState === 'ambiguous') return true const nextMetadata = { ...latest.metadata, cursorMigrationState: 'ambiguous' as const } const result = this.store.sessions.updateSessionMetadata( sessionId, nextMetadata, latest.metadataVersion, namespace, { touchUpdatedAt: false } ) if (result.result === 'success') { this.sessionCache.refreshSession(sessionId) return true } if (result.result === 'version-mismatch') { this.sessionCache.refreshSession(sessionId) continue } return false } return false } /** * Set `metadata.cursorMigrationState='in_progress'` on the session row * with a single retry on version-mismatch. Returns true if the flag was * persisted (so the caller knows the finally-cleanup is required), false * if the write failed entirely — in which case the banner never appeared * and there's nothing to clean up. UX A++ helper for the auto-migrate * banner; see maybeAutoMigrateLegacyCursorSession. */ private setCursorMigrationStateInProgress(sessionId: string, namespace: string): boolean { for (let attempt = 0; attempt < 2; attempt += 1) { const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!latest?.metadata) return false if (latest.metadata.cursorMigrationState === 'in_progress') return true const nextMetadata = { ...latest.metadata, cursorMigrationState: 'in_progress' as const } const result = this.store.sessions.updateSessionMetadata( sessionId, nextMetadata, latest.metadataVersion, namespace, { touchUpdatedAt: false } ) if (result.result === 'success') { this.sessionCache.refreshSession(sessionId) return true } if (result.result === 'version-mismatch') { this.sessionCache.refreshSession(sessionId) continue } return false } return false } /** * Clear `metadata.cursorMigrationState` (failure / exception cleanup). * Idempotent; safe to call when the flag was never set. UX A++ helper. */ private clearCursorMigrationState(sessionId: string, namespace: string): void { for (let attempt = 0; attempt < 2; attempt += 1) { const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!latest?.metadata) return if (latest.metadata.cursorMigrationState === undefined) return const nextMetadata: typeof latest.metadata = { ...latest.metadata } delete nextMetadata.cursorMigrationState const result = this.store.sessions.updateSessionMetadata( sessionId, nextMetadata, latest.metadataVersion, namespace, { touchUpdatedAt: false } ) if (result.result === 'success') { this.sessionCache.refreshSession(sessionId) return } if (result.result === 'version-mismatch') { this.sessionCache.refreshSession(sessionId) continue } return } } /** Inactive session with directory path but no agent thread and no prior user turn. */ private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean { const metadata = session.metadata if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) { return false } if (this.resolveAgentResumeId(session, namespace)) { return false } return this.store.messages.getFirstMessages(sessionId, 1).length === 0 } async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } } const initialSession = access.session if (initialSession.active) { return { type: 'success', sessionId: access.sessionId } } // tiann/hapi#824 — invisible, automatic, per-session ACP migration on // first open. If this is a legacy stream-json Cursor session and we // can safely migrate it right now (no other agent acp transport // would block the post-migration ACP launcher), run the transplant // synchronously before resuming. The user sees the regular session // loading state for ~3–5s longer; the session opens as ACP. const session = await this.maybeAutoMigrateLegacyCursorSession(initialSession, namespace) const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace) let flavor: AgentFlavor let resumeToken: string | undefined let directory: string if (targetResult.type === 'success') { flavor = targetResult.target.flavor resumeToken = targetResult.target.agentSessionId directory = targetResult.target.directory } else if ( targetResult.code === 'resume_unavailable' && this.canFreshSpawnNeverStartedSession(session, access.sessionId, namespace) ) { const metadata = session.metadata! flavor = this.resolveFlavor(session) resumeToken = undefined directory = metadata.path } else { return targetResult } const metadata = session.metadata! const targetMachine = this.resolveOnlineMachineForSession( session, namespace, { strictMachineId: flavor === 'cursor' } ) if (!targetMachine) { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } if (flavor === 'pi' && resumeToken && targetMachine.runnerState?.capabilities?.piExistingSessionResume !== true) { return { type: 'error', message: 'Pi resume requires an upgraded runner', code: 'resume_failed' } } const requiresPiNativeReady = flavor === 'pi' && resumeToken !== undefined if (requiresPiNativeReady) { if (this.isPiResumeBlocked(access.sessionId)) { return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' } } this.piResumeInFlightIds.add(access.sessionId) this.sessionReadyIds.delete(access.sessionId) try { await this.writePiResumeAttempt(access.sessionId, namespace, { state: 'resuming', machineId: targetMachine.id, startedAt: Date.now(), archiveSnapshot: { lifecycleState: metadata.lifecycleState, lifecycleStateSince: metadata.lifecycleStateSince, archivedBy: metadata.archivedBy, archiveReason: metadata.archiveReason, }, }) } catch { this.piResumeInFlightIds.delete(access.sessionId) return { type: 'error', message: 'Failed to record Pi resume attempt', code: 'resume_failed' } } } if (flavor === 'cursor' && resumeToken) { try { const chatStatus = await this.rpcGateway.getCursorChatStoreStatus( targetMachine.id, directory, resumeToken, metadata.homeDir ) if (!chatStatus.onDisk) { return { type: 'error', message: 'Cursor chat data is no longer available on the recorded machine', code: 'resume_unavailable' } } } catch (error) { return { type: 'error', message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', code: 'resume_failed' } } } const metadataPermissionMode = session.metadata?.preferredPermissionMode const preferredPermissionMode = metadataPermissionMode === 'yolo' && opts?.permissionMode === 'default' ? metadataPermissionMode : opts?.permissionMode ?? session.permissionMode ?? metadataPermissionMode let piResumeSucceeded = false try { const spawnResult = await this.rpcGateway.spawnSession( targetMachine.id, directory, flavor, session.model ?? undefined, session.modelReasoningEffort ?? undefined, undefined, undefined, undefined, resumeToken, session.effort ?? undefined, preferredPermissionMode, session.serviceTier ?? undefined, access.sessionId, session.collaborationMode ?? undefined ) if (spawnResult.type !== 'success') { if (requiresPiNativeReady) { const stopped = await this.terminateInPlacePiResume( targetMachine.id, access.sessionId, namespace ) if (!stopped) { await this.quarantinePiResume(access.sessionId, namespace, targetMachine.id) return { type: 'error', message: spawnResult.message, code: 'resume_failed', rollbackSafe: false, } } } return { type: 'error', message: spawnResult.message, code: 'resume_failed' } } if (requiresPiNativeReady && spawnResult.sessionId !== access.sessionId) { const removed = await this.terminateUnexpectedPiTemp( targetMachine.id, spawnResult.sessionId, access.sessionId, namespace ) return { type: 'error', message: removed ? 'Pi runner created an unexpected session; upgrade the runner and retry' : 'Pi runner created an unexpected live session; upgrade the runner and retry', code: 'resume_failed' } } const becameActive = await this.waitForSessionActive(spawnResult.sessionId) if (!becameActive) { if (requiresPiNativeReady) { const inactive = await this.terminateInPlacePiResume( targetMachine.id, access.sessionId, namespace ) if (!inactive) { await this.quarantinePiResume(access.sessionId, namespace, targetMachine.id) return { type: 'error', message: 'Pi resume failed and the child is still active', code: 'resume_failed', rollbackSafe: false } } } return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' } } const needsReadyBeforeSuccess = requiresPiNativeReady || ( spawnResult.sessionId !== access.sessionId && flavor === 'cursor' && metadata.cursorSessionProtocol === 'acp' ) if (needsReadyBeforeSuccess) { const readyResult = await this.waitForSessionReady(spawnResult.sessionId) if (readyResult !== 'ready') { if (requiresPiNativeReady && readyResult !== 'ended') { const inactive = await this.terminateInPlacePiResume( targetMachine.id, access.sessionId, namespace ) if (!inactive) { await this.quarantinePiResume(access.sessionId, namespace, targetMachine.id) return { type: 'error', message: 'Pi native resume timed out and the child is still active', code: 'resume_failed', rollbackSafe: false } } } const message = flavor === 'pi' ? readyResult === 'ended' ? 'Pi session ended before native resume completed' : 'Pi session failed to become native-ready' : readyResult === 'ended' ? 'Session ended before Cursor ACP load completed' : 'Session failed to become ready' return { type: 'error', message, code: 'resume_failed' } } } if (spawnResult.sessionId !== access.sessionId) { const oldSession = this.sessionCache.getSessionByNamespace(access.sessionId, namespace) if (oldSession) { try { await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to merge resumed session' return { type: 'error', message, code: 'resume_failed' } } } } this.sessionCache.markSessionActive(spawnResult.sessionId) piResumeSucceeded = true if (requiresPiNativeReady) await this.writePiResumeAttempt(access.sessionId, namespace, null) return { type: 'success', sessionId: spawnResult.sessionId } } finally { if (requiresPiNativeReady) { this.piResumeInFlightIds.delete(access.sessionId) if (!piResumeSucceeded && this.sessionCache.getSession(access.sessionId)?.metadata?.piResumeAttempt?.state === 'resuming') { await this.writePiResumeAttempt(access.sessionId, namespace, null, true).catch(() => {}) } if (piResumeSucceeded) { this.triggerDedupIfNeeded(access.sessionId) } } } } /** * Revive an archived session so the web UI can reach it again. * * Behaviour: * - Active session: idempotent no-op (`resumed: false`). * - Non-archived inactive session: forwards to `resumeSession` without touching metadata. * - Archived session: validates that the agent has enough metadata to resume (Cursor * sessions require a `cursorSessionId` once they have any messages), clears the * archive metadata (`lifecycleState`, `archivedBy`, `archiveReason`), defaults the * Cursor protocol to `stream-json` for pre-#799 sessions, then forwards to * `resumeSession`. The CLI's `sessionFactory` will re-stamp `lifecycleState='running'` * when it boots, so we do not pre-write that here. * * Failure rollback: if `resumeSession` fails (no machine online, spawn timeout, etc.) * the archive snapshot is restored so the operator can retry without losing * `archiveReason`/`archivedBy`/`lifecycleState` and the UI still shows the row as * archived rather than a dangling inactive non-archived ghost. * * Returns `incomplete` (HTTP 422 from the route layer) when the agent metadata * needed to resume is missing. */ async reopenSession(sessionId: string, namespace: string): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } } const session = access.session const metadata = session.metadata if (metadata?.flavor === 'pi' && this.isPiResumeBlocked(access.sessionId)) { if (session.active) { return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' } } if (metadata.piResumeAttempt && !this.piResumeInFlightIds.has(access.sessionId)) { const reconciled = await this.reconcilePersistedPiResumeAttempt(session) return { type: 'error', message: reconciled ? 'Previous Pi resume attempt was cleaned up; retry' : 'Pi resume is already in progress', code: 'resume_failed' } } return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' } } if (session.active) { return { type: 'success', sessionId: access.sessionId, resumed: false } } const isArchived = metadata?.lifecycleState === 'archived' if (isArchived && metadata) { if (metadata.flavor === 'cursor' && !metadata.cursorSessionId) { const hasMessages = this.store.messages.getFirstMessages(access.sessionId, 1).length > 0 if (hasMessages) { return { type: 'incomplete', message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id', missing: ['cursorSessionId'] } } } const archiveSnapshot = { lifecycleState: metadata.lifecycleState, archivedBy: metadata.archivedBy, archiveReason: metadata.archiveReason, lifecycleStateSince: metadata.lifecycleStateSince } let applied: { cursorSessionProtocol?: 'acp' | 'stream-json' } = {} // Pi reuses the original HAPI row. Keep its archive snapshot persisted // until the CLI successfully bootstraps that row as running; this avoids // an inactive, non-archived gap if the Hub restarts before spawn. if (metadata.flavor !== 'pi') { try { applied = await this.sessionCache.clearSessionArchiveMetadata(access.sessionId) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to clear archive metadata' return { type: 'error', message, code: 'metadata_conflict' } } } const resumeResult = await this.resumeSession(access.sessionId, namespace) if (resumeResult.type === 'error') { // Never restore archived metadata over a live Pi child. A live // row blocks retry by itself and must remain visible as active. const current = this.sessionCache.getSessionByNamespace(access.sessionId, namespace) if (resumeResult.rollbackSafe !== false && !current?.active) { try { await this.sessionCache.restoreSessionArchiveMetadata(access.sessionId, archiveSnapshot) } catch { // Swallow restore failures - the resume error is the more important signal. } } return resumeResult } return { type: 'success', sessionId: resumeResult.sessionId, resumed: true, ...(applied.cursorSessionProtocol ? { cursorSessionProtocol: applied.cursorSessionProtocol } : {}) } } // Not active and not archived (e.g. brand-new session that has not yet connected, // or one that ended without writing archive metadata). Forward to resume so the // operator still gets one-click revival. const resumeResult = await this.resumeSession(access.sessionId, namespace) if (resumeResult.type === 'error') { return resumeResult } return { type: 'success', sessionId: resumeResult.sessionId, resumed: true } } async handoffSessionToLocal(sessionId: string, namespace: string): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } } if (!access.session.active) { return { type: 'success' } } if (access.session.agentState?.controlledByUser === true) { return { type: 'error', message: 'Session is already controlled by a local terminal', code: 'already_local' } } try { await this.rpcGateway.handoffSessionToLocal(access.sessionId) } catch (error) { return { type: 'error', message: error instanceof Error ? error.message : String(error), code: 'handoff_failed' } } const inactive = await this.waitForSessionInactive(access.sessionId) if (!inactive) { return { type: 'error', message: 'Timed out waiting for remote session to hand off', code: 'handoff_failed' } } return { type: 'success' } } private recoverClaudeSessionIdFromMessages(sessionId: string, namespace: string): string | null { const messages = this.messageService.getMessages(sessionId, 200) for (let i = messages.length - 1; i >= 0; i -= 1) { const found = this.extractClaudeSessionId(messages[i].content) if (!found) continue return this.persistRecoveredAgentSessionId(sessionId, namespace, 'claudeSessionId', found) } return null } private recoverCodexSessionIdFromMessages(sessionId: string, namespace: string): string | null { const messages = this.messageService.getMessages(sessionId, 200) for (let i = messages.length - 1; i >= 0; i -= 1) { const content = messages[i].content if (this.isCodexContextResetMessage(content)) return null const found = this.extractCodexParentThreadId(content) if (!found) continue return this.persistRecoveredAgentSessionId(sessionId, namespace, 'codexSessionId', found) } return null } private isCodexContextResetMessage(value: unknown): boolean { const message = asRecord(value) const content = asRecord(message?.content) const event = asRecord(content?.data) return message?.role === 'agent' && content?.type === 'event' && event?.type === 'message' && event.message === 'Context was reset' } private extractCodexParentThreadId(value: unknown): string | null { const message = asRecord(value) const content = asRecord(message?.content) const event = asRecord(content?.data) if (message?.role !== 'agent' || content?.type !== 'codex' || !event) { return null } const scope = asRecord(event.scope) const roles = [event.scope_role, event.scopeRole, scope?.role] .filter((role) => role !== undefined) if (roles.length === 0 || roles.some((role) => role !== 'parent')) { return null } const threadIds: string[] = [] for (const threadId of [event.thread_id, event.threadId, scope?.thread_id, scope?.threadId]) { if (threadId === undefined) continue const normalized = this.normalizeAgentSessionId(threadId) if (!normalized) return null threadIds.push(normalized) } const uniqueThreadIds = [...new Set(threadIds)] return uniqueThreadIds.length === 1 ? uniqueThreadIds[0] : null } private extractClaudeSessionId(value: unknown): string | null { if (!value || typeof value !== 'object') { return null } const obj = value as Record const direct = this.normalizeAgentSessionId(obj.session_id) ?? this.normalizeAgentSessionId(obj.sessionId) if (direct) { return direct } const content = obj.content if (content && typeof content === 'object') { const found = this.extractClaudeSessionId(content) if (found) return found } const data = obj.data if (data && typeof data === 'object') { const found = this.extractClaudeSessionId(data) if (found) return found } return null } private normalizeAgentSessionId(value: unknown): string | null { if (typeof value !== 'string') { return null } const trimmed = value.trim() return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed) ? trimmed : null } private persistRecoveredAgentSessionId( sessionId: string, namespace: string, field: 'claudeSessionId' | 'codexSessionId', agentSessionId: string ): string { for (let attempt = 0; attempt < 2; attempt += 1) { const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!latest?.metadata) return agentSessionId const existingAgentSessionId = latest.metadata[field] if (typeof existingAgentSessionId === 'string') { return existingAgentSessionId } const result = this.store.sessions.updateSessionMetadata( sessionId, { ...latest.metadata, [field]: agentSessionId }, latest.metadataVersion, namespace, { touchUpdatedAt: false } ) if (result.result === 'success') { this.sessionCache.refreshSession(sessionId) return agentSessionId } if (result.result !== 'version-mismatch') { return agentSessionId } this.sessionCache.refreshSession(sessionId) const refreshed = this.sessionCache.getSessionByNamespace(sessionId, namespace) const authoritativeAgentSessionId = refreshed?.metadata?.[field] if (typeof authoritativeAgentSessionId === 'string') { return authoritativeAgentSessionId } } return agentSessionId } private hasSameAgentSessionIds( prev: Session['metadata'] | null, next: NonNullable ): boolean { return (prev?.codexSessionId ?? null) === (next.codexSessionId ?? null) && (prev?.claudeSessionId ?? null) === (next.claudeSessionId ?? null) && (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null) && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) && (prev?.grokSessionId ?? null) === (next.grokSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) } private canRunCursorDedup(session: Session): boolean { if (this.piResumeInFlightIds.has(session.id) || this.piResumeQuarantinedIds.has(session.id)) return false if (session.metadata?.piResumeAttempt) return false if (this.sessionCache.getSessions().some((candidate) => candidate.metadata?.piResumeAttempt?.childSessionId === session.id)) return false const piSessionId = session.metadata?.piSessionId if (piSessionId && this.sessionCache.getSessions().some((candidate) => candidate.id !== session.id && candidate.namespace === session.namespace && candidate.metadata?.piResumeAttempt !== undefined && candidate.metadata.piSessionId === piSessionId )) return false if (session.metadata?.flavor !== 'cursor') { return true } if (session.metadata?.cursorSessionProtocol !== 'acp') { return true } return this.sessionReadyIds.has(session.id) } private async terminateInPlacePiResume( machineId: string, sessionId: string, namespace: string ): Promise { const existingAttempt = this.sessionCache.getSession(sessionId)?.metadata?.piResumeAttempt await this.writePiResumeAttempt(sessionId, namespace, { ...existingAttempt, state: 'terminating', machineId, startedAt: Date.now(), }) let status: 'stopped' | 'already_gone' | 'still_alive' try { status = await this.rpcGateway.stopRunnerSession(machineId, sessionId) } catch { status = 'still_alive' } await new Promise((resolve) => setTimeout(resolve, 0)) const session = this.sessionCache.refreshSession(sessionId) ?? this.sessionCache.getSession(sessionId) const attemptClearedByEnd = session?.metadata?.piResumeAttempt === undefined if (status === 'still_alive') { if (attemptClearedByEnd) return true return false } if (session?.active) this.handleSessionEnd({ sid: sessionId, time: Date.now(), reason: 'error' }) await this.writePiResumeAttempt(sessionId, namespace, null, true).catch(() => {}) return true } private async terminateUnexpectedPiTemp( machineId: string, sessionId: string, originalSessionId: string, namespace: string ): Promise { this.piUnexpectedTempOriginalIds.set(sessionId, originalSessionId) const existingAttempt = this.sessionCache.getSession(originalSessionId)?.metadata?.piResumeAttempt await this.writePiResumeAttempt(originalSessionId, namespace, { ...existingAttempt, state: 'terminating', machineId, startedAt: Date.now(), childSessionId: sessionId, }) let status: 'stopped' | 'already_gone' | 'still_alive' try { status = await this.rpcGateway.stopRunnerSession(machineId, sessionId) } catch { status = 'still_alive' } await new Promise((resolve) => setTimeout(resolve, 0)) const session = this.sessionCache.refreshSession(sessionId) ?? this.sessionCache.getSession(sessionId) const original = this.sessionCache.refreshSession(originalSessionId) ?? this.sessionCache.getSession(originalSessionId) const attemptClearedByEnd = original?.metadata?.piResumeAttempt === undefined if (status === 'still_alive' && !attemptClearedByEnd) { await this.writePiResumeAttempt(originalSessionId, namespace, { ...existingAttempt, state: 'quarantined', machineId, startedAt: Date.now(), childSessionId: sessionId, }) return false } if (session?.active) this.handleSessionEnd({ sid: sessionId, time: Date.now(), reason: 'error' }) const remaining = this.sessionCache.getSession(sessionId) if (remaining && !remaining.active) await this.sessionCache.deleteSession(sessionId) this.piUnexpectedTempOriginalIds.delete(sessionId) await this.writePiResumeAttempt(originalSessionId, namespace, null, true).catch(() => {}) return true } private async quarantinePiResume(sessionId: string, namespace: string, machineId: string): Promise { this.piResumeQuarantinedIds.add(sessionId) const existingAttempt = this.sessionCache.getSession(sessionId)?.metadata?.piResumeAttempt await this.writePiResumeAttempt(sessionId, namespace, { ...existingAttempt, state: 'quarantined', machineId, startedAt: Date.now(), }) } private isPiResumeBlocked(sessionId: string): boolean { const metadataAttempt = this.sessionCache.getSession(sessionId)?.metadata?.piResumeAttempt return this.piResumeInFlightIds.has(sessionId) || this.piResumeQuarantinedIds.has(sessionId) || metadataAttempt !== undefined || [...this.piUnexpectedTempOriginalIds.values()].includes(sessionId) } private async writePiResumeAttempt( sessionId: string, namespace: string, attempt: PiResumeAttempt | null, restoreArchive = false ): Promise { for (let i = 0; i < 5; i += 1) { const current = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) if (!current?.metadata) return const next = { ...current.metadata } if (attempt) next.piResumeAttempt = attempt else { const snapshot = current.metadata.piResumeAttempt?.archiveSnapshot delete next.piResumeAttempt if (restoreArchive && snapshot) { if (snapshot.lifecycleState === undefined) delete next.lifecycleState else next.lifecycleState = snapshot.lifecycleState if (snapshot.lifecycleStateSince === undefined) delete next.lifecycleStateSince else next.lifecycleStateSince = snapshot.lifecycleStateSince if (snapshot.archivedBy === undefined) delete next.archivedBy else next.archivedBy = snapshot.archivedBy if (snapshot.archiveReason === undefined) delete next.archiveReason else next.archiveReason = snapshot.archiveReason } } const result = this.store.sessions.updateSessionMetadata(sessionId, next, current.metadataVersion, namespace, { touchUpdatedAt: false }) if (result.result === 'success') { this.sessionCache.refreshSession(sessionId) return } if (result.result !== 'version-mismatch') throw new Error('Failed to update Pi resume attempt') this.sessionCache.refreshSession(sessionId) } throw new Error('Pi resume attempt metadata was modified concurrently') } private async clearPiAttemptForEndedSession(endedSessionId: string, restoreArchive: boolean): Promise { const ended = this.sessionCache.getSession(endedSessionId) if (ended?.metadata?.piResumeAttempt) { await this.writePiResumeAttempt(endedSessionId, ended.namespace, null, restoreArchive).catch(() => {}) return } for (const session of this.sessionCache.getSessions()) { if (session.metadata?.piResumeAttempt?.childSessionId === endedSessionId) { await this.writePiResumeAttempt(session.id, session.namespace, null, true).catch(() => {}) } } } private async reconcilePersistedPiResumeAttempt(session: Session): Promise { const attempt = session.metadata?.piResumeAttempt if (!attempt) return true const childSessionId = attempt.childSessionId ?? session.id let status: 'stopped' | 'already_gone' | 'still_alive' try { status = await this.rpcGateway.stopRunnerSession(attempt.machineId, childSessionId) } catch { return false } if (status === 'still_alive') return false const child = this.sessionCache.getSession(childSessionId) if (child?.active) this.handleSessionEnd({ sid: childSessionId, time: Date.now(), reason: 'error' }) if (childSessionId !== session.id) { const remaining = this.sessionCache.getSession(childSessionId) if (remaining && !remaining.active) await this.sessionCache.deleteSession(childSessionId) } await this.writePiResumeAttempt(session.id, session.namespace, null, true) this.piResumeQuarantinedIds.delete(session.id) this.piUnexpectedTempOriginalIds.delete(childSessionId) return true } private triggerDedupIfNeeded(sessionId: string): void { const session = this.sessionCache.getSession(sessionId) if (session?.metadata) { if (!this.canRunCursorDedup(session)) { return } void this.sessionCache.deduplicateByAgentSessionId(sessionId).catch(() => { // best-effort: web-side safety net hides remaining duplicates }) } } async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { const session = this.getSession(sessionId) if (session?.active) { return true } await new Promise((resolve) => setTimeout(resolve, 250)) } return false } async waitForSessionReady(sessionId: string, timeoutMs: number = 60_000): Promise<'ready' | 'ended' | 'timeout'> { const start = Date.now() while (Date.now() - start < timeoutMs) { if (this.sessionReadyIds.has(sessionId)) { return 'ready' } const session = this.getSession(sessionId) if (!session?.active) { return 'ended' } await new Promise((resolve) => setTimeout(resolve, 250)) } return 'timeout' } async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { const session = this.getSession(sessionId) if (!session?.active) { return true } await new Promise((resolve) => setTimeout(resolve, 250)) } return false } async checkPathsExist(machineId: string, paths: string[]): Promise> { return await this.rpcGateway.checkPathsExist(machineId, paths) } async listMachineDirectory(machineId: string, path: string): Promise { return await this.rpcGateway.listMachineDirectory(machineId, path) } async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.rpcGateway.getGitStatus(sessionId, cwd) } async getGitDiffNumstat(sessionId: string, options: { cwd?: string; staged?: boolean }): Promise { return await this.rpcGateway.getGitDiffNumstat(sessionId, options) } async getGitDiffFile(sessionId: string, options: { cwd?: string; filePath: string; staged?: boolean }): Promise { return await this.rpcGateway.getGitDiffFile(sessionId, options) } async readSessionFile(sessionId: string, path: string): Promise { return await this.rpcGateway.readSessionFile(sessionId, path) } async readGeneratedImage(sessionId: string, imageId: string): Promise { return await this.rpcGateway.readGeneratedImage(sessionId, imageId) } async listDirectory(sessionId: string, path: string): Promise { return await this.rpcGateway.listDirectory(sessionId, path) } async statFiles(sessionId: string, paths: string[]): Promise { return await this.rpcGateway.statFiles(sessionId, paths) } async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType) } async deleteUploadFile(sessionId: string, path: string): Promise { return await this.rpcGateway.deleteUploadFile(sessionId, path) } async runRipgrep(sessionId: string, args: string[], cwd?: string): Promise { return await this.rpcGateway.runRipgrep(sessionId, args, cwd) } async listSlashCommands(sessionId: string, agent: string): Promise { return await this.rpcGateway.listSlashCommands(sessionId, agent) } async listSkills(sessionId: string, flavor?: string): Promise<{ success: boolean skills?: Array<{ name: string; description?: string }> error?: string }> { return await this.rpcGateway.listSkills(sessionId, flavor) } async listCodexModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCodexModelsForMachine(machineId) } async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]) { return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds) } async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId) } async listCursorModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCursorModelsForSession(sessionId) } async listCursorModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCursorModelsForMachine(machineId) } async listOpencodeModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listOpencodeModelsForSession(sessionId) } async listOpencodeModelsForCwd(machineId: string, cwd: string): Promise { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } async listGrokModelsForCwd(machineId: string, cwd: string): Promise { return await this.rpcGateway.listGrokModelsForCwd(machineId, cwd) } async listGrokModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listGrokModelsForSession(sessionId) } async listGrokReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.rpcGateway.listGrokReasoningEffortOptionsForSession(sessionId) } /** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */ async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { return await this.rpcGateway.callPiRpc(sessionId, method, params, timeoutMs) } async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise { return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId) } }