diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 809ff579..e53fb333 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -42,6 +42,7 @@ type OpenCodeNativePromptOptions = { type BunFetchInit = RequestInit & { timeout?: false }; + export type AcpModelDescriptor = { modelId: string; name?: string; @@ -92,14 +93,21 @@ export class AcpSdkBackend implements AgentBackend { private initializeInFlight: Promise | null = null; private setModeSupported: boolean | undefined = undefined; private isProcessingMessage = false; - private activePromptRequests = 0; private promptRequestInFlight = false; + /** Concurrent session/prompt requests (main prompt + soft steers). */ + private activePromptRequests = 0; + /** Foreground prompt only; soft steers are excluded after Abort. */ + private foregroundPromptRequests = 0; + /** Bumped by abortSoftSteers; stale finishes from cancelled requests are dropped. */ + private promptRequestEpoch = 0; + /** Incremented for each foreground prompt turn, including retry-wrapped turns. */ + private promptGeneration = 0; private responseCompleteResolvers: Array<() => void> = []; private lastSessionUpdateAt = 0; private latestUsageUpdate: AcpUsageUpdate | null = null; - private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private nativePromptAbortController: AbortController | null = null; private nativePromptAbortOptions: OpenCodeNativePromptOptions | null = null; + private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; /** Fired on foreground ACP state / permission so launchers can bump hub thinking (#1470). */ @@ -403,17 +411,57 @@ export class AcpSdkBackend implements AgentBackend { // exposed as `unstable_setSessionModel` but the JSON-RPC method on the wire // is unprefixed). Errors (including JSON-RPC 'method not found') propagate // as rejections from the transport; the launcher's catch block handles them. - const response = await this.transport.sendRequest('session/set_model', { - sessionId, - modelId - }); + let configOptionResponse: unknown; + let usedConfigOption = false; + if (opts?.flavor === 'opencode') { + // OpenCode's `session/set_model` response only carries an opaque + // `_meta` block with no `configOptions`, so the per-session + // thought_level options captured at session/new go stale after an + // inline switch. `session/set_config_option` with configId "model" + // (OpenCode's fixed id for the model picker) echoes fresh + // `configOptions` including thought_level for the new model. + try { + configOptionResponse = await this.transport.sendRequest('session/set_config_option', { + sessionId, + configId: 'model', + value: modelId + }); + usedConfigOption = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Older OpenCode builds predate set_config_option — fall back to + // the legacy set_model path below. Any other error propagates to + // the launcher's existing catch handling. + if (!/method not found/i.test(message)) { + throw error; + } + } + } - if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { + const response = usedConfigOption + ? configOptionResponse + : await this.transport.sendRequest('session/set_model', { + sessionId, + modelId + }); + + if (usedConfigOption) { + this.captureSessionMetadata(sessionId, response); + } else if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { // OpenCode's set_model response only carries an opaque `_meta` block, // not `availableModels`/`currentModelId`. Optimistically update the // cached currentModelId (the call succeeded, so the agent has switched) // while preserving the availableModels list captured from session/new. this.updateCurrentModelOptimistic(sessionId, modelId); + if (opts.flavor === 'opencode') { + const options = this.sessionConfigOptions.get(sessionId); + if (options) { + this.sessionConfigOptions.set( + sessionId, + options.filter((option) => option.category !== 'thought_level') + ); + } + } } else { // For other flavors (e.g. Gemini), if the response carries metadata, // capture it. Missing fields are silently ignored. @@ -667,7 +715,9 @@ export class AcpSdkBackend implements AgentBackend { textChunkMode: this.options.textChunkMode, flavor: this.options.flavor, }); - this.beginPromptRequest(); + this.promptGeneration++; + this.foregroundPromptRequests++; + const promptRequestEpoch = this.beginPromptRequest(); this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; this.lastForwardedUsageUpdate = null; @@ -735,7 +785,14 @@ export class AcpSdkBackend implements AgentBackend { } } finally { this.promptUsageCallback = null; - this.finishPromptRequest(); + this.foregroundPromptRequests = Math.max(0, this.foregroundPromptRequests - 1); + if (promptRequestEpoch !== this.promptRequestEpoch) { + this.activePromptRequests = Math.max(0, this.activePromptRequests - 1); + this.isProcessingMessage = this.activePromptRequests > 0; + if (!this.isProcessingMessage) this.notifyResponseComplete(); + } else { + this.finishPromptRequest(promptRequestEpoch); + } } } } @@ -744,15 +801,12 @@ export class AcpSdkBackend implements AgentBackend { const nativeController = this.nativePromptAbortController; const nativeOptions = this.nativePromptAbortOptions; if (nativeController && nativeOptions) { - // OpenCode's ACP implementation does not expose session/cancel, - // but its native HTTP API does. Abort the server-side turn first, - // then release the local fetch so the launcher can leave remote - // mode without waiting for the provider response. const url = `${nativeOptions.baseUrl}/session/${encodeURIComponent(sessionId)}/abort?directory=${encodeURIComponent(nativeOptions.directory)}`; void fetch(url, { method: 'POST' }).catch(() => undefined); nativeController.abort(); return; } + if (!this.transport) { return; } @@ -779,14 +833,14 @@ export class AcpSdkBackend implements AgentBackend { throw new Error('No active ACP prompt to soft-steer into'); } - this.beginPromptRequest(); + const promptRequestEpoch = this.beginPromptRequest(); try { await this.transport.sendRequest('session/prompt', { sessionId, prompt: content }, { timeoutMs: Infinity }); } finally { - this.finishPromptRequest(); + this.finishPromptRequest(promptRequestEpoch); } } @@ -807,11 +861,11 @@ export class AcpSdkBackend implements AgentBackend { } const transport = this.transport; - this.beginPromptRequest(); + const promptRequestEpoch = this.beginPromptRequest(); const request = transport.sendRequestWithDispatch('session/prompt', { sessionId, prompt: content - }, { timeoutMs: Infinity }); + }, { timeoutMs: Infinity, dispatchTimeoutMs: 20_000 }); const completed = (async () => { try { await request.completed; @@ -825,7 +879,7 @@ export class AcpSdkBackend implements AgentBackend { await this.drainLateBuffers(); this.messageHandler?.drainBuffers(); } finally { - this.finishPromptRequest(); + this.finishPromptRequest(promptRequestEpoch); } } })(); @@ -945,6 +999,10 @@ export class AcpSdkBackend implements AgentBackend { return this.promptRequestInFlight; } + getPromptGeneration(): number { + return this.promptGeneration; + } + getLastSessionUpdateAt(): number { return this.lastSessionUpdateAt; } @@ -976,6 +1034,7 @@ export class AcpSdkBackend implements AgentBackend { this.messageHandler = null; this.activeSessionId = null; this.activePromptRequests = 0; + this.foregroundPromptRequests = 0; this.isProcessingMessage = false; this.sessionModelsMetadata.clear(); this.initialAvailableCommands.clear(); @@ -1256,12 +1315,35 @@ export class AcpSdkBackend implements AgentBackend { return await responsePromise; } - private beginPromptRequest(): void { + private beginPromptRequest(): number { this.activePromptRequests++; this.isProcessingMessage = true; + return this.promptRequestEpoch; } - private finishPromptRequest(): void { + /** + * Force-settle soft-steer bookkeeping without waiting for the concurrent + * `session/prompt` to finish. Called on abort: the in-flight turn is + * cancelled anyway, so a pending soft steer may never complete; dropping + * its counter keeps {@link waitForResponseComplete} from blocking the next + * turn. Bumps the epoch so a stale finish from a cancelled request cannot + * decrement a newer prompt's counter. + */ + abortSoftSteers(): void { + this.messageHandler?.drainBuffers(); + this.messageHandler?.deactivate?.(); + this.promptRequestEpoch++; + this.activePromptRequests = this.foregroundPromptRequests; + this.isProcessingMessage = this.activePromptRequests > 0; + if (!this.isProcessingMessage) { + this.notifyResponseComplete(); + } + } + + private finishPromptRequest(epoch: number): void { + if (epoch !== this.promptRequestEpoch) { + return; + } this.activePromptRequests = Math.max(0, this.activePromptRequests - 1); this.isProcessingMessage = this.activePromptRequests > 0; if (!this.isProcessingMessage) { @@ -1307,37 +1389,28 @@ export class AcpSdkBackend implements AgentBackend { } private extractPromptUsage(response: unknown): AcpPromptUsage | null { - if (!isObject(response)) return null; - // ACP returns a top-level `usage` object. OpenCode's native HTTP - // prompt returns the same counters under `info.tokens` instead. - const usage = isObject(response.usage) - ? response.usage - : isObject(response.info) && isObject(response.info.tokens) - ? response.info.tokens - : null; - if (!usage) return null; - const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens ?? usage.input); - const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens ?? usage.output); + if (!isObject(response) || !isObject(response.usage)) return null; + const usage = response.usage; + const inputTokens = this.asFiniteNumber(usage.inputTokens ?? usage.input_tokens); + const outputTokens = this.asFiniteNumber(usage.outputTokens ?? usage.output_tokens); if (inputTokens === null || outputTokens === null) return null; return { inputTokens, outputTokens, - totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens ?? usage.total) ?? undefined, - thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens ?? usage.reasoning) ?? undefined, + totalTokens: this.asFiniteNumber(usage.totalTokens ?? usage.total_tokens) ?? undefined, + thoughtTokens: this.asFiniteNumber(usage.thoughtTokens ?? usage.thought_tokens) ?? undefined, cacheReadTokens: this.asFiniteNumber( usage.cachedReadTokens ?? usage.cached_read_tokens ?? usage.cachedInputTokens ?? usage.cached_input_tokens - ?? (isObject(usage.cache) ? usage.cache.read : undefined) ) ?? undefined, cacheCreationTokens: this.asFiniteNumber( usage.cachedWriteTokens ?? usage.cached_write_tokens ?? usage.cacheCreationInputTokens ?? usage.cache_creation_input_tokens - ?? (isObject(usage.cache) ? usage.cache.write : undefined) ) ?? undefined }; } diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index 2d34eb0c..e94cf840 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -16,6 +16,20 @@ import { } from './agentCliGuard'; import { matchesAcpHttp2Cancel, matchesAcpRetryBackoff } from './acpStderrErrors'; +/** Marks transport-level failures whose request outcome is unknown (unlike an + * explicit JSON-RPC error response). */ +export const ACP_INDETERMINATE_SYMBOL = Symbol('acp-indeterminate'); + +function markAcpIndeterminate(error: Error): Error { + Object.defineProperty(error, ACP_INDETERMINATE_SYMBOL, { value: true }); + return error; +} + +export function isAcpIndeterminateError(error: unknown): boolean { + return typeof error === 'object' && error !== null + && (error as Record)[ACP_INDETERMINATE_SYMBOL] === true; +} + interface JsonRpcRequest { jsonrpc: '2.0'; id: string | number | null; @@ -68,6 +82,7 @@ export class AcpStdioTransport { private readonly pending = new Map void; reject: (error: Error) => void; + rejectDispatched: (error: Error) => void; }>(); private readonly requestHandlers = new Map(); private notificationHandler: ((method: string, params: unknown) => void) | null = null; @@ -243,19 +258,24 @@ export class AcpStdioTransport { /** Default timeout for requests in milliseconds (2 minutes) */ static readonly DEFAULT_TIMEOUT_MS = 120_000; - async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise { + async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number; dispatchTimeoutMs?: number }): Promise { const request = this.sendRequestWithDispatch(method, params, options); void request.dispatched.catch(() => {}); return request.completed; } + /** + * Split a request into transport dispatch (stdin accepted) and completion + * (JSON-RPC response). Lets callers commit state once stdin accepted the + * request without waiting for the (possibly long-running) response. + */ sendRequestWithDispatch( method: string, params?: unknown, - options?: { timeoutMs?: number } + options?: { timeoutMs?: number; dispatchTimeoutMs?: number } ): { dispatched: Promise; completed: Promise } { if (this.closed || this.exited) { - const error = this.closeError ?? this.exitError ?? new Error('ACP transport is closed'); + const error = markAcpIndeterminate(this.closeError ?? this.exitError ?? new Error('ACP transport is closed')); return { dispatched: Promise.reject(error), completed: Promise.reject(error) }; } @@ -268,53 +288,105 @@ export class AcpStdioTransport { }; const timeoutMs = options?.timeoutMs ?? AcpStdioTransport.DEFAULT_TIMEOUT_MS; + const dispatchTimeoutMs = options?.dispatchTimeoutMs ?? timeoutMs; let timer: ReturnType | null = null; + let dispatchTimer: ReturnType | null = null; + let resolveDispatched!: () => void; + let rejectDispatched!: (error: Error) => void; let resolveCompleted!: (value: unknown) => void; let rejectCompleted!: (error: Error) => void; + const dispatched = new Promise((resolve, reject) => { + resolveDispatched = resolve; + rejectDispatched = reject; + }); const completed = new Promise((resolve, reject) => { resolveCompleted = resolve; rejectCompleted = reject; }); + let dispatchSettled = false; + + const clearTimers = () => { + if (timer) clearTimeout(timer); + if (dispatchTimer) clearTimeout(dispatchTimer); + }; + const failRequest = (error: Error) => { + this.pending.delete(id); + clearTimers(); + if (!dispatchSettled) { + dispatchSettled = true; + rejectDispatched(error); + } + rejectCompleted(error); + }; if (Number.isFinite(timeoutMs)) { timer = setTimeout(() => { if (this.pending.has(id)) { - this.pending.delete(id); - rejectCompleted(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`)); + failRequest(markAcpIndeterminate(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`))); } }, timeoutMs); timer.unref(); } + if (Number.isFinite(dispatchTimeoutMs)) { + dispatchTimer = setTimeout(() => { + if (this.pending.has(id) && !dispatchSettled) { + const error = markAcpIndeterminate(new Error(`ACP request '${method}' dispatch timed out after ${dispatchTimeoutMs}ms`)); + try { + this.process.stdin.destroy(); + } catch (destroyError) { + logger.debug('[ACP] Error destroying stalled stdin', destroyError); + } + this.markClosed(error); + } + }, dispatchTimeoutMs); + dispatchTimer.unref(); + } this.pending.set(id, { resolve: (value) => { - if (timer) clearTimeout(timer); + clearTimers(); + if (!dispatchSettled) { + dispatchSettled = true; + resolveDispatched(); + } resolveCompleted(value); }, reject: (error) => { - if (timer) clearTimeout(timer); + clearTimers(); + if (!dispatchSettled) { + dispatchSettled = true; + resolveDispatched(); + } rejectCompleted(error); + }, + rejectDispatched: (error) => { + if (!dispatchSettled) { + dispatchSettled = true; + rejectDispatched(error); + } } }); - const dispatched = new Promise((resolve, reject) => { - try { - const serialized = JSON.stringify(payload); - this.process.stdin.write(`${serialized}\n`, (error) => { - if (error) { - const writeError = error instanceof Error ? error : new Error(String(error)); - this.markClosed(writeError); - reject(writeError); - return; - } - resolve(); - }); - } catch (error) { - const writeError = error instanceof Error ? error : new Error(String(error)); - this.markClosed(writeError); - reject(writeError); - } - }); + try { + const serialized = JSON.stringify(payload); + this.process.stdin.write(`${serialized}\n`, (error) => { + if (error) { + const writeError = markAcpIndeterminate(error instanceof Error ? error : new Error(String(error))); + this.markClosed(writeError); + failRequest(writeError); + return; + } + if (!dispatchSettled) { + dispatchSettled = true; + if (dispatchTimer) clearTimeout(dispatchTimer); + resolveDispatched(); + } + }); + } catch (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + this.markClosed(writeError); + failRequest(writeError); + } return { dispatched, completed }; } @@ -502,8 +574,10 @@ export class AcpStdioTransport { } private rejectAllPending(error: Error): void { - for (const { reject } of this.pending.values()) { - reject(error); + const indeterminate = markAcpIndeterminate(error); + for (const { reject, rejectDispatched } of this.pending.values()) { + rejectDispatched(indeterminate); + reject(indeterminate); } this.pending.clear(); } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 8ff67810..1ce90d19 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -16,6 +16,7 @@ import { ListDshSessionsRpcRequestSchema, ListPiSessionsRpcRequestSchema, type ArchiveCodexSessionRpcResponse, + type AgentAvailabilityResponse, type ListClaudeSessionsRpcResponse, type DshModelsResponse, type ListCodexSessionsRpcResponse, @@ -39,6 +40,10 @@ import { type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' +import { + listOpencodeModelVariants, + type ListOpencodeModelVariantsResponse +} from '../modules/common/opencodeModelVariants' import { listGrokModelsForCwd, type ListGrokModelsForCwdRequest, @@ -61,6 +66,7 @@ import { collectMachineHealth } from '@/utils/machineHealth' import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus' import { homedir } from 'node:os' import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes' +import { MachinePathPolicy } from './machinePathPolicy' type MachineRpcHandlers = { spawnSession: (options: SpawnSessionOptions) => Promise @@ -128,6 +134,7 @@ export class ApiMachineClient { private rpcHandlerManager: RpcHandlerManager private readonly normalizedWorkspaceRoots: string[] | undefined + private readonly pathPolicy: MachinePathPolicy constructor( private readonly token: string, @@ -138,6 +145,10 @@ export class ApiMachineClient { // canonical, symlink-resolved locations. Falls back to lexical // resolution if realpath fails so we still get protection. this.normalizedWorkspaceRoots = normalizeWorkspaceRoots(workspaceRoots) + this.pathPolicy = new MachinePathPolicy({ + workspaceRoots, + homeDirectory: this.machine.metadata?.homeDir ?? homedir(), + }) this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.machine.id, @@ -264,8 +275,8 @@ export class ApiMachineClient { return { success: false, error: 'cwd is required' } } - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -273,14 +284,27 @@ export class ApiMachineClient { } ) + this.rpcHandlerManager.registerHandler<{ cwd?: string | null }, ListOpencodeModelVariantsResponse>( + RPC_METHODS.ListOpencodeModelVariants, + async (params) => { + const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' + if (!rawCwd) return { success: false, error: 'cwd is required' } + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + return await listOpencodeModelVariants({ cwd: resolvedCwd }) + } + ) + this.rpcHandlerManager.registerHandler( RPC_METHODS.ListGrokModelsForCwd, async (params) => { const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' if (!rawCwd) return { success: false, error: 'cwd is required' } - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -294,8 +318,8 @@ export class ApiMachineClient { const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' if (!rawCwd) return { success: false, error: 'cwd is required' } - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } @@ -310,8 +334,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Codex sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -350,8 +374,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Claude sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -374,8 +398,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid Pi sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -398,8 +422,8 @@ export class ApiMachineClient { if (!parsed.success) return { success: false, error: 'Invalid DeepSeek Harness sessions request' } const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' if (rawCwd) { - const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { return { success: false, error: 'Path is outside workspace roots' } } } @@ -442,7 +466,7 @@ export class ApiMachineClient { const cwd = session.cwd?.trim() if (!cwd) return false const resolvedCwd = await this.resolveForWorkspaceCheck(cwd) - return this.isWithinWorkspaceRoots(resolvedCwd) + return this.pathPolicy.isWithinSpawnRoots(resolvedCwd) } private isWithinWorkspaceRoots(absolutePath: string): boolean { diff --git a/cli/src/commands/dsh.ts b/cli/src/commands/dsh.ts index 0b89fb43..49dff678 100644 --- a/cli/src/commands/dsh.ts +++ b/cli/src/commands/dsh.ts @@ -6,12 +6,17 @@ import { DSH_PERMISSION_MODES } from '@hapi/protocol' import type { CommandDefinition } from './types' import { parseRemoteAgentCommandOptions } from './agentCommandOptions' +export function parseDshCommandOptions(commandArgs: string[]) { + const options = parseRemoteAgentCommandOptions(commandArgs, DSH_PERMISSION_MODES) + return { ...options, startingMode: 'remote' as const } +} + export const dshCommand: CommandDefinition = { name: 'dsh', requiresRuntimeAssets: false, run: async ({ commandArgs }) => { try { - const options = parseRemoteAgentCommandOptions(commandArgs, DSH_PERMISSION_MODES) + const options = parseDshCommandOptions(commandArgs) await initializeToken() await maybeAutoStartServer() await authAndSetupMachineIfNeeded() diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index e1913b6f..df1bd052 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -5,7 +5,6 @@ import { claudeCommand } from './claude' import { codexCommand } from './codex' import { dshCommand } from './dsh' import { cursorCommand } from './cursor' -import { dshCommand } from './dsh' import { connectCommand } from './connect' import { runnerCommand } from './runner' import { resumeCommand } from './resume' diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index a273423f..e7fda7ff 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -13,6 +13,7 @@ import { import type { CursorSession } from './session'; import type { EnhancedMode } from './loop'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; +import { getAgentLaunchCommand } from '@/agent/agentLaunchCommand'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 069b2c11..3323d0c3 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -28,6 +28,7 @@ import { fetchOpenCodeReasoningEffortState, type OpenCodeReasoningEffortState } import type { AgentSessionConfigOptionDescriptor } from '@/agent/types'; type OpencodeRemoteLauncherOptions = { + onModelRollback?: (model: string | null) => void; onReasoningEffortRollback?: (effort: string | null) => void; // Called with `true` once the ACP backend + internal HTTP baseUrl are // ready (so /compact can actually run) and with `false` whenever this @@ -253,6 +254,26 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId); this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null; this.defaultBackendEffort = this.currentBackendEffort; + // Apply an explicitly requested startup model before the first effort + // options poll so model-specific reasoning variants are available. + const requestedStartupModel = this.session.getModel?.(); + if ( + !this.shouldExit + && typeof requestedStartupModel === 'string' + && requestedStartupModel.length > 0 + && requestedStartupModel !== this.defaultBackendModel + && typeof backend.setModel === 'function' + ) { + try { + await backend.setModel(acpSessionId, requestedStartupModel, { flavor: 'opencode' }); + this.currentBackendModel = requestedStartupModel; + const refreshedThoughtLevel = backend.getThoughtLevelConfigOption?.(acpSessionId); + this.currentBackendEffort = refreshedThoughtLevel?.currentValue ?? null; + this.defaultBackendEffort = this.currentBackendEffort; + } catch (error) { + logger.warn('[opencode-remote] Eager startup model application failed; first batch will retry inline', error); + } + } if (!thoughtLevelOption) { await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel); } @@ -417,13 +438,19 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.currentBackendModel = requestedModel; } else if (requestedModel && requestedModel !== this.currentBackendModel) { if (!backend.setModel || this.setModelSupported === false) { - batch.mode.model = this.currentBackendModel ?? undefined; + this.rollbackModel(batch, this.currentBackendModel); } else { logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`); try { await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' }); this.currentBackendModel = requestedModel; this.setModelSupported = true; + // Model changes can replace the backend's available + // effort values; refresh the descriptor before applying + // the queued turn's reasoning preference. + const refreshedInlineEffort = backend.getThoughtLevelConfigOption?.(acpSessionId); + this.currentBackendEffort = refreshedInlineEffort?.currentValue ?? null; + this.defaultBackendEffort = this.currentBackendEffort; // Reflect the resolved model back into the batch so // downstream display logic sees the concrete id rather // than a `null` placeholder. @@ -445,7 +472,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { message: `Failed to switch model to ${requestedModel}. Continuing with ${this.currentBackendModel ?? '(default)'}.` }); } - batch.mode.model = this.currentBackendModel ?? undefined; + this.rollbackModel(batch, this.currentBackendModel); } } if (requestedModel === this.currentBackendModel) { @@ -899,6 +926,13 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.options.onReasoningEffortRollback?.(effort); } + private rollbackModel(batch: { mode: OpencodeMode }, model: string | null): void { + batch.mode.model = model ?? undefined; + this.session.setModel(model); + this.session.pushKeepAlive(); + this.options.onModelRollback?.(model); + } + /** * Executes the /compact operation for a queued `operation:'compact'` * batch. Reached only through the main dequeue loop (so it never runs diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 1163258b..ea8fe70f 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -12,10 +12,10 @@ import { cliBinaryUpdatedOnDisk, isMachineCapabilitySkewed, } from '@hapi/protocol/runnerCapabilities' -import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' +import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, RewindConversationErrorCode, SlashCommandsResponse, ImplementCodexPlanResult } from '@hapi/protocol/apiTypes' import type { SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' -import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import { hasConversationMessageContent, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' import type { Store, CancelQueuedMessageResult } from '../store' @@ -30,6 +30,7 @@ import { MachineCache, type Machine } from './machineCache' import { MessageService, type RetryIndeterminateMessageResult } from './messageService' import { createTitleSuggestionService, type TitleSuggestionService } from './titleSuggestion' import { selectForkTranscriptPrefix } from './forkTranscript' +import { buildForkSessionSummary } from './forkSessionSummary' import { RpcGateway, RpcTargetMissingError, @@ -50,6 +51,7 @@ import { type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, + type RpcListOpencodeModelVariantsResponse, type RpcListGrokModelsResponse, type RpcListCopilotModelsResponse, type RpcListGrokReasoningEffortOptionsResponse, @@ -86,6 +88,7 @@ export type { RpcListPiSessionsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, + RpcListOpencodeModelVariantsResponse, RpcListGrokModelsResponse, RpcListCopilotModelsResponse, RpcListGrokReasoningEffortOptionsResponse, @@ -113,7 +116,7 @@ export type LocalResumeTargetResult = export type LocalHandoffResult = | { type: 'success' } - | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' | 'control_mode_not_applicable' } export type ClearOpencodeSessionResult = | { type: 'success'; sessionId: string } @@ -216,6 +219,16 @@ export class SyncEngine { ) { this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) + this.eventPublisher.subscribe((event) => { + if (event.type === 'message-received') { + if (!this.sessionCache.getSession(event.sessionId)?.hasConversationContent + && hasConversationMessageContent(event.message.content)) { + this.sessionCache.refreshConversationContent(event.sessionId) + } + } else if (event.type === 'message-cancelled' || event.type === 'messages-invalidated') { + this.sessionCache.refreshConversationContent(event.sessionId) + } + }) this.machineCache = new MachineCache(store, this.eventPublisher) this.messageService = new MessageService( store, @@ -413,8 +426,8 @@ export class SyncEngine { return this.messageService.getQueuedState(sessionId, localIds) } - getSessionExport(sessionId: string, session: Session): HapiSessionExportResult { - return this.messageService.getSessionExport(sessionId, session) + getSessionExport(sessionId: string, session: Session, options?: { force?: boolean }): HapiSessionExportResult { + return this.messageService.getSessionExport(sessionId, session, options) } getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] { @@ -1465,6 +1478,11 @@ export class SyncEngine { if (!rpcResult?.nativeSessionId) { return { type: 'error', message: 'Native fork did not return a session id' } } + if (rpcResult.sessionId) { + const child = await this.validateSharedChild(source, rpcResult.sessionId, rpcResult.nativeSessionId) + if (!child || child.metadata?.forkedFrom !== sessionId) return { type: 'error', message: 'Invalid shared-runtime fork binding' } + return { type: 'success', sessionId: child.id } + } // Native fork RPC can race CLI metadata/transcript updates. Construct // the child only from a fresh source snapshot, never the pre-RPC row. @@ -1485,11 +1503,13 @@ export class SyncEngine { const copiedLocalIds = new Set( prefix.flatMap((message) => (message.localId ? [message.localId] : [])) ) + const forkSummary = buildForkSessionSummary(source.metadata) const childMetadata: Record = { path: directory, host: source.metadata?.host ?? 'unknown', machineId, flavor, + ...(forkSummary ? { summary: forkSummary } : {}), forkedFrom: sessionId, startedBy: 'runner', capabilities: source.metadata?.capabilities, @@ -1650,7 +1670,7 @@ export class SyncEngine { sessionId: string, namespace: string, messageLocalId: string - ): Promise<{ type: 'success' } | { type: 'error'; message: string; hydrateFailed?: boolean }> { + ): Promise<{ type: 'success' } | { type: 'error'; message: string; code?: RewindConversationErrorCode; hydrateFailed?: boolean }> { if (this.historyActionsInFlight.has(sessionId)) { return { type: 'error', message: 'Conversation history action already in progress' } } @@ -1666,7 +1686,7 @@ export class SyncEngine { sessionId: string, namespace: string, messageLocalId: string - ): Promise<{ type: 'success' } | { type: 'error'; message: string; hydrateFailed?: boolean }> { + ): Promise<{ type: 'success' } | { type: 'error'; message: string; code?: RewindConversationErrorCode; hydrateFailed?: boolean }> { const access = this.resolveSessionAccess(sessionId, namespace) if (!access.ok) { return { type: 'error', message: access.reason === 'not-found' ? 'Session not found' : 'Access denied' } @@ -1702,7 +1722,11 @@ export class SyncEngine { } if (rpcResult?.success !== true) { - return { type: 'error', message: rpcResult?.error ?? 'Native rewind failed' } + return { + type: 'error', + message: rpcResult?.error ?? 'Native rewind failed', + ...(rpcResult?.success === false && rpcResult.code ? { code: rpcResult.code } : {}) + } } try { @@ -1713,7 +1737,13 @@ export class SyncEngine { ) this.scrubHistoryLocators(sessionId, namespace) this.sessionCache.rebuildTodosFromTranscript(sessionId) - this.eventPublisher.emit({ type: 'messages-invalidated', sessionId, namespace }) + this.eventPublisher.emit({ + type: 'messages-invalidated', + sessionId, + namespace, + reason: 'rewind', + truncateFromLocalId: rpcResult.truncateFromLocalId ?? messageLocalId + }) this.sessionCache.refreshSession(sessionId) return { type: 'success' } } catch (error) { @@ -1918,7 +1948,43 @@ export class SyncEngine { }) } + private async validateSharedChild(source: Session, id: string, nativeId?: string): Promise { + if (!source.metadata?.capabilities?.concurrentClients || id === source.id) return null + const deadline = Date.now() + 5_000 + do { + const child = this.sessionCache.refreshSession(id) + if (child && child.namespace === source.namespace + && child.metadata?.machineId === source.metadata.machineId + && child.metadata?.hostPid === source.metadata.hostPid + && child.metadata?.capabilities?.concurrentClients + && child.metadata.codexSessionId && (!nativeId || child.metadata.codexSessionId === nativeId)) return child + await new Promise(resolve => setTimeout(resolve, 50)) + } while (Date.now() < deadline) + return null + } + + async clearConversation(sessionId: string, namespace: string): Promise<{ sessionId: string }> { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok || !access.session.active || !access.session.metadata?.capabilities?.concurrentClients) { + throw new Error('Clear requires an active shared session') + } + const result = await this.rpcGateway.clearConversation(access.sessionId) + const child = await this.validateSharedChild(access.session, result.sessionId) + if (!child) throw new Error('Invalid shared-runtime clear binding') + return { sessionId: child.id } + } + + async implementCodexPlan(sessionId: string, namespace: string, planId: string): Promise { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok || !access.session.active || access.session.metadata?.flavor !== 'codex' + || !access.session.metadata.capabilities?.concurrentClients) { + return { ok: false, code: 'unavailable', error: 'Plan implementation requires an active shared Codex session' } + } + return await this.rpcGateway.implementCodexPlan(access.sessionId, planId) + } + async switchSession(sessionId: string, to: 'remote' | 'local'): Promise { + if (this.getSession(sessionId)?.metadata?.capabilities?.concurrentClients) throw new Error('control_mode_not_applicable') if (this.historyActionsInFlight.has(sessionId)) { throw new Error('Conversation history action already in progress') } @@ -3424,11 +3490,14 @@ export class SyncEngine { } } + if (access.session.metadata?.capabilities?.concurrentClients) { + return { type: 'error', message: 'Shared sessions attach without handoff', code: 'control_mode_not_applicable' } + } if (!access.session.active) { return { type: 'success' } } - if (access.session.agentState?.controlledByUser === true) { + if (access.session.agentState?.controlledByUser === true && !access.session.metadata?.capabilities?.concurrentClients) { return { type: 'error', message: 'Session is already controlled by a local terminal', @@ -3906,10 +3975,14 @@ export class SyncEngine { return false } - async checkPathsExist(machineId: string, paths: string[]): Promise> { + async checkPathsExist(machineId: string, paths: string[]): ReturnType { return await this.rpcGateway.checkPathsExist(machineId, paths) } + async getAgentAvailability(machineId: string): ReturnType { + return await this.rpcGateway.getAgentAvailability(machineId) + } + async listMachineDirectory(machineId: string, path: string, includeHidden?: boolean): Promise { return await this.rpcGateway.listMachineDirectory(machineId, path, includeHidden) } @@ -3966,8 +4039,8 @@ export class SyncEngine { return await this.rpcGateway.listSkills(sessionId, flavor) } - async listAgyModelsForMachine(machineId: string): Promise { - return await this.rpcGateway.listAgyModelsForMachine(machineId) + async listAgyModelsForMachine(machineId: string, options?: { refresh?: boolean }): Promise { + return await this.rpcGateway.listAgyModelsForMachine(machineId, options) } async listPiModelsForMachine(machineId: string): Promise { @@ -3978,6 +4051,10 @@ export class SyncEngine { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + async listOpencodeModelVariantsForMachine(machineId: string, cwd?: string | null): Promise { + return await this.rpcGateway.listOpencodeModelVariantsForMachine(machineId, cwd) + } + async listCodexModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCodexModelsForSession(sessionId) } diff --git a/shared/src/agentConfig.ts b/shared/src/agentConfig.ts index b2f56855..31727c65 100644 --- a/shared/src/agentConfig.ts +++ b/shared/src/agentConfig.ts @@ -66,7 +66,6 @@ const BUILTIN_DESCRIPTORS: Record = { { id: 'serviceTier', section: 'settings', kind: 'select', optionSource: 'model', availability: 'both' }, { id: 'collaborationMode', section: 'settings', kind: 'select', optionSource: 'static', availability: 'both' } ), - dsh: fields(MANAGED_PERMISSION), copilot: fields({ ...MODEL, optionSource: 'directory' }, PERMISSION), cursor: fields({ id: 'model', section: 'model', kind: 'dependent-select', optionSource: 'machine', availability: 'both' }, PERMISSION), dsh: fields({ ...MODEL, optionSource: 'machine' }, PERMISSION), diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 50125add..498cfe39 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -17,7 +17,6 @@ const FLAVOR_CAPS: Record> = { copilot: new Set([Capabilities.ModelChange]), grok: new Set([Capabilities.ModelChange, Capabilities.Effort]), codex: new Set([Capabilities.ModelChange]), - dsh: new Set(), cursor: new Set([Capabilities.ModelChange]), dsh: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), @@ -33,7 +32,6 @@ const FLAVOR_LABELS: Record = { copilot: 'Copilot', grok: 'Grok Build', codex: 'Codex', - dsh: 'DeepSeek Harness', cursor: 'Cursor', dsh: 'DeepSeek Harness', opencode: 'OpenCode', diff --git a/shared/src/modes.ts b/shared/src/modes.ts index c25ea5ed..1c1c0b66 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -174,6 +174,11 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe return CLAUDE_PERMISSION_MODES } +/** New Codex sessions use the native shared runtime, without HAPI Safe Yolo. */ +export function getLaunchPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] { + return getPermissionModesForFlavor(flavor).filter(mode => flavor !== 'codex' || mode !== 'safe-yolo') +} + export function getPermissionModeOptionsForFlavor(flavor?: string | null): PermissionModeOption[] { return getPermissionModesForFlavor(flavor).map((mode) => ({ mode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 273bf284..0e3a016a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -30,6 +30,7 @@ import type { SpawnResponse, VisibilityPayload, HapiSessionExport, + HapiSessionExportResponse, HubHealthResponse, SessionResponse, SessionTitleSuggestionResponse, @@ -43,6 +44,7 @@ import type { SessionShareExchangeResponse } from '@/types/api' import type { + AgentAvailabilityResponse, AgyModelsResponse, CodexModelsResponse, DshModelsResponse, @@ -60,6 +62,7 @@ import type { MachineListDirectoryResponse, MachinePathsExistsResponse, OpencodeModelsResponse, + OpencodeModelVariantsResponse, OpencodeReasoningEffortResponse, PiModelsResponse, QueuedStateResponse, @@ -497,9 +500,10 @@ export class ApiClient { ) } - async getSessionExport(sessionId: string, options?: { signal?: AbortSignal }): Promise { - return await this.request( - `/api/sessions/${encodeURIComponent(sessionId)}/export`, + async getSessionExport(sessionId: string, options?: { force?: boolean; signal?: AbortSignal }): Promise { + const query = options?.force ? '?force=true' : '' + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/export${query}`, { signal: options?.signal } ) } @@ -723,6 +727,16 @@ export class ApiClient { }) } + async clearConversation(sessionId: string): Promise<{ sessionId: string }> { + return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear`, { method: 'POST' }) + } + + async implementCodexPlan(sessionId: string, planId: string): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/codex/plan/implement`, { + method: 'POST', body: JSON.stringify({ planId }) + }) + } + async retryCodexTurn(sessionId: string): Promise<{ retried: boolean; error?: string; sessionId?: string }> { return await this.request<{ retried: boolean; error?: string; sessionId?: string }>( `/api/sessions/${encodeURIComponent(sessionId)}/retry-codex-turn`, @@ -938,6 +952,12 @@ export class ApiClient { ) } + async getMachineAgentAvailability(machineId: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/agent-availability` + ) + } + async listMachineDirectory( machineId: string, path: string, @@ -1001,9 +1021,10 @@ export class ApiClient { }) } - async getMachineAgyModels(machineId: string): Promise { + async getMachineAgyModels(machineId: string, options?: { refresh?: boolean }): Promise { + const query = options?.refresh ? '?refresh=true' : '' return await this.request( - `/api/machines/${encodeURIComponent(machineId)}/agy-models` + `/api/machines/${encodeURIComponent(machineId)}/agy-models${query}` ) } @@ -1075,6 +1096,12 @@ export class ApiClient { ) } + async getMachineOpencodeModelVariants(machineId: string, cwd?: string | null): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/opencode-model-variants${cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''}` + ) + } + async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}` diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index 00445bf1..40c12e47 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -21,7 +21,6 @@ const FLAVOR_LOGOS: Record = { agy: AntigravityColor, claude: ClaudeColor, codex: CodexColor, - dsh: DeepSeekColor, cursor: CursorMono, dsh: DeepSeekColor, gemini: GeminiColor, diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index f2ee1486..10919fb5 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { ApiClient } from '@/api/client' import type { ClaudeLocalSessionSummary, CodexDuplicateSessionGroup, CodexLocalSessionSummary, DshLocalSessionSummary, Machine, PiLocalSessionSummary } from '@/types/api' -import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS, type CodexCollaborationMode, type GrokPermissionMode, type PermissionMode, type CopilotAgentMode } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' @@ -290,7 +290,7 @@ export function NewSession(props: { setCollaborationMode(draft.collaborationMode) setCopilotAgentMode(draft.copilotAgentMode) setYoloMode(draft.yoloMode) - setCodexFamilyPermissionMode(draft.codexFamilyPermissionMode) + setCodexFamilyPermissionMode(draft.codexFamilyPermissionMode ?? 'default') setGrokPermissionMode(draft.grokPermissionMode) setSessionType(draft.sessionType) setWorktreeName(draft.worktreeName) @@ -1730,7 +1730,7 @@ export function NewSession(props: { setError(null) try { const existsResult = await checkPathsExists([trimmedDirectory]) - const directoryExists = existsResult[trimmedDirectory] + const directoryExists = existsResult.exists?.[trimmedDirectory] if (sessionType === 'worktree' && directoryExists === false) { haptic.notification('error') @@ -2035,6 +2035,7 @@ export function NewSession(props: { /> diff --git a/web/src/components/NewSession/newSessionFormDraft.ts b/web/src/components/NewSession/newSessionFormDraft.ts index 8e09775f..cefd42b5 100644 --- a/web/src/components/NewSession/newSessionFormDraft.ts +++ b/web/src/components/NewSession/newSessionFormDraft.ts @@ -25,7 +25,8 @@ export type NewSessionFormDraft = { collaborationMode: CodexCollaborationMode copilotAgentMode: CopilotAgentMode yoloMode: boolean - nativePermissionMode: PermissionMode + codexFamilyPermissionMode?: PermissionMode + nativePermissionMode?: PermissionMode grokPermissionMode: GrokPermissionMode sessionType: SessionType worktreeName: string @@ -74,6 +75,12 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null { ? normalizeCopilotAgentMode(parsed.copilotAgentMode) : 'interactive', yoloMode: Boolean(parsed.yoloMode), + codexFamilyPermissionMode: (() => { + const modes = getLaunchPermissionModesForFlavor(restoredAgent) + const parsedMode = parsed.codexFamilyPermissionMode as PermissionMode | undefined + if (agentPreserved && parsedMode && modes.includes(parsedMode)) return parsedMode + return 'default' + })(), nativePermissionMode: (() => { const modes = getLaunchPermissionModesForFlavor(restoredAgent) const parsedMode = parsed.nativePermissionMode as PermissionMode | undefined diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index b2f29839..ab7273a1 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -38,7 +38,6 @@ export const MODEL_OPTIONS: Record void sessionPersonalPinned?: boolean onSetPersonalPinned?: (pinned: boolean) => void + onMarkUnread?: () => void onExport?: () => void onStudio?: () => void onSessionShare?: () => void diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 43a1b5ae..63c3f91d 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -4,10 +4,12 @@ import { useNavigate } from '@tanstack/react-router' import { PRESERVE_SESSION_SIDEBAR_SCROLL } from '@/lib/sessionNavigation' import { AssistantRuntimeProvider, useAui, useAuiState } from '@assistant-ui/react' import { DragDropZone } from '@/components/AssistantChat/DragDropZone' -import type { ApiClient } from '@/api/client' +import { ApiError, type ApiClient } from '@/api/client' import type { + AgyModelSummary, AttachmentMetadata, CodexCollaborationMode, + CodexModelSummary, CopilotAgentMode, DecryptedMessage, PermissionMode, @@ -78,6 +80,7 @@ import { type AttachmentDraftInput, } from '@/lib/composer-attachment-drafts' import { useTranslation } from '@/lib/use-translation' +import { queryKeys } from '@/lib/query-keys' import type { SendMessageAcceptance, SendMessageSettlement } from '@/hooks/mutations/useSendMessage' import { handoffComposerDraft, transferComposerDraftThenNavigate } from '@/lib/composer-draft-transfer' import { SessionHeader } from '@/components/SessionHeader' @@ -121,6 +124,42 @@ import { useOptionalAppContext } from '@/lib/app-context' type SessionModelSelection = { provider: string; modelId: string } | string | null +export function opencodeEffortOptionsInvalidationKey( + agentFlavor: string | null | undefined, + sessionId: string +): readonly unknown[] | null { + return agentFlavor === 'opencode' ? queryKeys.sessionOpencodeReasoningEffortOptions(sessionId) : null +} + +export function isRewindForkFallbackError(error: unknown): boolean { + return error instanceof ApiError && error.code === 'ambiguous_native_boundary_fork_safe' +} + +export function buildAgyComposerModelOptions( + availableModels: AgyModelSummary[] +): Array<{ value: string; label: string }> | undefined { + if (availableModels.length === 0) return undefined + return availableModels.map((model) => ({ value: model.modelId, label: model.name ?? model.modelId })) +} + +export function shouldClearReasoningEffortForModelChange(args: { + agentFlavor: string | null | undefined + previousModelReasoningEffort: string | null + codexModels: readonly CodexModelSummary[] + model: SessionModelSelection +}): boolean { + if (!args.previousModelReasoningEffort || args.agentFlavor === 'opencode') return false + return args.agentFlavor === 'codex' && supportsCodexReasoningEffort(args.codexModels, args.model, args.previousModelReasoningEffort) === false +} + +export function mergeStagedAttachmentsInOrder( + attachments: readonly AttachmentMetadata[], + staged: readonly AttachmentMetadata[] +): AttachmentMetadata[] { + const stagedById = new Map(staged.map((attachment) => [attachment.id, attachment])) + return attachments.map((attachment) => stagedById.get(attachment.id) ?? attachment) +} + export function resolvePiContextWindow( models: PiModelSummary[] | undefined, selectedModel: { provider: string; modelId: string } | null | undefined, diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index dbc2f87f..b8f4ea95 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -121,6 +121,8 @@ export function SessionRowSummary(props: { machineLabel?: string /** Whether the owner has an active collaborative share for this session. */ shared?: boolean + /** Optional list version supplied by session-list tests/virtualized callers. */ + lastSeenVersion?: number }) { const { session: s,