import type { SmartDataAuthStore } from './classes.authstore.js'; import type { IControllerResourceRuntimeHost } from './classes.resourcecoordinator.js'; import type { ControllerTerminalManager, TControllerTerminalStopReason, } from './classes.terminalmanager.js'; import type { IControllerResourceDocument } from './interfaces.projects.js'; import type { TControllerTerminalAgentFailure } from '../ts_interfaces/index.js'; /** Maps a start failure onto a stable code the browser can explain without parsing prose. */ const classifyAgentStartFailure = (errorArg: unknown): TControllerTerminalAgentFailure => { const message = errorArg instanceof Error ? errorArg.message : String(errorArg); if (message.includes('No trusted Claude Code executable')) return 'binary_missing'; if (message.includes('project directory binding changed')) return 'cwd_rebound'; if (message.includes('already open in another process')) return 'session_in_use'; if (message.includes('liveness listing is unavailable')) return 'liveness_unverified'; return 'spawn_failed'; }; export class ControllerTerminalResourceHost implements IControllerResourceRuntimeHost { private readonly pendingStoppedResources = new Map(); constructor( private readonly terminalManager: ControllerTerminalManager, private readonly store: Pick< SmartDataAuthStore, 'markTerminalResourceRunning' | 'markTerminalResourceStopped' >, ) {} public async startResource( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise { signalArg.throwIfAborted(); this.pendingStoppedResources.delete(this.resourceKey(resourceArg.projectId, resourceArg.id)); try { const started = await this.terminalManager.startTerminalResource(resourceArg); await this.store.markTerminalResourceRunning(resourceArg.projectId, resourceArg.id, { ...(started.launchMode === undefined ? {} : { launchMode: started.launchMode }), }); } catch (errorArg) { const cleanupErrors: unknown[] = []; await this.terminalManager.stopTerminalResource(resourceArg.projectId, resourceArg.id) .catch((cleanupErrorArg) => cleanupErrors.push(cleanupErrorArg)); // A failed start of an agent chat is recorded as a failure so the startup restart loop can // stop retrying a permanently broken conversation, and so the browser can say why. await this.store.markTerminalResourceStopped(resourceArg.projectId, resourceArg.id, { ...(resourceArg.terminal?.agent === undefined ? {} : { failure: { code: classifyAgentStartFailure(errorArg), message: errorArg instanceof Error ? errorArg.message : String(errorArg), }, }), }).catch((cleanupErrorArg) => cleanupErrors.push(cleanupErrorArg)); if (cleanupErrors.length > 0) { throw new AggregateError([errorArg, ...cleanupErrors], 'Terminal startup cleanup is incomplete.'); } throw errorArg; } } public async stopResource( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise { signalArg.throwIfAborted(); await this.terminalManager.stopTerminalResource(resourceArg.projectId, resourceArg.id); await this.store.markTerminalResourceStopped(resourceArg.projectId, resourceArg.id, { desiredState: 'stopped', }); this.pendingStoppedResources.delete(this.resourceKey(resourceArg.projectId, resourceArg.id)); } public isRunning(resourceArg: IControllerResourceDocument): boolean { return this.terminalManager.isTerminalRunning(resourceArg.projectId, resourceArg.id); } public isAvailable(): boolean { return true; } public async renameResource(resourceArg: IControllerResourceDocument): Promise { await this.terminalManager.renameTerminalResource( resourceArg.projectId, resourceArg.id, resourceArg.title, ); } public async reconcileAttachment(): Promise { await this.flushPendingStoppedResources(); } public async retireResource( resourceArg: IControllerResourceDocument, signalArg: AbortSignal, ): Promise { await this.stopResource(resourceArg, signalArg); } public async recordTerminalStopped( projectIdArg: string, resourceIdArg: string, lastExitCodeArg: number | undefined, reasonArg: TControllerTerminalStopReason, ): Promise { const key = this.resourceKey(projectIdArg, resourceIdArg); const pending = { projectId: projectIdArg, resourceId: resourceIdArg, ...(lastExitCodeArg === undefined ? {} : { lastExitCode: lastExitCodeArg }), reason: reasonArg, }; this.pendingStoppedResources.set(key, pending); await this.store.markTerminalResourceStopped( projectIdArg, resourceIdArg, this.stoppedOutcome(pending), ); if (this.pendingStoppedResources.get(key) === pending) { this.pendingStoppedResources.delete(key); } } public async flushPendingStoppedResources(): Promise { const results = await Promise.allSettled( [...this.pendingStoppedResources.entries()].map(async ([key, pending]) => { await this.store.markTerminalResourceStopped( pending.projectId, pending.resourceId, this.stoppedOutcome(pending), ); if (this.pendingStoppedResources.get(key) === pending) { this.pendingStoppedResources.delete(key); } }), ); const errors = results .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map((result) => result.reason); if (errors.length > 0) { throw new AggregateError(errors, 'Terminal stopped-state persistence is incomplete.'); } } /** * Only a root that ended on its own records a stopped intent. A controller-initiated close * leaves the intent alone, so the chat comes back on the next start. */ private stoppedOutcome(pendingArg: { lastExitCode?: number; reason: TControllerTerminalStopReason; }): { lastExitCode?: number; desiredState?: 'stopped' } { return { ...(pendingArg.lastExitCode === undefined ? {} : { lastExitCode: pendingArg.lastExitCode }), ...(pendingArg.reason === 'exited' ? { desiredState: 'stopped' as const } : {}), }; } private resourceKey(projectIdArg: string, resourceIdArg: string): string { return JSON.stringify([projectIdArg, resourceIdArg]); } }