From 6ed17c48d31c782e6c102b2637ef093ed4b90ae6 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 4 Feb 2026 17:29:06 +0100 Subject: [PATCH 1/6] feat: add codex --- .gitignore | 3 + apps/twig/forge.config.ts | 2 +- apps/twig/package.json | 2 +- apps/twig/scripts/download-codex-acp.mjs | 144 ++++++++++ apps/twig/src/main/services/agent/schemas.ts | 31 ++- apps/twig/src/main/services/agent/service.ts | 184 ++++++++---- apps/twig/src/main/trpc/routers/agent.ts | 23 +- .../components/AdapterIndicator.tsx | 23 ++ .../components/EditorToolbar.tsx | 6 +- .../components/MessageEditor.tsx | 10 +- .../sessions/components/ModelSelector.tsx | 72 ++++- .../components/ReasoningLevelSelector.tsx | 93 +++++++ .../sessions/components/SessionView.tsx | 3 + .../features/sessions/stores/sessionStore.ts | 158 +++++++---- .../sessions/utils/parseSessionLogs.ts | 63 +++-- .../features/settings/stores/settingsStore.ts | 27 ++ .../task-detail/components/AdapterSelect.tsx | 85 ++++++ .../task-detail/components/TaskInput.tsx | 43 +-- .../components/TaskInputEditor.tsx | 3 + .../task-detail/hooks/useTaskCreation.ts | 6 + .../src/renderer/sagas/task/task-creation.ts | 3 +- .../renderer/stores/navigationStore.test.ts | 55 +++- .../src/renderer/stores/navigationStore.ts | 2 + .../src/renderer/stores/taskDirectoryStore.ts | 2 + apps/twig/vite.main.config.mts | 38 +++ packages/agent/package.json | 2 +- packages/agent/src/acp-extensions.ts | 5 +- packages/agent/src/adapters/acp-connection.ts | 261 ++++++++++++++++-- .../agent/src/adapters/claude/claude-agent.ts | 54 ++-- .../adapters/claude/conversion/sdk-to-acp.ts | 11 +- .../src/adapters/claude/session/options.ts | 9 +- packages/agent/src/adapters/claude/types.ts | 7 +- packages/agent/src/adapters/codex/spawn.ts | 115 ++++++++ packages/agent/src/agent.ts | 26 +- packages/agent/src/index.ts | 3 + packages/agent/src/types.ts | 4 +- packages/agent/src/utils/gateway.ts | 6 +- packages/agent/src/utils/streams.ts | 51 ++++ pnpm-lock.yaml | 175 +++++++----- pnpm-workspace.yaml | 3 + 40 files changed, 1486 insertions(+), 327 deletions(-) create mode 100644 apps/twig/scripts/download-codex-acp.mjs create mode 100644 apps/twig/src/renderer/features/message-editor/components/AdapterIndicator.tsx create mode 100644 apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx create mode 100644 apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx create mode 100644 packages/agent/src/adapters/codex/spawn.ts diff --git a/.gitignore b/.gitignore index dbebd06ea..983d7be4b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ test-results/ *storybook.log storybook-static + +# Downloaded binaries +apps/twig/resources/codex-acp/ diff --git a/apps/twig/forge.config.ts b/apps/twig/forge.config.ts index 2a81c7533..6bc2073e5 100644 --- a/apps/twig/forge.config.ts +++ b/apps/twig/forge.config.ts @@ -128,7 +128,7 @@ const config: ForgeConfig = { packagerConfig: { asar: { unpack: - "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}", + "{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}", }, prune: false, name: "Twig", diff --git a/apps/twig/package.json b/apps/twig/package.json index b8bb3630d..ed5d52d58 100644 --- a/apps/twig/package.json +++ b/apps/twig/package.json @@ -25,7 +25,7 @@ "test": "vitest run", "test:e2e": "playwright test --config=tests/e2e/playwright.config.ts", "test:e2e:headed": "playwright test --config=tests/e2e/playwright.config.ts --headed", - "postinstall": "cd ../.. && npx @electron/rebuild -f -m node_modules/node-pty || true && bash apps/twig/scripts/patch-electron-name.sh", + "postinstall": "cd ../.. && npx @electron/rebuild -f -m node_modules/node-pty || true && bash apps/twig/scripts/patch-electron-name.sh && node apps/twig/scripts/download-codex-acp.mjs", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/apps/twig/scripts/download-codex-acp.mjs b/apps/twig/scripts/download-codex-acp.mjs new file mode 100644 index 000000000..0b0e85d8e --- /dev/null +++ b/apps/twig/scripts/download-codex-acp.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +import { createWriteStream, existsSync, mkdirSync, chmodSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { createGunzip } from "node:zlib"; +import { extract } from "tar"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const CODEX_ACP_VERSION = "0.9.1"; +const GITHUB_RELEASE_BASE = `https://github.com/zed-industries/codex-acp/releases/download/v${CODEX_ACP_VERSION}`; + +function getPlatformTarget() { + const platform = process.platform; + const arch = process.arch; + + if (platform === "darwin") { + return arch === "arm64" + ? "aarch64-apple-darwin" + : "x86_64-apple-darwin"; + } + + if (platform === "linux") { + return arch === "arm64" + ? "aarch64-unknown-linux-gnu" + : "x86_64-unknown-linux-gnu"; + } + + if (platform === "win32") { + return arch === "arm64" + ? "aarch64-pc-windows-msvc" + : "x86_64-pc-windows-msvc"; + } + + throw new Error(`Unsupported platform: ${platform}-${arch}`); +} + +function getDownloadUrl(target) { + const ext = target.includes("windows") ? "zip" : "tar.gz"; + return `${GITHUB_RELEASE_BASE}/codex-acp-${CODEX_ACP_VERSION}-${target}.${ext}`; +} + +function getBinaryName() { + return process.platform === "win32" ? "codex-acp.exe" : "codex-acp"; +} + +async function downloadFile(url, destPath) { + console.log(`Downloading ${url}...`); + + const response = await fetch(url, { redirect: "follow" }); + + if (!response.ok) { + throw new Error(`Failed to download: ${response.status} ${response.statusText}`); + } + + const fileStream = createWriteStream(destPath); + await pipeline(response.body, fileStream); + + console.log(`Downloaded to ${destPath}`); +} + +async function extractTarGz(archivePath, destDir) { + console.log(`Extracting ${archivePath} to ${destDir}...`); + + await extract({ + file: archivePath, + cwd: destDir, + }); + + console.log("Extraction complete"); +} + +async function extractZip(archivePath, destDir) { + const { default: AdmZip } = await import("adm-zip"); + console.log(`Extracting ${archivePath} to ${destDir}...`); + + const zip = new AdmZip(archivePath); + zip.extractAllTo(destDir, true); + + console.log("Extraction complete"); +} + +async function main() { + const destDir = join(__dirname, "..", "resources", "codex-acp"); + const binaryName = getBinaryName(); + const binaryPath = join(destDir, binaryName); + + if (existsSync(binaryPath)) { + console.log(`codex-acp binary already exists at ${binaryPath}`); + return; + } + + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + + const target = getPlatformTarget(); + const url = getDownloadUrl(target); + const isZip = url.endsWith(".zip"); + const archivePath = join(destDir, isZip ? "codex-acp.zip" : "codex-acp.tar.gz"); + + await downloadFile(url, archivePath); + + if (isZip) { + await extractZip(archivePath, destDir); + } else { + await extractTarGz(archivePath, destDir); + } + + if (existsSync(binaryPath)) { + if (process.platform !== "win32") { + chmodSync(binaryPath, 0o755); + } + + if (process.platform === "darwin") { + try { + execSync(`xattr -cr "${binaryPath}"`, { stdio: "inherit" }); + console.log("Cleared extended attributes"); + } catch { + console.log("No extended attributes to clear"); + } + + try { + execSync(`codesign --force --sign - "${binaryPath}"`, { stdio: "inherit" }); + console.log("Ad-hoc signed binary for macOS"); + } catch (err) { + console.warn("Failed to ad-hoc sign binary:", err.message); + } + } + + console.log(`codex-acp binary ready at ${binaryPath}`); + } else { + console.error(`Binary not found after extraction. Expected: ${binaryPath}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Failed to download codex-acp:", err); + process.exit(1); +}); diff --git a/apps/twig/src/main/services/agent/schemas.ts b/apps/twig/src/main/services/agent/schemas.ts index cb51e3537..0573734bc 100644 --- a/apps/twig/src/main/services/agent/schemas.ts +++ b/apps/twig/src/main/services/agent/schemas.ts @@ -21,9 +21,11 @@ export const sessionConfigSchema = z.object({ repoPath: z.string(), credentials: credentialsSchema, logUrl: z.string().optional(), - sdkSessionId: z.string().optional(), + /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ + sessionId: z.string().optional(), model: z.string().optional(), executionMode: executionModeSchema.optional(), + adapter: z.enum(["claude", "codex"]).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), }); @@ -46,6 +48,7 @@ export const startSessionInput = z.object({ .enum(["default", "acceptEdits", "plan", "bypassPermissions"]) .optional(), runMode: z.enum(["local", "cloud"]).optional(), + adapter: z.enum(["claude", "codex"]).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), }); @@ -124,7 +127,9 @@ export const reconnectSessionInput = z.object({ apiHost: z.string(), projectId: z.number(), logUrl: z.string().optional(), - sdkSessionId: z.string().optional(), + sessionId: z.string().optional(), + adapter: z.enum(["claude", "codex"]).optional(), + model: z.string().optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), }); @@ -148,9 +153,16 @@ export const setModeInput = z.object({ modeId: executionModeSchema, }); +// Set config option input (for Codex reasoning level, etc.) +export const setConfigOptionInput = z.object({ + sessionId: z.string(), + configId: z.string(), + value: z.string(), +}); + // Subscribe to session events input export const subscribeSessionInput = z.object({ - sessionId: z.string(), + taskRunId: z.string(), }); // Agent events @@ -160,12 +172,17 @@ export const AgentServiceEvent = { } as const; export interface AgentSessionEventPayload { - sessionId: string; + taskRunId: string; payload: unknown; } export type PermissionOption = SdkPermissionOption; -export type PermissionRequestPayload = RequestPermissionRequest; +export type PermissionRequestPayload = Omit< + RequestPermissionRequest, + "sessionId" +> & { + taskRunId: string; +}; export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; @@ -174,7 +191,7 @@ export interface AgentServiceEvents { // Permission response input for tRPC export const respondToPermissionInput = z.object({ - sessionId: z.string(), + taskRunId: z.string(), toolCallId: z.string(), optionId: z.string(), // For "Other" option: custom text input from user (ACP extension via _meta) @@ -187,7 +204,7 @@ export type RespondToPermissionInput = z.infer; // Permission cancellation input for tRPC export const cancelPermissionInput = z.object({ - sessionId: z.string(), + taskRunId: z.string(), toolCallId: z.string(), }); diff --git a/apps/twig/src/main/services/agent/service.ts b/apps/twig/src/main/services/agent/service.ts index 2cdba5a58..d400e4e2a 100644 --- a/apps/twig/src/main/services/agent/service.ts +++ b/apps/twig/src/main/services/agent/service.ts @@ -168,9 +168,11 @@ interface SessionConfig { repoPath: string; credentials: Credentials; logUrl?: string; - sdkSessionId?: string; + /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ + sessionId?: string; model?: string; executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories?: string[]; } @@ -197,6 +199,7 @@ interface ManagedSession { description?: string | null; }>; currentModelId?: string; + sessionId: string; } function getClaudeCliPath(): string { @@ -206,10 +209,17 @@ function getClaudeCliPath(): string { : join(appPath, ".vite/build/claude-cli/cli.js"); } +function getCodexBinaryPath(): string { + const appPath = app.getAppPath(); + return app.isPackaged + ? join(`${appPath}.unpacked`, ".vite/build/codex-acp/codex-acp") + : join(appPath, ".vite/build/codex-acp/codex-acp"); +} + interface PendingPermission { resolve: (response: RequestPermissionResponse) => void; reject: (error: Error) => void; - sessionId: string; + taskRunId: string; toolCallId: string; } @@ -268,22 +278,22 @@ export class AgentService extends TypedEventEmitter { * This resolves the promise that the agent is waiting on. */ public respondToPermission( - sessionId: string, + taskRunId: string, toolCallId: string, optionId: string, customInput?: string, answers?: Record, ): void { - const key = `${sessionId}:${toolCallId}`; + const key = `${taskRunId}:${toolCallId}`; const pending = this.pendingPermissions.get(key); if (!pending) { - log.warn("No pending permission found", { sessionId, toolCallId }); + log.warn("No pending permission found", { taskRunId, toolCallId }); return; } log.info("Permission response received", { - sessionId, + taskRunId, toolCallId, optionId, hasCustomInput: !!customInput, @@ -309,19 +319,19 @@ export class AgentService extends TypedEventEmitter { * Cancel a pending permission request. * This resolves the promise with a "cancelled" outcome per ACP spec. */ - public cancelPermission(sessionId: string, toolCallId: string): void { - const key = `${sessionId}:${toolCallId}`; + public cancelPermission(taskRunId: string, toolCallId: string): void { + const key = `${taskRunId}:${toolCallId}`; const pending = this.pendingPermissions.get(key); if (!pending) { log.warn("No pending permission found to cancel", { - sessionId, + taskRunId, toolCallId, }); return; } - log.info("Permission cancelled", { sessionId, toolCallId }); + log.info("Permission cancelled", { taskRunId, toolCallId }); pending.resolve({ outcome: { @@ -407,9 +417,10 @@ export class AgentService extends TypedEventEmitter { repoPath, credentials, logUrl, - sdkSessionId, + sessionId: existingSessionId, model, executionMode, + adapter, additionalDirectories, } = config; @@ -442,6 +453,9 @@ export class AgentService extends TypedEventEmitter { try { const acpConnection = await agent.run(taskId, taskRunId, { + adapter, + model, + codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined, processCallbacks: { onProcessSpawned: (info) => { this.processTracking.register( @@ -480,14 +494,25 @@ export class AgentService extends TypedEventEmitter { }, }); - const mcpServers = this.buildMcpServers(credentials); + const mcpServers = + adapter === "codex" ? [] : this.buildMcpServers(credentials); let availableModels: | Array<{ modelId: string; name: string; description?: string | null }> | undefined; let currentModelId: string | undefined; + let agentSessionId: string; - if (isReconnect) { + if (isReconnect && adapter === "codex" && config.sessionId) { + const loadResponse = await connection.loadSession({ + sessionId: config.sessionId, + cwd: repoPath, + mcpServers, + }); + availableModels = loadResponse.models?.availableModels; + currentModelId = loadResponse.models?.currentModelId; + agentSessionId = config.sessionId; + } else if (isReconnect && adapter !== "codex") { const systemPrompt = this.buildPostHogSystemPrompt(credentials); const resumeResponse = await connection.extMethod( "_posthog/session/resume", @@ -499,7 +524,8 @@ export class AgentService extends TypedEventEmitter { ...(logUrl && { persistence: { taskId, runId: taskRunId, logUrl }, }), - ...(sdkSessionId && { sdkSessionId }), + taskRunId, + ...(existingSessionId && { sessionId: existingSessionId }), systemPrompt, ...(additionalDirectories?.length && { claudeCode: { @@ -519,13 +545,14 @@ export class AgentService extends TypedEventEmitter { | undefined; availableModels = resumeMeta?.models?.availableModels; currentModelId = resumeMeta?.models?.currentModelId; + agentSessionId = (resumeResponse?.sessionId as string) ?? taskRunId; } else { const systemPrompt = this.buildPostHogSystemPrompt(credentials); const newSessionResponse = await connection.newSession({ cwd: repoPath, mcpServers, _meta: { - sessionId: taskRunId, + taskRunId, model, systemPrompt, ...(executionMode && { initialModeId: executionMode }), @@ -538,8 +565,11 @@ export class AgentService extends TypedEventEmitter { }); availableModels = newSessionResponse.models?.availableModels; currentModelId = newSessionResponse.models?.currentModelId; + agentSessionId = newSessionResponse.sessionId; } + config.sessionId = agentSessionId; + const session: ManagedSession = { taskRunId, taskId, @@ -555,6 +585,7 @@ export class AgentService extends TypedEventEmitter { promptPending: false, availableModels, currentModelId, + sessionId: agentSessionId, }; this.sessions.set(taskRunId, session); @@ -658,7 +689,7 @@ export class AgentService extends TypedEventEmitter { try { const result = await session.clientSideConnection.prompt({ - sessionId, + sessionId: session.sessionId, prompt: finalPrompt, }); return { @@ -670,7 +701,7 @@ export class AgentService extends TypedEventEmitter { log.warn("Auth error during prompt, recreating session", { sessionId }); session = await this.recreateSession(sessionId); const result = await session.clientSideConnection.prompt({ - sessionId, + sessionId: session.sessionId, prompt: finalPrompt, }); return { @@ -714,7 +745,7 @@ export class AgentService extends TypedEventEmitter { try { await session.clientSideConnection.cancel({ - sessionId, + sessionId: session.sessionId, _meta: reason ? { interruptReason: reason } : undefined, }); if (reason) { @@ -740,7 +771,7 @@ export class AgentService extends TypedEventEmitter { try { await session.clientSideConnection.unstable_setSessionModel({ - sessionId, + sessionId: session.sessionId, modelId, }); log.info("Session model updated", { sessionId, modelId }); @@ -757,7 +788,10 @@ export class AgentService extends TypedEventEmitter { } try { - await session.clientSideConnection.setSessionMode({ sessionId, modeId }); + await session.clientSideConnection.setSessionMode({ + sessionId: session.sessionId, + modeId, + }); log.info("Session mode updated", { sessionId, modeId }); } catch (err) { log.error("Failed to set session mode", { sessionId, modeId, err }); @@ -765,6 +799,34 @@ export class AgentService extends TypedEventEmitter { } } + async setSessionConfigOption( + sessionId: string, + configId: string, + value: string, + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + try { + await session.clientSideConnection.setSessionConfigOption({ + sessionId: session.sessionId, + configId, + value, + }); + log.info("Session config option updated", { sessionId, configId, value }); + } catch (err) { + log.error("Failed to set session config option", { + sessionId, + configId, + value, + err, + }); + throw err; + } + } + listSessions(taskId?: string): ManagedSession[] { const all = Array.from(this.sessions.values()); return taskId ? all.filter((s) => s.taskId === taskId) : all; @@ -966,7 +1028,7 @@ For git operations while detached: const emitToRenderer = (payload: unknown) => { // Emit event via TypedEventEmitter for tRPC subscription this.emit(AgentServiceEvent.SessionEvent, { - sessionId: taskRunId, + taskRunId, payload, }); }; @@ -1003,7 +1065,7 @@ For git operations while detached: const toolCallId = params.toolCall?.toolCallId || ""; log.info("requestPermission called", { - sessionId: taskRunId, + taskRunId, toolCallId, toolName, title: params.toolCall?.title, @@ -1022,15 +1084,19 @@ For git operations while detached: service.pendingPermissions.set(key, { resolve, reject, - sessionId: taskRunId, + taskRunId, toolCallId, }); log.info("Emitting permission request to renderer", { - sessionId: taskRunId, + taskRunId, toolCallId, }); - service.emit(AgentServiceEvent.PermissionRequest, params); + const { sessionId: _agentSessionId, ...rest } = params; + service.emit(AgentServiceEvent.PermissionRequest, { + ...rest, + taskRunId, + }); }, ); } finally { @@ -1043,7 +1109,7 @@ For git operations while detached: // Fallback: no toolCallId means we can't track the response, auto-approve log.warn("No toolCallId in permission request, auto-approving", { - sessionId: taskRunId, + taskRunId, toolName, }); const allowOption = params.options.find( @@ -1066,14 +1132,26 @@ For git operations while detached: params: Record, ): Promise => { if (method === "_posthog/sdk_session") { - const { sessionId, sdkSessionId } = params as { + const { + taskRunId: notifTaskRunId, + sessionId, + adapter: notifAdapter, + } = params as { + taskRunId: string; sessionId: string; - sdkSessionId: string; + adapter: "claude" | "codex"; }; - const session = this.sessions.get(sessionId); + const session = this.sessions.get(notifTaskRunId); if (session) { - session.config.sdkSessionId = sdkSessionId; - log.info("SDK session ID captured", { sessionId, sdkSessionId }); + session.config.sessionId = sessionId; + if (notifAdapter) { + session.config.adapter = notifAdapter; + } + log.info("Session ID captured", { + taskRunId: notifTaskRunId, + sessionId, + adapter: notifAdapter, + }); } } @@ -1081,6 +1159,7 @@ For git operations while detached: // The extNotification callback doesn't write to the stream, so we need // to manually emit these to the renderer if ( + method === "_posthog/sdk_session" || method === "_posthog/status" || method === "_posthog/task_notification" || method === "_posthog/compact_boundary" @@ -1132,10 +1211,11 @@ For git operations while detached: projectId: params.projectId, }, logUrl: "logUrl" in params ? params.logUrl : undefined, - sdkSessionId: "sdkSessionId" in params ? params.sdkSessionId : undefined, + sessionId: "sessionId" in params ? params.sessionId : undefined, model: "model" in params ? params.model : undefined, executionMode: "executionMode" in params ? params.executionMode : undefined, + adapter: "adapter" in params ? params.adapter : undefined, additionalDirectories: "additionalDirectories" in params ? params.additionalDirectories @@ -1264,30 +1344,32 @@ For git operations while detached: const gatewayUrl = getLlmGatewayUrl(apiHost); const models = await fetchGatewayModels({ gatewayUrl }); - const MODEL_TIER_ORDER = ["opus", "sonnet", "haiku"]; - - const getModelTier = (modelId: string): number => { - const lowerId = modelId.toLowerCase(); - for (let i = 0; i < MODEL_TIER_ORDER.length; i++) { - if (lowerId.includes(MODEL_TIER_ORDER[i])) return i; - } - return MODEL_TIER_ORDER.length; - }; - - // TODO: Re-enable OpenAI models once the upstream gateway issue is fixed. - const filteredModels = models.filter( - (model) => model.owned_by !== "openai" && !model.id.startsWith("openai/"), - ); - - const mapped = filteredModels.map((model) => ({ + const mapped = models.map((model) => ({ modelId: model.id, name: formatGatewayModelName(model), description: `Context: ${model.context_window.toLocaleString()} tokens`, provider: getProviderName(model.owned_by), })); - return mapped.sort( - (a, b) => getModelTier(a.modelId) - getModelTier(b.modelId), - ); + const CLAUDE_TIER_ORDER = ["opus", "sonnet", "haiku"]; + const getModelTier = (modelId: string): number => { + const lowerId = modelId.toLowerCase(); + for (let i = 0; i < CLAUDE_TIER_ORDER.length; i++) { + if (lowerId.includes(CLAUDE_TIER_ORDER[i])) return i; + } + return CLAUDE_TIER_ORDER.length; + }; + + return mapped.sort((a, b) => { + const providerOrder = ["Anthropic", "OpenAI", "Gemini"]; + const aProviderIdx = providerOrder.indexOf(a.provider ?? ""); + const bProviderIdx = providerOrder.indexOf(b.provider ?? ""); + if (aProviderIdx !== bProviderIdx) { + const aIdx = aProviderIdx === -1 ? 999 : aProviderIdx; + const bIdx = bProviderIdx === -1 ? 999 : bProviderIdx; + return aIdx - bIdx; + } + return getModelTier(a.modelId) - getModelTier(b.modelId); + }); } } diff --git a/apps/twig/src/main/trpc/routers/agent.ts b/apps/twig/src/main/trpc/routers/agent.ts index ec5a565a9..81f436f26 100644 --- a/apps/twig/src/main/trpc/routers/agent.ts +++ b/apps/twig/src/main/trpc/routers/agent.ts @@ -16,6 +16,7 @@ import { reconnectSessionInput, respondToPermissionInput, sessionResponseSchema, + setConfigOptionInput, setModeInput, setModelInput, startSessionInput, @@ -71,17 +72,27 @@ export const agentRouter = router({ getService().setSessionMode(input.sessionId, input.modeId), ), + setConfigOption: publicProcedure + .input(setConfigOptionInput) + .mutation(({ input }) => + getService().setSessionConfigOption( + input.sessionId, + input.configId, + input.value, + ), + ), + onSessionEvent: publicProcedure .input(subscribeSessionInput) .subscription(async function* (opts) { const service = getService(); - const targetSessionId = opts.input.sessionId; + const targetTaskRunId = opts.input.taskRunId; const iterable = service.toIterable(AgentServiceEvent.SessionEvent, { signal: opts.signal, }); for await (const event of iterable) { - if (event.sessionId === targetSessionId) { + if (event.taskRunId === targetTaskRunId) { yield event.payload; } } @@ -92,13 +103,13 @@ export const agentRouter = router({ .input(subscribeSessionInput) .subscription(async function* (opts) { const service = getService(); - const targetSessionId = opts.input.sessionId; + const targetTaskRunId = opts.input.taskRunId; const iterable = service.toIterable(AgentServiceEvent.PermissionRequest, { signal: opts.signal, }); for await (const event of iterable) { - if (event.sessionId === targetSessionId) { + if (event.taskRunId === targetTaskRunId) { yield event; } } @@ -109,7 +120,7 @@ export const agentRouter = router({ .input(respondToPermissionInput) .mutation(({ input }) => getService().respondToPermission( - input.sessionId, + input.taskRunId, input.toolCallId, input.optionId, input.customInput, @@ -121,7 +132,7 @@ export const agentRouter = router({ cancelPermission: publicProcedure .input(cancelPermissionInput) .mutation(({ input }) => - getService().cancelPermission(input.sessionId, input.toolCallId), + getService().cancelPermission(input.taskRunId, input.toolCallId), ), listSessions: publicProcedure diff --git a/apps/twig/src/renderer/features/message-editor/components/AdapterIndicator.tsx b/apps/twig/src/renderer/features/message-editor/components/AdapterIndicator.tsx new file mode 100644 index 000000000..3fce025f4 --- /dev/null +++ b/apps/twig/src/renderer/features/message-editor/components/AdapterIndicator.tsx @@ -0,0 +1,23 @@ +import { Robot } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; + +interface AdapterIndicatorProps { + adapter: "claude" | "codex"; +} + +export function AdapterIndicator({ adapter }: AdapterIndicatorProps) { + return ( + + + + {adapter} + + + ); +} diff --git a/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx b/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx index 72a2e35a6..bc6d70ceb 100644 --- a/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/EditorToolbar.tsx @@ -1,4 +1,5 @@ import { ModelSelector } from "@features/sessions/components/ModelSelector"; +import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; import { Paperclip } from "@phosphor-icons/react"; import { Flex, IconButton, Tooltip } from "@radix-ui/themes"; import { useRef } from "react"; @@ -7,6 +8,7 @@ import type { MentionChip } from "../utils/content"; interface EditorToolbarProps { disabled?: boolean; taskId?: string; + adapter?: "claude" | "codex"; onInsertChip: (chip: MentionChip) => void; onAttachFiles?: (files: File[]) => void; attachTooltip?: string; @@ -16,6 +18,7 @@ interface EditorToolbarProps { export function EditorToolbar({ disabled = false, taskId, + adapter, onInsertChip, onAttachFiles, attachTooltip = "Attach file", @@ -65,7 +68,8 @@ export function EditorToolbar({ - + + ); } diff --git a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx index 1e70e081f..bf16156b0 100644 --- a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -10,6 +10,7 @@ import { useDraftStore } from "../stores/draftStore"; import { useTiptapEditor } from "../tiptap/useTiptapEditor"; import type { EditorHandle } from "../types"; import type { EditorContent as EditorContentType } from "../utils/content"; +import { AdapterIndicator } from "./AdapterIndicator"; import { DiffStatsIndicator } from "./DiffStatsIndicator"; import { EditorToolbar } from "./EditorToolbar"; import { ModeIndicatorInput } from "./ModeIndicatorInput"; @@ -28,6 +29,7 @@ interface MessageEditorProps { autoFocus?: boolean; currentMode?: ExecutionMode; onModeChange?: () => void; + adapter?: "claude" | "codex"; } export const MessageEditor = forwardRef( @@ -43,6 +45,7 @@ export const MessageEditor = forwardRef( autoFocus = false, currentMode, onModeChange, + adapter, }, ref, ) => { @@ -206,9 +209,12 @@ export const MessageEditor = forwardRef( )} - {onModeChange && currentMode && ( + {(onModeChange || adapter) && ( - + {onModeChange && currentMode && ( + + )} + {adapter && } )} diff --git a/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx b/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx index e651f56ea..834273389 100644 --- a/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx +++ b/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx @@ -1,18 +1,60 @@ import { Select, Text } from "@radix-ui/themes"; -import { Fragment } from "react"; -import { useModelsStore } from "../stores/modelsStore"; +import { Fragment, useEffect, useMemo } from "react"; +import { + type GroupedModels, + type ModelOption, + useModelsStore, +} from "../stores/modelsStore"; import { useSessionActions, useSessionForTask } from "../stores/sessionStore"; interface ModelSelectorProps { taskId?: string; disabled?: boolean; onModelChange?: (modelId: string) => void; + adapter?: "claude" | "codex"; +} + +function getProviderForAdapter(adapter: "claude" | "codex"): string { + return adapter === "claude" ? "Anthropic" : "OpenAI"; +} + +function filterModelsByAdapter( + grouped: GroupedModels[], + adapter?: "claude" | "codex", +): GroupedModels[] { + if (!adapter) return grouped; + const allowedProvider = getProviderForAdapter(adapter); + return grouped.filter((group) => group.provider === allowedProvider); +} + +function isModelCompatibleWithAdapter( + model: ModelOption | undefined, + adapter: "claude" | "codex" | undefined, +): boolean { + if (!model || !adapter) return true; + const allowedProvider = getProviderForAdapter(adapter); + return model.provider === allowedProvider; +} + +function getDefaultModelForAdapter( + models: ModelOption[], + adapter: "claude" | "codex", +): string | undefined { + const allowedProvider = getProviderForAdapter(adapter); + const compatibleModel = models.find((m) => m.provider === allowedProvider); + return compatibleModel?.modelId; +} + +function stripReasoningSuffix(modelId: string | undefined): string | undefined { + if (!modelId) return modelId; + return modelId.replace(/\/(minimal|low|medium|high|xhigh)$/, ""); } export function ModelSelector({ taskId, disabled, onModelChange, + adapter, }: ModelSelectorProps) { const { setSessionModel } = useSessionActions(); const session = useSessionForTask(taskId); @@ -22,7 +64,28 @@ export function ModelSelector({ const selectedModel = useModelsStore((s) => s.selectedModel); const setSelectedModel = useModelsStore((s) => s.setSelectedModel); - const activeModel = session?.model ?? selectedModel; + const effectiveAdapter = adapter ?? session?.adapter; + const filteredGroupedModels = useMemo( + () => filterModelsByAdapter(groupedModels, effectiveAdapter), + [groupedModels, effectiveAdapter], + ); + + const rawSessionModel = session?.model; + const sessionModel = stripReasoningSuffix(rawSessionModel); + const activeModel = sessionModel ?? selectedModel; + const currentModel = models.find((m) => m.modelId === activeModel); + + useEffect(() => { + if (!effectiveAdapter || !models.length) return; + + if (!isModelCompatibleWithAdapter(currentModel, effectiveAdapter)) { + const defaultModel = getDefaultModelForAdapter(models, effectiveAdapter); + if (defaultModel) { + setSelectedModel(defaultModel); + onModelChange?.(defaultModel); + } + } + }, [effectiveAdapter, currentModel, models, setSelectedModel, onModelChange]); const handleChange = (value: string) => { setSelectedModel(value); @@ -33,7 +96,6 @@ export function ModelSelector({ } }; - const currentModel = models.find((m) => m.modelId === activeModel); const displayName = currentModel?.name ?? activeModel; return ( @@ -59,7 +121,7 @@ export function ModelSelector({ - {groupedModels.map((group, index) => ( + {filteredGroupedModels.map((group, index) => ( {index > 0 && } diff --git a/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx b/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx new file mode 100644 index 000000000..6258fc395 --- /dev/null +++ b/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx @@ -0,0 +1,93 @@ +import { Select, Text } from "@radix-ui/themes"; +import { + type CodexReasoningLevel, + useCodexReasoningLevelForTask, + useSessionActions, + useSessionForTask, +} from "../stores/sessionStore"; + +interface ReasoningLevelSelectorProps { + taskId?: string; + disabled?: boolean; +} + +const REASONING_LEVELS: { value: CodexReasoningLevel; label: string }[] = [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, +]; + +function supportsConfigurableReasoning(model: string | undefined): boolean { + if (!model) return false; + const normalizedModel = model.toLowerCase(); + return normalizedModel.includes("gpt-5.2"); +} + +function extractReasoningFromModelId( + modelId: string | undefined, +): CodexReasoningLevel | undefined { + if (!modelId) return undefined; + const match = modelId.match(/\/(low|medium|high|xhigh)$/); + return match ? (match[1] as CodexReasoningLevel) : undefined; +} + +export function ReasoningLevelSelector({ + taskId, + disabled, +}: ReasoningLevelSelectorProps) { + const { setCodexReasoningLevel } = useSessionActions(); + const session = useSessionForTask(taskId); + const reasoningLevel = useCodexReasoningLevelForTask(taskId); + + const isCodex = session?.adapter === "codex"; + const hasConfigurableReasoning = supportsConfigurableReasoning( + session?.model, + ); + + if (!isCodex || !hasConfigurableReasoning) { + return null; + } + + const levelFromModelId = extractReasoningFromModelId(session?.model); + const activeLevel = reasoningLevel ?? levelFromModelId ?? "medium"; + + const handleChange = (value: string) => { + if (taskId && session?.status === "connected" && !session.isCloud) { + setCodexReasoningLevel(taskId, value as CodexReasoningLevel); + } + }; + + return ( + + + + Reasoning:{" "} + {REASONING_LEVELS.find((l) => l.value === activeLevel)?.label} + + + + {REASONING_LEVELS.map((level) => ( + + {level.label} + + ))} + + + ); +} diff --git a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx index e59474f79..da012f9c1 100644 --- a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx @@ -7,6 +7,7 @@ import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { cycleExecutionMode, type ExecutionMode, + useAdapterForTask, useCurrentModeForTask, usePendingPermissionsForTask, useSessionActions, @@ -73,6 +74,7 @@ export function SessionView({ const { respondToPermission, cancelPermission, setSessionMode } = useSessionActions(); const sessionMode = useCurrentModeForTask(taskId); + const adapter = useAdapterForTask(taskId); const { allowBypassPermissions } = useSettingsStore(); const currentMode: ExecutionMode = sessionMode ?? "default"; @@ -425,6 +427,7 @@ export function SessionView({ onCancel={onCancelPrompt} currentMode={currentMode} onModeChange={!isCloud ? handleModeChange : undefined} + adapter={adapter} /> )} diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts index 193a3959a..00135ee9f 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts @@ -27,7 +27,10 @@ import { getCloudUrlFromRegion } from "@/constants/oauth"; import { getIsOnline } from "@/renderer/stores/connectivityStore"; import { trpcVanilla } from "@/renderer/trpc"; import { ANALYTICS_EVENTS } from "@/types/analytics"; -import type { PermissionRequest } from "../utils/parseSessionLogs"; +import { + fetchSessionLogs, + type PermissionRequest, +} from "../utils/parseSessionLogs"; import { useModelsStore } from "./modelsStore"; import { getPersistedTaskMode, setPersistedTaskMode } from "./sessionModeStore"; @@ -73,6 +76,8 @@ export interface QueuedMessage { queuedAt: number; } +export type CodexReasoningLevel = "low" | "medium" | "high" | "xhigh"; + export interface AgentSession { taskRunId: string; taskId: string; @@ -88,11 +93,12 @@ export interface AgentSession { logUrl?: string; processedLineCount?: number; model?: string; + codexReasoningLevel?: CodexReasoningLevel; availableModels?: AgentModelOption[]; framework?: "claude"; + adapter?: "claude" | "codex"; currentMode: ExecutionMode; pendingPermissions: Map; - // Queue of messages to send when current turn completes messageQueue: QueuedMessage[]; } @@ -106,6 +112,7 @@ interface SessionActions { repoPath: string; initialPrompt?: ContentBlock[]; executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; }) => Promise; disconnectFromTask: (taskId: string) => Promise; sendPrompt: ( @@ -115,6 +122,10 @@ interface SessionActions { cancelPrompt: (taskId: string) => Promise; setSessionModel: (taskId: string, modelId: string) => Promise; setSessionMode: (taskId: string, modeId: ExecutionMode) => Promise; + setCodexReasoningLevel: ( + taskId: string, + level: CodexReasoningLevel, + ) => Promise; appendUserShellExecute: ( taskId: string, command: string, @@ -165,7 +176,7 @@ function subscribeToChannel(taskRunId: string) { } const eventSubscription = trpcVanilla.agent.onSessionEvent.subscribe( - { sessionId: taskRunId }, + { taskRunId }, { onData: (payload: unknown) => { useStore.setState((state) => { @@ -247,6 +258,25 @@ function subscribeToChannel(taskRunId: string) { } } } + + // Handle _posthog/sdk_session notifications (adapter info) + if ( + "method" in msg && + msg.method === "_posthog/sdk_session" && + "params" in msg + ) { + const params = msg.params as { + adapter?: "claude" | "codex"; + sessionId?: string; + }; + if (params.adapter) { + session.adapter = params.adapter; + log.info("Session adapter updated", { + taskRunId, + adapter: params.adapter, + }); + } + } } }); @@ -286,7 +316,7 @@ function subscribeToChannel(taskRunId: string) { // Subscribe to permission requests (for AskUserQuestion, ExitPlanMode, etc.) const permissionSubscription = trpcVanilla.agent.onPermissionRequest.subscribe( - { sessionId: taskRunId }, + { taskRunId }, { onData: async (payload) => { log.info("Permission request received in renderer", { @@ -444,43 +474,6 @@ function shellExecutesToContextBlocks( })); } -async function fetchSessionLogs( - logUrl: string, -): Promise<{ rawEntries: StoredLogEntry[]; sdkSessionId?: string }> { - if (!logUrl) return { rawEntries: [] }; - - try { - const content = await trpcVanilla.logs.fetchS3Logs.query({ logUrl }); - if (!content?.trim()) return { rawEntries: [] }; - - const rawEntries: StoredLogEntry[] = []; - let sdkSessionId: string | undefined; - - for (const line of content.trim().split("\n")) { - try { - const stored = JSON.parse(line) as StoredLogEntry; - rawEntries.push(stored); - - if ( - stored.type === "notification" && - stored.notification?.method?.endsWith("posthog/sdk_session") - ) { - const params = stored.notification.params as { - sdkSessionId?: string; - }; - if (params?.sdkSessionId) sdkSessionId = params.sdkSessionId; - } - } catch { - log.warn("Failed to parse log entry", { line }); - } - } - - return { rawEntries, sdkSessionId }; - } catch { - return { rawEntries: [] }; - } -} - function convertStoredEntriesToEvents( entries: StoredLogEntry[], taskDescription?: string, @@ -677,16 +670,21 @@ const useStore = create()( repoPath: string, auth: AuthCredentials, ) => { - const { rawEntries, sdkSessionId } = await fetchSessionLogs(logUrl); + const { rawEntries, sessionId, adapter, model } = + await fetchSessionLogs(logUrl); const events = convertStoredEntriesToEvents(rawEntries); const persistedMode = getPersistedTaskMode(taskId); const session = createBaseSession(taskRunId, taskId, taskTitle, false); session.events = events; session.logUrl = logUrl; + session.model = model; if (persistedMode) { session.currentMode = persistedMode; } + if (adapter) { + session.adapter = adapter; + } addSession(session); subscribeToChannel(taskRunId); @@ -700,29 +698,19 @@ const useStore = create()( apiHost: auth.apiHost, projectId: auth.projectId, logUrl, - sdkSessionId, + sessionId, + adapter, + model, }); if (result) { - const selectedModel = useModelsStore.getState().getEffectiveModel(); + const sessionModel = result.currentModelId; updateSession(taskRunId, { status: "connected", - model: selectedModel, + model: sessionModel, availableModels: result.availableModels, }); - try { - await trpcVanilla.agent.setModel.mutate({ - sessionId: taskRunId, - modelId: selectedModel, - }); - } catch (error) { - log.warn("Failed to restore model after reconnect", { - taskId, - error, - }); - } - if (persistedMode) { try { await trpcVanilla.agent.setMode.mutate({ @@ -766,6 +754,7 @@ const useStore = create()( auth: AuthCredentials, initialPrompt?: ContentBlock[], executionMode?: ExecutionMode, + adapter?: "claude" | "codex", ) => { if (!auth.client) { throw new Error( @@ -791,6 +780,7 @@ const useStore = create()( projectId: auth.projectId, model: selectedModel, executionMode: effectiveMode, + adapter, }); const session = createBaseSession( @@ -922,6 +912,7 @@ const useStore = create()( repoPath, initialPrompt, executionMode, + adapter, }) => { log.info("Connecting to task", { taskId: task.id }); @@ -1045,6 +1036,7 @@ const useStore = create()( auth, initialPrompt, executionMode, + adapter, ); } } catch (error) { @@ -1289,6 +1281,27 @@ const useStore = create()( } }, + setCodexReasoningLevel: async (taskId, level) => { + const session = getSessionByTaskId(taskId); + if (!session || session.isCloud || session.adapter !== "codex") + return; + + try { + await trpcVanilla.agent.setConfigOption.mutate({ + sessionId: session.taskRunId, + configId: "reasoning_effort", + value: level, + }); + updateSession(session.taskRunId, { codexReasoningLevel: level }); + } catch (error) { + log.error("Failed to change codex reasoning level", { + taskId, + level, + error, + }); + } + }, + appendUserShellExecute: async (taskId, command, cwd, result) => { const session = getSessionByTaskId(taskId); if (!session) return; @@ -1336,7 +1349,7 @@ const useStore = create()( try { await trpcVanilla.agent.respondToPermission.mutate({ - sessionId: session.taskRunId, + taskRunId: session.taskRunId, toolCallId, optionId, customInput, @@ -1385,7 +1398,7 @@ const useStore = create()( try { await trpcVanilla.agent.cancelPermission.mutate({ - sessionId: session.taskRunId, + taskRunId: session.taskRunId, toolCallId, }); @@ -1638,3 +1651,30 @@ export const useQueuedMessagesForTask = ( return session?.messageQueue ?? []; }); }; + +/** + * Hook to get the adapter type (claude or codex) for a task. + */ +export const useAdapterForTask = ( + taskId: string | undefined, +): "claude" | "codex" | undefined => { + return useStore((s) => { + if (!taskId) return undefined; + const session = Object.values(s.sessions).find( + (sess) => sess.taskId === taskId, + ); + return session?.adapter; + }); +}; + +export const useCodexReasoningLevelForTask = ( + taskId: string | undefined, +): CodexReasoningLevel | undefined => { + return useStore((s) => { + if (!taskId) return undefined; + const session = Object.values(s.sessions).find( + (sess) => sess.taskId === taskId, + ); + return session?.codexReasoningLevel; + }); +}; diff --git a/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts b/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts index 2b66edf7a..5e91ffe8a 100644 --- a/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts +++ b/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts @@ -5,25 +5,19 @@ import { type RequestPermissionRequest, type SessionNotification, } from "@agentclientprotocol/sdk"; +import type { StoredLogEntry as BaseStoredLogEntry } from "@shared/types/session-events"; import { trpcVanilla } from "@/renderer/trpc"; -export interface StoredLogEntry { - type: string; - timestamp?: string; - notification?: { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; - }; +export interface StoredLogEntry extends BaseStoredLogEntry { direction?: "client" | "agent"; } export interface ParsedSessionLogs { notifications: SessionNotification[]; rawEntries: StoredLogEntry[]; - sdkSessionId?: string; + sessionId?: string; + adapter?: "claude" | "codex"; + model?: string; } /** @@ -45,16 +39,14 @@ export async function fetchSessionLogs( const notifications: SessionNotification[] = []; const rawEntries: StoredLogEntry[] = []; - let sdkSessionId: string | undefined; + let sessionId: string | undefined; + let adapter: "claude" | "codex" | undefined; + let model: string | undefined; for (const line of content.trim().split("\n")) { try { const stored = JSON.parse(line) as StoredLogEntry; - // Infer direction from message structure: - // - Request (has id + method) = client → agent - // - Response (has id + result/error) = agent → client - // - Notification (has method, no id) = agent → client const msg = stored.notification; if (msg) { const hasId = msg.id !== undefined; @@ -72,26 +64,48 @@ export async function fetchSessionLogs( rawEntries.push(stored); - // Extract session/update notifications if ( stored.type === "notification" && stored.notification?.method === "session/update" && stored.notification?.params ) { notifications.push(stored.notification.params as SessionNotification); + + const params = stored.notification.params as { + update?: { + sessionUpdate?: string; + configOptions?: Array<{ id: string; currentValue?: string }>; + }; + }; + if (params.update?.sessionUpdate === "config_option_update") { + const modelOption = params.update.configOptions?.find( + (opt) => opt.id === "model", + ); + if (modelOption?.currentValue) { + model = modelOption.currentValue; + } + } } - // Extract SDK session ID from _posthog/sdk_session notification if ( stored.type === "notification" && stored.notification?.method?.endsWith("posthog/sdk_session") && stored.notification?.params ) { const params = stored.notification.params as { + sessionId?: string; sdkSessionId?: string; + adapter?: "claude" | "codex"; }; - if (params.sdkSessionId) { - sdkSessionId = params.sdkSessionId; + if (params.sessionId) { + sessionId = params.sessionId; + } else if (params.sdkSessionId) { + sessionId = params.sdkSessionId; + } + if (params.adapter) { + adapter = params.adapter; + } else if (sessionId) { + adapter = "claude"; } } } catch { @@ -99,13 +113,14 @@ export async function fetchSessionLogs( } } - return { notifications, rawEntries, sdkSessionId }; + return { notifications, rawEntries, sessionId, adapter, model }; } catch { return { notifications: [], rawEntries: [] }; } } -export type PermissionRequest = RequestPermissionRequest & { +export type PermissionRequest = Omit & { + taskRunId: string; receivedAt: number; }; @@ -182,8 +197,10 @@ export function findPendingPermissions( if (isResolved || isStale) continue; const params = entry.notification?.params as RequestPermissionRequest; + const { sessionId, ...rest } = params; pending.set(toolCallId, { - ...params, + ...rest, + taskRunId: sessionId, receivedAt: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(), diff --git a/apps/twig/src/renderer/features/settings/stores/settingsStore.ts b/apps/twig/src/renderer/features/settings/stores/settingsStore.ts index 06976b7bc..1568f2758 100644 --- a/apps/twig/src/renderer/features/settings/stores/settingsStore.ts +++ b/apps/twig/src/renderer/features/settings/stores/settingsStore.ts @@ -1,3 +1,4 @@ +import { electronStorage } from "@renderer/lib/electronStorage"; import type { WorkspaceMode } from "@shared/types"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -6,12 +7,14 @@ export type DefaultRunMode = "local" | "cloud" | "last_used"; export type LocalWorkspaceMode = "worktree" | "local"; export type SendMessagesWith = "enter" | "cmd+enter"; export type CompletionSound = "none" | "guitar" | "danilo" | "revi" | "meep"; +export type AgentAdapter = "claude" | "codex"; interface SettingsStore { defaultRunMode: DefaultRunMode; lastUsedRunMode: "local" | "cloud"; lastUsedLocalWorkspaceMode: LocalWorkspaceMode; lastUsedWorkspaceMode: WorkspaceMode; + lastUsedAdapter: AgentAdapter; desktopNotifications: boolean; dockBadgeNotifications: boolean; cursorGlow: boolean; @@ -28,6 +31,7 @@ interface SettingsStore { setLastUsedRunMode: (mode: "local" | "cloud") => void; setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void; setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void; + setLastUsedAdapter: (adapter: AgentAdapter) => void; setDesktopNotifications: (enabled: boolean) => void; setDockBadgeNotifications: (enabled: boolean) => void; setCursorGlow: (enabled: boolean) => void; @@ -44,6 +48,7 @@ export const useSettingsStore = create()( lastUsedRunMode: "local", lastUsedLocalWorkspaceMode: "worktree", lastUsedWorkspaceMode: "worktree", + lastUsedAdapter: "claude", desktopNotifications: true, dockBadgeNotifications: true, completionSound: "none", @@ -61,6 +66,7 @@ export const useSettingsStore = create()( setLastUsedLocalWorkspaceMode: (mode) => set({ lastUsedLocalWorkspaceMode: mode }), setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }), + setLastUsedAdapter: (adapter) => set({ lastUsedAdapter: adapter }), setDesktopNotifications: (enabled) => set({ desktopNotifications: enabled }), setDockBadgeNotifications: (enabled) => @@ -76,6 +82,27 @@ export const useSettingsStore = create()( }), { name: "settings-storage", + storage: electronStorage, + partialize: (state) => ({ + defaultRunMode: state.defaultRunMode, + lastUsedRunMode: state.lastUsedRunMode, + lastUsedLocalWorkspaceMode: state.lastUsedLocalWorkspaceMode, + lastUsedWorkspaceMode: state.lastUsedWorkspaceMode, + lastUsedAdapter: state.lastUsedAdapter, + desktopNotifications: state.desktopNotifications, + dockBadgeNotifications: state.dockBadgeNotifications, + cursorGlow: state.cursorGlow, + autoConvertLongText: state.autoConvertLongText, + completionSound: state.completionSound, + completionVolume: state.completionVolume, + sendMessagesWith: state.sendMessagesWith, + allowBypassPermissions: state.allowBypassPermissions, + preventSleepWhileRunning: state.preventSleepWhileRunning, + }), + merge: (persisted, current) => ({ + ...current, + ...(persisted as Partial), + }), }, ), ); diff --git a/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx b/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx new file mode 100644 index 000000000..cda263329 --- /dev/null +++ b/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx @@ -0,0 +1,85 @@ +import type { AgentAdapter } from "@features/settings/stores/settingsStore"; +import { Cpu, Robot } from "@phosphor-icons/react"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import type { Responsive } from "@radix-ui/themes/dist/esm/props/prop-def.js"; + +interface AdapterSelectProps { + value: AgentAdapter; + onChange: (adapter: AgentAdapter) => void; + size?: Responsive<"1" | "2">; +} + +const ADAPTER_CONFIG: Record< + AgentAdapter, + { label: string; description: string; icon: React.ReactNode } +> = { + claude: { + label: "Claude", + description: "Anthropic Claude", + icon: , + }, + codex: { + label: "Codex", + description: "OpenAI Codex", + icon: , + }, +}; + +export function AdapterSelect({ + value, + onChange, + size = "1", +}: AdapterSelectProps) { + const currentAdapter = ADAPTER_CONFIG[value] ?? ADAPTER_CONFIG.claude; + + return ( + + + + + + + onChange("claude")} + style={{ padding: "6px 8px", height: "auto" }} + > +
+ +
+ {ADAPTER_CONFIG.claude.label} +
+
+
+ onChange("codex")} + style={{ padding: "6px 8px", height: "auto" }} + > +
+ +
+ {ADAPTER_CONFIG.codex.label} + + {ADAPTER_CONFIG.codex.description} + +
+
+
+
+
+ ); +} diff --git a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx index a24bb7e68..9ea89e28d 100644 --- a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx @@ -5,6 +5,7 @@ import { cycleExecutionMode, type ExecutionMode, } from "@features/sessions/stores/sessionStore"; +import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { Flex } from "@radix-ui/themes"; @@ -13,6 +14,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import { useTaskDirectoryStore } from "@stores/taskDirectoryStore"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTaskCreation } from "../hooks/useTaskCreation"; +import { AdapterSelect } from "./AdapterSelect"; import { SuggestedTasks } from "./SuggestedTasks"; import { TaskInputEditor } from "./TaskInputEditor"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; @@ -25,24 +27,29 @@ export function TaskInput() { const { lastUsedLocalWorkspaceMode, setLastUsedLocalWorkspaceMode, + lastUsedAdapter, + setLastUsedAdapter, allowBypassPermissions, } = useSettingsStore(); const editorRef = useRef(null); const containerRef = useRef(null); - const [selectedDirectory, setSelectedDirectory] = useState( - lastUsedDirectory || "", - ); - // We're temporarily removing the cloud/local toggle, so hardcode to local const runMode = "local"; - const [workspaceMode, setWorkspaceMode] = useState( - lastUsedLocalWorkspaceMode || "worktree", - ); const [editorIsEmpty, setEditorIsEmpty] = useState(true); const [executionMode, setExecutionMode] = useState("default"); - // Reset to default mode if bypass was disabled while in bypass mode + const selectedDirectory = lastUsedDirectory || ""; + const workspaceMode = lastUsedLocalWorkspaceMode || "worktree"; + const adapter = lastUsedAdapter; + + const setSelectedDirectory = (path: string) => + setLastUsedDirectory(path || null); + const setWorkspaceMode = (mode: WorkspaceMode) => + setLastUsedLocalWorkspaceMode(mode as "worktree" | "local"); + const setAdapter = (newAdapter: AgentAdapter) => + setLastUsedAdapter(newAdapter); + useEffect(() => { if (!allowBypassPermissions && executionMode === "bypassPermissions") { setExecutionMode("default"); @@ -59,19 +66,13 @@ export function TaskInput() { useEffect(() => { if (view.folderId) { - // Access store directly to avoid folders dependency triggering re-sync const currentFolders = useRegisteredFoldersStore.getState().folders; const folder = currentFolders.find((f) => f.id === view.folderId); if (folder) { - setSelectedDirectory(folder.path); + setLastUsedDirectory(folder.path); } } - }, [view.folderId]); - - const handleDirectoryChange = (newPath: string) => { - setSelectedDirectory(newPath); - setLastUsedDirectory(newPath || null); - }; + }, [view.folderId, setLastUsedDirectory]); const effectiveWorkspaceMode = workspaceMode; @@ -83,6 +84,7 @@ export function TaskInput() { branch: null, editorIsEmpty, executionMode: executionMode === "default" ? undefined : executionMode, + adapter, }); return ( @@ -147,18 +149,16 @@ export function TaskInput() { { - setWorkspaceMode(mode); - setLastUsedLocalWorkspaceMode(mode); - }} + onChange={setWorkspaceMode} size="1" /> + diff --git a/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx index d0ad3cbcd..41964689d 100644 --- a/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx @@ -24,6 +24,7 @@ interface TaskInputEditorProps { onEmptyChange?: (isEmpty: boolean) => void; executionMode: ExecutionMode; onModeChange: () => void; + adapter?: "claude" | "codex"; } export const TaskInputEditor = forwardRef< @@ -42,6 +43,7 @@ export const TaskInputEditor = forwardRef< onEmptyChange, executionMode, onModeChange, + adapter, }, ref, ) => { @@ -205,6 +207,7 @@ export const TaskInputEditor = forwardRef< (RENDERER_TOKENS.TaskService); @@ -191,6 +196,7 @@ export function useTaskCreation({ workspaceMode, branch, executionMode, + adapter, invalidateTasks, navigateToTask, ]); diff --git a/apps/twig/src/renderer/sagas/task/task-creation.ts b/apps/twig/src/renderer/sagas/task/task-creation.ts index d1d08d5ff..b5274e347 100644 --- a/apps/twig/src/renderer/sagas/task/task-creation.ts +++ b/apps/twig/src/renderer/sagas/task/task-creation.ts @@ -36,6 +36,7 @@ export interface TaskCreationInput { branch?: string | null; githubIntegrationId?: number; executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; } export interface TaskCreationOutput { @@ -200,12 +201,12 @@ export class TaskCreationSaga extends Saga< repoPath: agentCwd ?? "", }); } else { - // Don't await for create - allows faster navigation to task page getSessionActions().connectToTask({ task, repoPath: agentCwd ?? "", initialPrompt, executionMode: input.executionMode, + adapter: input.adapter, }); } return { taskId: task.id }; diff --git a/apps/twig/src/renderer/stores/navigationStore.test.ts b/apps/twig/src/renderer/stores/navigationStore.test.ts index 823192249..51b36f880 100644 --- a/apps/twig/src/renderer/stores/navigationStore.test.ts +++ b/apps/twig/src/renderer/stores/navigationStore.test.ts @@ -1,6 +1,20 @@ import type { Task } from "@shared/types"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useNavigationStore } from "./navigationStore"; + +const { getItem, setItem } = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcVanilla: { + secureStore: { + getItem: { query: getItem }, + setItem: { query: setItem }, + removeItem: { query: vi.fn() }, + }, + }, +})); vi.mock("@renderer/lib/analytics", () => ({ track: vi.fn() })); vi.mock("@renderer/lib/logger", () => ({ @@ -23,6 +37,8 @@ vi.mock("@stores/taskDirectoryStore", () => ({ useTaskDirectoryStore: { getState: () => ({ getTaskDirectory: () => null }) }, })); +import { useNavigationStore } from "./navigationStore"; + const mockTask: Task = { id: "task-123", task_number: 1, @@ -36,14 +52,13 @@ const mockTask: Task = { const getStore = () => useNavigationStore.getState(); const getView = () => getStore().view; -const getPersistedState = () => { - const data = localStorage.getItem("navigation-storage"); - return data ? JSON.parse(data).state : null; -}; describe("navigationStore", () => { beforeEach(() => { - localStorage.clear(); + getItem.mockReset(); + setItem.mockReset(); + getItem.mockResolvedValue(null); + setItem.mockResolvedValue(undefined); useNavigationStore.setState({ view: { type: "task-input" }, history: [{ type: "task-input" }], @@ -103,17 +118,32 @@ describe("navigationStore", () => { it("persists view type and taskId but not full task data", async () => { await getStore().navigateToTask(mockTask); - const persisted = getPersistedState(); - expect(persisted.view).toEqual({ + await vi.waitFor(() => { + expect(setItem).toHaveBeenCalled(); + }); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[0].value); + expect(persisted.state.view).toEqual({ type: "task-detail", taskId: "task-123", folderId: undefined, }); }); - it("restores view from localStorage without task data", async () => { - await getStore().navigateToTask(mockTask); - const storedData = localStorage.getItem("navigation-storage"); + it("restores view from electronStorage without task data", async () => { + const storedState = JSON.stringify({ + state: { + view: { + type: "task-detail", + taskId: "task-123", + folderId: undefined, + }, + }, + version: 0, + }); + + getItem.mockResolvedValue(storedState); useNavigationStore.setState({ view: { type: "task-input" }, @@ -121,8 +151,7 @@ describe("navigationStore", () => { historyIndex: 0, }); - localStorage.setItem("navigation-storage", storedData!); - useNavigationStore.persist.rehydrate(); + await useNavigationStore.persist.rehydrate(); expect(getView()).toMatchObject({ type: "task-detail", diff --git a/apps/twig/src/renderer/stores/navigationStore.ts b/apps/twig/src/renderer/stores/navigationStore.ts index 85edbcbec..8960829bf 100644 --- a/apps/twig/src/renderer/stores/navigationStore.ts +++ b/apps/twig/src/renderer/stores/navigationStore.ts @@ -1,6 +1,7 @@ import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore"; import { track } from "@renderer/lib/analytics"; +import { electronStorage } from "@renderer/lib/electronStorage"; import { logger } from "@renderer/lib/logger"; import type { Task, WorkspaceMode } from "@shared/types"; import { useRegisteredFoldersStore } from "@stores/registeredFoldersStore"; @@ -184,6 +185,7 @@ export const useNavigationStore = create()( }, { name: "navigation-storage", + storage: electronStorage, partialize: (state) => ({ view: { type: state.view.type, diff --git a/apps/twig/src/renderer/stores/taskDirectoryStore.ts b/apps/twig/src/renderer/stores/taskDirectoryStore.ts index 6608bd1da..5dcdb52d1 100644 --- a/apps/twig/src/renderer/stores/taskDirectoryStore.ts +++ b/apps/twig/src/renderer/stores/taskDirectoryStore.ts @@ -1,3 +1,4 @@ +import { electronStorage } from "@renderer/lib/electronStorage"; import { trpcVanilla } from "@renderer/trpc/client"; import { omitKey } from "@utils/object"; import { expandTildePath } from "@utils/path"; @@ -76,6 +77,7 @@ export const useTaskDirectoryStore = create()( }), { name: "task-directory-mappings", + storage: electronStorage, partialize: (state) => ({ repoDirectories: state.repoDirectories, lastUsedDirectory: state.lastUsedDirectory, diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index c99a80e09..d5a8938d7 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -137,6 +137,43 @@ function copyClaudeExecutable(): Plugin { }; } +function copyCodexAcpBinary(): Plugin { + return { + name: "copy-codex-acp-binary", + writeBundle() { + const destDir = join(__dirname, ".vite/build/codex-acp"); + + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + + const binaryName = process.platform === "win32" ? "codex-acp.exe" : "codex-acp"; + const sourceDir = join(__dirname, "resources/codex-acp"); + const sourcePath = join(sourceDir, binaryName); + + if (existsSync(sourcePath)) { + const destPath = join(destDir, binaryName); + copyFileSync(sourcePath, destPath); + console.log(`Copied codex-acp binary to ${destDir}`); + + if (process.platform === "darwin") { + try { + execSync(`xattr -cr "${destPath}"`, { stdio: "inherit" }); + execSync(`codesign --force --sign - "${destPath}"`, { stdio: "inherit" }); + console.log("Ad-hoc signed codex-acp binary"); + } catch (err) { + console.warn("Failed to sign codex-acp binary:", err); + } + } + } else { + console.warn( + `[copy-codex-acp-binary] Binary not found at ${sourcePath}. Run 'node scripts/download-codex-acp.mjs' first.`, + ); + } + }, + }; +} + // Allow forcing dev mode in packaged builds via FORCE_DEV_MODE=1 const forceDevMode = process.env.FORCE_DEV_MODE === "1"; @@ -150,6 +187,7 @@ export default defineConfig(({ mode }) => { autoServicesPlugin(join(__dirname, "src/main/services")), fixFilenameCircularRef(), copyClaudeExecutable(), + copyCodexAcpBinary(), ], define: { __BUILD_COMMIT__: JSON.stringify(_getGitCommit()), diff --git a/packages/agent/package.json b/packages/agent/package.json index fe594cecf..ba34666f4 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -76,7 +76,7 @@ "dependencies": { "@posthog/shared": "workspace:*", "@twig/git": "workspace:*", - "@agentclientprotocol/sdk": "^0.13.1", + "@agentclientprotocol/sdk": "^0.14.0", "@anthropic-ai/claude-agent-sdk": "0.2.12", "@anthropic-ai/sdk": "^0.71.0", "@modelcontextprotocol/sdk": "^1.25.3", diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index b4d039854..8e7a01797 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -32,7 +32,7 @@ export const POSTHOG_NOTIFICATIONS = { /** Console/log output from the agent */ CONSOLE: "_posthog/console", - /** Maps a session ID to the underlying SDK session ID (for resumption) */ + /** Maps taskRunId to agent's sessionId and adapter type (for resumption) */ SDK_SESSION: "_posthog/sdk_session", /** Tree state snapshot captured (git tree hash + file archive) */ @@ -96,8 +96,9 @@ export interface ConsoleNotificationPayload { } export interface SdkSessionPayload { + taskRunId: string; sessionId: string; - sdkSessionId: string; + adapter: "claude" | "codex"; } export interface TreeSnapshotPayload { diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index e5813ba06..82346300c 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -1,33 +1,40 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; +import { POSTHOG_NOTIFICATIONS } from "@/acp-extensions.js"; import type { SessionLogWriter } from "@/session-log-writer.js"; import { Logger } from "@/utils/logger.js"; import { createBidirectionalStreams, createTappedWritableStream, + nodeReadableToWebReadable, + nodeWritableToWebWritable, type StreamPair, } from "@/utils/streams.js"; import { ClaudeAcpAgent, type ClaudeAcpAgentOptions, } from "./claude/claude-agent.js"; +import { type CodexProcessOptions, spawnCodexProcess } from "./codex/spawn.js"; -export type AgentAdapter = "claude"; +export type AgentAdapter = "claude" | "codex"; export type AcpConnectionConfig = { adapter?: AgentAdapter; logWriter?: SessionLogWriter; - sessionId?: string; + taskRunId?: string; taskId?: string; logger?: Logger; processCallbacks?: ClaudeAcpAgentOptions; + codexOptions?: CodexProcessOptions; }; -export type InProcessAcpConnection = { - agentConnection: AgentSideConnection; +export type AcpConnection = { + agentConnection?: AgentSideConnection; clientStreams: StreamPair; cleanup: () => Promise; }; +export type InProcessAcpConnection = AcpConnection; + /** * Creates an ACP connection with the specified agent framework. * @@ -36,7 +43,17 @@ export type InProcessAcpConnection = { */ export function createAcpConnection( config: AcpConnectionConfig = {}, -): InProcessAcpConnection { +): AcpConnection { + const adapterType = config.adapter ?? "claude"; + + if (adapterType === "codex") { + return createCodexConnection(config); + } + + return createClaudeConnection(config); +} + +function createClaudeConnection(config: AcpConnectionConfig): AcpConnection { const logger = config.logger?.child("AcpConnection") ?? new Logger({ debug: true, prefix: "[AcpConnection]" }); @@ -47,44 +64,39 @@ export function createAcpConnection( let agentWritable = streams.agent.writable; let clientWritable = streams.client.writable; - if (config.sessionId && logWriter) { - if (!logWriter.isRegistered(config.sessionId)) { - logWriter.register(config.sessionId, { - taskId: config.taskId ?? config.sessionId, - runId: config.sessionId, + if (config.taskRunId && logWriter) { + if (!logWriter.isRegistered(config.taskRunId)) { + logWriter.register(config.taskRunId, { + taskId: config.taskId ?? config.taskRunId, + runId: config.taskRunId, }); } agentWritable = createTappedWritableStream(streams.agent.writable, { onMessage: (line) => { - logWriter.appendRawLine(config.sessionId!, line); + logWriter.appendRawLine(config.taskRunId!, line); }, logger, }); clientWritable = createTappedWritableStream(streams.client.writable, { onMessage: (line) => { - logWriter.appendRawLine(config.sessionId!, line); + logWriter.appendRawLine(config.taskRunId!, line); }, logger, }); } else { logger.info("Tapped streams NOT enabled", { - hasSessionId: !!config.sessionId, + hasTaskRunId: !!config.taskRunId, hasLogWriter: !!logWriter, }); } const agentStream = ndJsonStream(agentWritable, streams.agent.readable); - const adapterType = config.adapter ?? "claude"; let agent: ClaudeAcpAgent | null = null; const agentConnection = new AgentSideConnection((client) => { - switch (adapterType) { - case "claude": - agent = new ClaudeAcpAgent(client, logWriter, config.processCallbacks); - break; - } + agent = new ClaudeAcpAgent(client, logWriter, config.processCallbacks); logger.info(`Created ${agent.adapterName} agent`); return agent; }, agentStream); @@ -102,8 +114,6 @@ export function createAcpConnection( await agent.closeSession(); } - // Then close the streams to properly terminate the ACP connection - // This signals the connection to close and cleanup try { await streams.client.writable.close(); } catch { @@ -117,3 +127,212 @@ export function createAcpConnection( }, }; } + +function createCodexConnection(config: AcpConnectionConfig): AcpConnection { + const logger = + config.logger?.child("CodexConnection") ?? + new Logger({ debug: true, prefix: "[CodexConnection]" }); + + const { logWriter } = config; + + const codexProcess = spawnCodexProcess({ + ...config.codexOptions, + logger, + }); + + let clientReadable = nodeReadableToWebReadable(codexProcess.stdout); + let clientWritable = nodeWritableToWebWritable(codexProcess.stdin); + + let isLoadingSession = false; + let loadRequestId: string | number | null = null; + let newSessionRequestId: string | number | null = null; + let sdkSessionEmitted = false; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let readBuffer = ""; + + const taskRunId = config.taskRunId; + + const filteringReadable = clientReadable.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + readBuffer += decoder.decode(chunk, { stream: true }); + const lines = readBuffer.split("\n"); + readBuffer = lines.pop() ?? ""; + + const outputLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + outputLines.push(line); + continue; + } + + let shouldFilter = false; + + try { + const msg = JSON.parse(trimmed); + + if ( + !sdkSessionEmitted && + newSessionRequestId !== null && + msg.id === newSessionRequestId && + "result" in msg + ) { + const sessionId = msg.result?.sessionId; + if (sessionId && taskRunId) { + const sdkSessionNotification = { + jsonrpc: "2.0", + method: POSTHOG_NOTIFICATIONS.SDK_SESSION, + params: { + taskRunId, + sessionId, + adapter: "codex", + }, + }; + outputLines.push(JSON.stringify(sdkSessionNotification)); + sdkSessionEmitted = true; + } + newSessionRequestId = null; + } + + if (isLoadingSession) { + if (msg.id === loadRequestId && "result" in msg) { + logger.debug("session/load complete, resuming stream"); + isLoadingSession = false; + loadRequestId = null; + } else if (msg.method === "session/update") { + logger.debug("Filtering replay session/update during load"); + shouldFilter = true; + } + } + } catch { + // Not valid JSON, pass through + } + + if (!shouldFilter) { + outputLines.push(line); + const isChunkNoise = + trimmed.includes('"sessionUpdate":"agent_message_chunk"') || + trimmed.includes('"sessionUpdate":"agent_thought_chunk"'); + if (!isChunkNoise) { + logger.debug("codex-acp stdout:", trimmed); + } + } + } + + if (outputLines.length > 0) { + const output = `${outputLines.join("\n")}\n`; + controller.enqueue(encoder.encode(output)); + } + }, + flush(controller) { + if (readBuffer.trim()) { + controller.enqueue(encoder.encode(readBuffer)); + } + }, + }), + ); + clientReadable = filteringReadable; + + const originalWritable = clientWritable; + clientWritable = new WritableStream({ + write(chunk) { + const text = decoder.decode(chunk, { stream: true }); + const trimmed = text.trim(); + logger.debug("codex-acp stdin:", trimmed); + + try { + const msg = JSON.parse(trimmed); + if (msg.method === "session/new" && msg.id) { + logger.debug("session/new detected, tracking request ID"); + newSessionRequestId = msg.id; + } else if (msg.method === "session/load" && msg.id) { + logger.debug("session/load detected, pausing stream updates"); + isLoadingSession = true; + loadRequestId = msg.id; + } + } catch { + // Not valid JSON + } + + const writer = originalWritable.getWriter(); + return writer.write(chunk).finally(() => writer.releaseLock()); + }, + close() { + const writer = originalWritable.getWriter(); + return writer.close().finally(() => writer.releaseLock()); + }, + }); + + const shouldTapLogs = config.taskRunId && logWriter; + + if (shouldTapLogs) { + const taskRunId = config.taskRunId!; + if (!logWriter.isRegistered(taskRunId)) { + logWriter.register(taskRunId, { + taskId: config.taskId ?? taskRunId, + runId: taskRunId, + }); + } + + clientWritable = createTappedWritableStream(clientWritable, { + onMessage: (line) => { + logWriter.appendRawLine(taskRunId, line); + }, + logger, + }); + + const originalReadable = clientReadable; + const logDecoder = new TextDecoder(); + let logBuffer = ""; + + clientReadable = originalReadable.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + logBuffer += logDecoder.decode(chunk, { stream: true }); + const lines = logBuffer.split("\n"); + logBuffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.trim()) { + logWriter.appendRawLine(taskRunId, line); + } + } + + controller.enqueue(chunk); + }, + flush() { + if (logBuffer.trim()) { + logWriter.appendRawLine(taskRunId, logBuffer); + } + }, + }), + ); + } else { + logger.info("Tapped streams NOT enabled for Codex", { + hasTaskRunId: !!config.taskRunId, + hasLogWriter: !!logWriter, + }); + } + + return { + agentConnection: undefined, + clientStreams: { + readable: clientReadable, + writable: clientWritable, + }, + cleanup: async () => { + logger.info("Cleaning up Codex connection"); + codexProcess.kill(); + + try { + await clientWritable.close(); + } catch { + // Stream may already be closed + } + }, + }; +} diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 609d90211..000b9f007 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -135,7 +135,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.checkAuthStatus(); const meta = params._meta as NewSessionMeta | undefined; - const sessionId = meta?.sessionId ?? uuidv7(); + const internalSessionId = uuidv7(); const permissionMode = (meta?.initialModeId as TwigExecutionMode) ?? "default"; @@ -143,14 +143,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const options = buildSessionOptions({ cwd: params.cwd, - sessionId, mcpServers, permissionMode, - canUseTool: this.createCanUseTool(sessionId), + canUseTool: this.createCanUseTool(internalSessionId), logger: this.logger, systemPrompt: buildSystemPrompt(meta?.systemPrompt), userProvidedOptions: meta?.claudeCode?.options, - onModeChange: this.createOnModeChange(sessionId), + onModeChange: this.createOnModeChange(internalSessionId), onProcessSpawned: this.processCallbacks?.onProcessSpawned, onProcessExited: this.processCallbacks?.onProcessExited, }); @@ -158,27 +157,31 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const input = new Pushable(); const q = query({ prompt: input, options }); - this.createSession( - sessionId, + const session = this.createSession( + internalSessionId, q, input, permissionMode, params.cwd, options.abortController as AbortController, ); - this.registerPersistence(sessionId, meta as Record); + session.taskRunId = meta?.taskRunId; + this.registerPersistence( + internalSessionId, + meta as Record, + ); if (meta?.model) { await this.trySetModel(q, meta.model); } this.sendAvailableCommandsUpdate( - sessionId, + internalSessionId, await getAvailableSlashCommands(q), ); return { - sessionId, + sessionId: internalSessionId, models: await this.getAvailableModels(meta?.model ?? DEFAULT_MODEL), modes: { currentModeId: permissionMode, @@ -194,8 +197,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { async resumeSession( params: LoadSessionRequest, ): Promise { - const { sessionId } = params; - if (this.sessionId === sessionId) { + const { sessionId: internalSessionId } = params; + if (this.sessionId === internalSessionId) { return {}; } @@ -203,23 +206,27 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const mcpServers = parseMcpServers(params); const { query: q, session } = await this.initializeQuery({ - sessionId, + internalSessionId, cwd: params.cwd, permissionMode: "default", mcpServers, systemPrompt: buildSystemPrompt(meta?.systemPrompt), userProvidedOptions: meta?.claudeCode?.options, - sdkSessionId: meta?.sdkSessionId, + sessionId: meta?.sessionId, additionalDirectories: meta?.claudeCode?.options?.additionalDirectories, }); - if (meta?.sdkSessionId) { - session.sdkSessionId = meta.sdkSessionId; + session.taskRunId = meta?.taskRunId; + if (meta?.sessionId) { + session.sessionId = meta.sessionId; } - this.registerPersistence(sessionId, meta as Record); + this.registerPersistence( + internalSessionId, + meta as Record, + ); this.sendAvailableCommandsUpdate( - sessionId, + internalSessionId, await getAvailableSlashCommands(q), ); @@ -294,13 +301,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } private async initializeQuery(config: { - sessionId: string; + internalSessionId: string; cwd: string; permissionMode: TwigExecutionMode; mcpServers: ReturnType; userProvidedOptions?: Options; systemPrompt?: Options["systemPrompt"]; - sdkSessionId?: string; + sessionId?: string; additionalDirectories?: string[]; }): Promise<{ query: Query; @@ -311,16 +318,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const options = buildSessionOptions({ cwd: config.cwd, - sessionId: config.sessionId, mcpServers: config.mcpServers, permissionMode: config.permissionMode, - canUseTool: this.createCanUseTool(config.sessionId), + canUseTool: this.createCanUseTool(config.internalSessionId), logger: this.logger, systemPrompt: config.systemPrompt, userProvidedOptions: config.userProvidedOptions, - sdkSessionId: config.sdkSessionId, + sessionId: config.sessionId, additionalDirectories: config.additionalDirectories, - onModeChange: this.createOnModeChange(config.sessionId), + onModeChange: this.createOnModeChange(config.internalSessionId), onProcessSpawned: this.processCallbacks?.onProcessSpawned, onProcessExited: this.processCallbacks?.onProcessExited, }); @@ -329,7 +335,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const abortController = options.abortController as AbortController; const session = this.createSession( - config.sessionId, + config.internalSessionId, q, input, config.permissionMode, diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 9033e7fc2..efb92977c 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -332,12 +332,13 @@ export async function handleSystemMessage( switch (message.subtype) { case "init": - if (message.session_id) { - if (session && !session.sdkSessionId) { - session.sdkSessionId = message.session_id; + if (message.session_id && session && !session.sessionId) { + session.sessionId = message.session_id; + if (session.taskRunId) { await client.extNotification("_posthog/sdk_session", { - sessionId, - sdkSessionId: message.session_id, + taskRunId: session.taskRunId, + sessionId: message.session_id, + adapter: "claude", }); } } diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index bf2e70618..e83af7b44 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -21,14 +21,13 @@ export interface ProcessSpawnedInfo { export interface BuildOptionsParams { cwd: string; - sessionId: string; mcpServers: Record; permissionMode: TwigExecutionMode; canUseTool: Options["canUseTool"]; logger: Logger; systemPrompt?: Options["systemPrompt"]; userProvidedOptions?: Options; - sdkSessionId?: string; + sessionId?: string; additionalDirectories?: string[]; onModeChange?: OnModeChange; onProcessSpawned?: (info: ProcessSpawnedInfo) => void; @@ -199,7 +198,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { ), ...(params.onProcessSpawned && { spawnClaudeCodeProcess: buildSpawnWrapper( - params.sessionId, + params.sessionId ?? "unknown", params.onProcessSpawned, params.onProcessExited, ), @@ -210,8 +209,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { options.pathToClaudeCodeExecutable = process.env.CLAUDE_CODE_EXECUTABLE; } - if (params.sdkSessionId) { - options.resume = params.sdkSessionId; + if (params.sessionId) { + options.resume = params.sessionId; } if (params.additionalDirectories) { diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 25ed4ce1b..1d7c61642 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -27,7 +27,8 @@ export type Session = BaseSession & { input: Pushable; permissionMode: TwigExecutionMode; cwd: string; - sdkSessionId?: string; + taskRunId?: string; + sessionId?: string; lastPlanFilePath?: string; lastPlanContent?: string; }; @@ -49,11 +50,11 @@ export type ToolUpdateMeta = { }; export type NewSessionMeta = { - sessionId?: string; + taskRunId?: string; initialModeId?: string; disableBuiltInTools?: boolean; systemPrompt?: unknown; - sdkSessionId?: string; + sessionId?: string; model?: string; claudeCode?: { options?: Options; diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts new file mode 100644 index 000000000..37dd3624a --- /dev/null +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -0,0 +1,115 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import type { Readable, Writable } from "node:stream"; +import { Logger } from "@/utils/logger.js"; + +export interface CodexProcessOptions { + cwd?: string; + apiBaseUrl?: string; + apiKey?: string; + model?: string; + binaryPath?: string; + logger?: Logger; +} + +export interface CodexProcess { + process: ChildProcess; + stdin: Writable; + stdout: Readable; + kill: () => void; +} + +function buildConfigArgs(options: CodexProcessOptions): string[] { + const args: string[] = []; + + args.push("-c", `features.remote_models=false`); + + if (options.apiBaseUrl) { + args.push("-c", `model_provider="posthog"`); + args.push("-c", `model_providers.posthog.name="PostHog Gateway"`); + args.push("-c", `model_providers.posthog.base_url="${options.apiBaseUrl}"`); + args.push("-c", `model_providers.posthog.wire_api="responses"`); + args.push( + "-c", + `model_providers.posthog.env_key="POSTHOG_GATEWAY_API_KEY"`, + ); + } + + if (options.model) { + args.push("-c", `model="${options.model}"`); + } + + return args; +} + +function findCodexBinary(options: CodexProcessOptions): { + command: string; + args: string[]; +} { + const configArgs = buildConfigArgs(options); + + if (options.binaryPath && existsSync(options.binaryPath)) { + return { command: options.binaryPath, args: configArgs }; + } + + return { command: "npx", args: ["@zed-industries/codex-acp", ...configArgs] }; +} + +export function spawnCodexProcess(options: CodexProcessOptions): CodexProcess { + const logger = + options.logger ?? new Logger({ debug: true, prefix: "[CodexSpawn]" }); + + const env: NodeJS.ProcessEnv = { ...process.env }; + + // Prevent Electron's GPU/Chromium processes from interfering with child + delete env.ELECTRON_RUN_AS_NODE; + delete env.ELECTRON_NO_ASAR; + + if (options.apiKey) { + env.POSTHOG_GATEWAY_API_KEY = options.apiKey; + } + + const { command, args } = findCodexBinary(options); + + logger.info("Spawning codex-acp process", { + command, + args, + cwd: options.cwd, + hasApiBaseUrl: !!options.apiBaseUrl, + hasApiKey: !!options.apiKey, + binaryPath: options.binaryPath, + }); + + const child = spawn(command, args, { + cwd: options.cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + + child.stderr?.on("data", (data: Buffer) => { + logger.debug("codex-acp stderr:", data.toString()); + }); + + child.on("error", (err) => { + logger.error("codex-acp process error:", err); + }); + + child.on("exit", (code, signal) => { + logger.info("codex-acp process exited", { code, signal }); + }); + + if (!child.stdin || !child.stdout) { + throw new Error("Failed to get stdio streams from codex-acp process"); + } + + return { + process: child, + stdin: child.stdin, + stdout: child.stdout, + kill: () => { + logger.info("Killing codex-acp process"); + child.kill("SIGTERM"); + }, + }; +} diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 32bd6f194..df08235e3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -32,16 +32,24 @@ export class Agent { } } - private async _configureLlmGateway(): Promise { + private _configureLlmGateway(_adapter?: "claude" | "codex"): { + gatewayUrl: string; + apiKey: string; + } | null { if (!this.posthogAPI) { - return; + return null; } try { const gatewayUrl = this.posthogAPI.getLlmGatewayUrl(); const apiKey = this.posthogAPI.getApiKey(); + + process.env.OPENAI_BASE_URL = `${gatewayUrl}/v1`; + process.env.OPENAI_API_KEY = apiKey; process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = apiKey; + + return { gatewayUrl, apiKey }; } catch (error) { this.logger.error("Failed to configure LLM gateway", error); throw error; @@ -53,17 +61,27 @@ export class Agent { taskRunId: string, options: TaskExecutionOptions = {}, ): Promise { - await this._configureLlmGateway(); + const gatewayConfig = this._configureLlmGateway(options.adapter); this.taskRunId = taskRunId; this.acpConnection = createAcpConnection({ adapter: options.adapter, logWriter: this.sessionLogWriter, - sessionId: taskRunId, + taskRunId, taskId, logger: this.logger, processCallbacks: options.processCallbacks, + codexOptions: + options.adapter === "codex" && gatewayConfig + ? { + cwd: options.repositoryPath, + apiBaseUrl: `${gatewayConfig.gatewayUrl}/v1`, + apiKey: gatewayConfig.apiKey, + binaryPath: options.codexBinaryPath, + model: options.model ?? "gpt-5.2", + } + : undefined, }); return this.acpConnection; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index bab891197..1eb2beb45 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -17,10 +17,13 @@ export type { } from "./acp-extensions.js"; export { POSTHOG_NOTIFICATIONS } from "./acp-extensions.js"; export type { + AcpConnection, AcpConnectionConfig, + AgentAdapter, InProcessAcpConnection, } from "./adapters/acp-connection.js"; export { createAcpConnection } from "./adapters/acp-connection.js"; +export type { CodexProcessOptions } from "./adapters/codex/spawn.js"; export { Agent } from "./agent.js"; export { PostHogAPIClient } from "./posthog-api.js"; export type { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 46cd9407c..0da65d5d2 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -107,7 +107,9 @@ export interface ProcessSpawnedCallback { export interface TaskExecutionOptions { repositoryPath?: string; - adapter?: "claude"; + adapter?: "claude" | "codex"; + model?: string; + codexBinaryPath?: string; processCallbacks?: ProcessSpawnedCallback; } diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index 5b56c5f4e..73ae8d1b9 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -4,15 +4,15 @@ export function getLlmGatewayUrl(posthogHost: string): string { // Local development (normalize 127.0.0.1 to localhost) if (hostname === "localhost" || hostname === "127.0.0.1") { - return `${url.protocol}//localhost:3308/array`; + return `${url.protocol}//localhost:3308/twig`; } // Docker containers accessing host if (hostname === "host.docker.internal") { - return `${url.protocol}//host.docker.internal:3308/array`; + return `${url.protocol}//host.docker.internal:3308/twig`; } // Production - extract region from hostname, default to US const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; - return `https://gateway.${region}.posthog.com/array`; + return `https://gateway.${region}.posthog.com/twig`; } diff --git a/packages/agent/src/utils/streams.ts b/packages/agent/src/utils/streams.ts index 40bd5ec1c..0fcffc606 100644 --- a/packages/agent/src/utils/streams.ts +++ b/packages/agent/src/utils/streams.ts @@ -1,3 +1,4 @@ +import type { Readable, Writable } from "node:stream"; import { ReadableStream, WritableStream } from "node:stream/web"; import type { Logger } from "./logger.js"; @@ -167,3 +168,53 @@ export function createTappedWritableStream( }, }); } + +export function nodeReadableToWebReadable( + nodeStream: Readable, +): globalThis.ReadableStream { + return new ReadableStream({ + start(controller) { + nodeStream.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)); + }); + nodeStream.on("end", () => { + controller.close(); + }); + nodeStream.on("error", (err) => { + controller.error(err); + }); + }, + cancel() { + nodeStream.destroy(); + }, + }) as unknown as globalThis.ReadableStream; +} + +export function nodeWritableToWebWritable( + nodeStream: Writable, +): globalThis.WritableStream { + return new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + const ok = nodeStream.write(Buffer.from(chunk), (err) => { + if (err) reject(err); + }); + if (ok) { + resolve(); + } else { + nodeStream.once("drain", resolve); + } + }); + }, + close() { + return new Promise((resolve) => { + nodeStream.end(resolve); + }); + }, + abort(reason) { + nodeStream.destroy( + reason instanceof Error ? reason : new Error(String(reason)), + ); + }, + }) as globalThis.WritableStream; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2caafc21c..cf7bd7e1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,10 +469,10 @@ importers: version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: 10.2.0 - version: 10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + version: 10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + version: 10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -496,7 +496,7 @@ importers: version: 9.0.8 '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/ui': specifier: ^4.0.10 version: 4.0.17(vitest@4.0.17) @@ -541,13 +541,13 @@ importers: version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.10 - version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) yaml: specifier: ^2.8.1 version: 2.8.2 @@ -555,8 +555,8 @@ importers: packages/agent: dependencies: '@agentclientprotocol/sdk': - specifier: ^0.13.1 - version: 0.13.1(zod@3.25.76) + specifier: ^0.14.0 + version: 0.14.0(zod@3.25.76) '@anthropic-ai/claude-agent-sdk': specifier: 0.2.12 version: 0.2.12(zod@3.25.76) @@ -724,8 +724,8 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@agentclientprotocol/sdk@0.13.1': - resolution: {integrity: sha512-6byvu+F/xc96GBkdAx4hq6/tB3vT63DSBO4i3gYCz8nuyZMerVFna2Gkhm8EHNpZX0J9DjUxzZCW+rnHXUg0FA==} + '@agentclientprotocol/sdk@0.14.0': + resolution: {integrity: sha512-PNaDAiFIRzthaBjPljioHoadzYD2mRovA00ksCeCaerAU9qyqUQJdRBiJwlOxJ3SucY/nyJg8+0sh1sZrPhgmA==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -800,6 +800,10 @@ packages: resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.28.6': resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} engines: {node: '>=6.9.0'} @@ -812,6 +816,10 @@ packages: resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.0': + resolution: {integrity: sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -908,6 +916,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-decorators@7.28.6': resolution: {integrity: sha512-RVdFPPyY9fCRAX68haPmOk2iyKW8PKJFthmm8NeSI3paNxKWGZIn99+VbIf0FrtCpFnPgnpF/L48tadi617ULg==} engines: {node: '>=6.9.0'} @@ -1322,10 +1335,18 @@ packages: resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.6': resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -6727,11 +6748,13 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -6740,12 +6763,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -9621,12 +9644,12 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.6: resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} @@ -10545,7 +10568,7 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@agentclientprotocol/sdk@0.13.1(zod@3.25.76)': + '@agentclientprotocol/sdk@0.14.0(zod@3.25.76)': dependencies: zod: 3.25.76 @@ -10638,6 +10661,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.28.6': {} '@babel/core@7.28.6': @@ -10668,6 +10697,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.0': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.28.6 @@ -10716,7 +10753,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -10738,7 +10775,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 '@babel/helper-plugin-utils@7.28.6': {} @@ -10763,7 +10800,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -10777,7 +10814,7 @@ snapshots: dependencies: '@babel/template': 7.28.6 '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -10797,6 +10834,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-proposal-decorators@7.28.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -10976,7 +11017,7 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.6) - '@babel/traverse': 7.28.6 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -11277,11 +11318,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.0 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.6': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} '@biomejs/biome@2.2.4': @@ -13149,11 +13207,11 @@ snapshots: '@jimp/types': 1.6.0 tinycolor2: 1.6.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: glob: 11.1.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 @@ -14860,10 +14918,10 @@ snapshots: axe-core: 4.11.1 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/addon-docs@10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@19.1.0) - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -14877,27 +14935,27 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 rollup: 4.55.1 - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.104.1(esbuild@0.27.2) '@storybook/global@5.0.0': {} @@ -14913,11 +14971,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/react-vite@10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@storybook/react': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -14927,7 +14985,7 @@ snapshots: resolve: 1.22.11 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw @@ -14970,7 +15028,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -15415,7 +15473,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -15423,7 +15481,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -15484,21 +15542,21 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.45.0) - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.17 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@4.0.17(vite@6.4.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -15911,7 +15969,7 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: '@babel/template': 7.28.6 - '@babel/types': 7.28.6 + '@babel/types': 7.29.0 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -18038,7 +18096,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.28.6 - '@babel/parser': 7.28.6 + '@babel/parser': 7.29.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -18103,7 +18161,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.0 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -18855,7 +18913,7 @@ snapshots: metro-source-map@0.83.3: dependencies: '@babel/traverse': 7.28.6 - '@babel/traverse--for-generate-function-map': '@babel/traverse@7.28.6' + '@babel/traverse--for-generate-function-map': '@babel/traverse@7.29.0' '@babel/types': 7.28.6 flow-enums-runtime: 0.0.6 invariant: 2.2.4 @@ -21616,13 +21674,13 @@ snapshots: magic-string: 0.30.21 vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -21649,23 +21707,6 @@ snapshots: lightningcss: 1.30.2 terser: 5.45.0 - vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.55.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.29 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.30.2 - terser: 5.45.0 - tsx: 4.21.0 - yaml: 2.8.2 - vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -21772,10 +21813,10 @@ snapshots: - supports-color - terser - vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 - '@vitest/mocker': 4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.17 '@vitest/runner': 4.0.17 '@vitest/snapshot': 4.0.17 @@ -21792,7 +21833,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 65a5d383f..52659ea4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,6 @@ onlyBuiltDependencies: - node-pty minimumReleaseAge: 1440 + +minimumReleaseAgeExclude: + - "@agentclientprotocol/sdk" From f59e75e45032713af3fd615c779de372b7549b82 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 4 Feb 2026 20:20:36 +0100 Subject: [PATCH 2/6] kill gpt-5-mini, use acp modes as source of truth --- apps/twig/src/main/services/agent/schemas.ts | 22 +- apps/twig/src/main/services/agent/service.ts | 23 ++- .../components/MessageEditor.tsx | 10 +- .../components/ModeIndicatorInput.tsx | 65 ++++-- .../sessions/components/SessionView.tsx | 40 +++- .../sessions/stores/sessionModeStore.ts | 27 +-- .../features/sessions/stores/sessionStore.ts | 118 ++++++++--- .../task-detail/components/TaskInput.tsx | 23 +-- .../components/TaskInputEditor.tsx | 188 ++++++++---------- .../task-detail/hooks/useTaskCreation.ts | 6 +- .../src/renderer/sagas/task/task-creation.ts | 9 +- packages/agent/src/agent.ts | 8 +- packages/agent/src/gateway-models.ts | 5 +- packages/agent/src/index.ts | 9 + 14 files changed, 315 insertions(+), 238 deletions(-) diff --git a/apps/twig/src/main/services/agent/schemas.ts b/apps/twig/src/main/services/agent/schemas.ts index 0573734bc..02f40b246 100644 --- a/apps/twig/src/main/services/agent/schemas.ts +++ b/apps/twig/src/main/services/agent/schemas.ts @@ -2,7 +2,6 @@ import type { RequestPermissionRequest, PermissionOption as SdkPermissionOption, } from "@agentclientprotocol/sdk"; -import { executionModeSchema } from "@shared/types"; import { z } from "zod"; // Session credentials schema @@ -24,7 +23,7 @@ export const sessionConfigSchema = z.object({ /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId: z.string().optional(), model: z.string().optional(), - executionMode: executionModeSchema.optional(), + executionMode: z.string().optional(), adapter: z.enum(["claude", "codex"]).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), @@ -44,12 +43,9 @@ export const startSessionInput = z.object({ permissionMode: z.string().optional(), autoProgress: z.boolean().optional(), model: z.string().optional(), - executionMode: z - .enum(["default", "acceptEdits", "plan", "bypassPermissions"]) - .optional(), + executionMode: z.string().optional(), runMode: z.enum(["local", "cloud"]).optional(), adapter: z.enum(["claude", "codex"]).optional(), - /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), }); @@ -64,11 +60,21 @@ export const modelOptionSchema = z.object({ export type ModelOption = z.infer; +export const modeOptionSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullish(), +}); + +export type ModeOption = z.infer; + export const sessionResponseSchema = z.object({ sessionId: z.string(), channel: z.string(), availableModels: z.array(modelOptionSchema).optional(), currentModelId: z.string().optional(), + availableModes: z.array(modeOptionSchema).optional(), + currentModeId: z.string().optional(), }); export type SessionResponse = z.infer; @@ -147,10 +153,10 @@ export const setModelInput = z.object({ modelId: z.string(), }); -// Set mode input +// Set mode input - accepts any agent-defined mode ID export const setModeInput = z.object({ sessionId: z.string(), - modeId: executionModeSchema, + modeId: z.string(), }); // Set config option input (for Codex reasoning level, etc.) diff --git a/apps/twig/src/main/services/agent/service.ts b/apps/twig/src/main/services/agent/service.ts index d400e4e2a..5a30c0522 100644 --- a/apps/twig/src/main/services/agent/service.ts +++ b/apps/twig/src/main/services/agent/service.ts @@ -9,6 +9,8 @@ import { PROTOCOL_VERSION, type RequestPermissionRequest, type RequestPermissionResponse, + type SessionMode, + type SessionModeId, } from "@agentclientprotocol/sdk"; import { Agent } from "@posthog/agent/agent"; import { @@ -20,7 +22,6 @@ import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import type { OnLogCallback } from "@posthog/agent/types"; import { app } from "electron"; import { inject, injectable, preDestroy } from "inversify"; -import type { ExecutionMode } from "@/shared/types.js"; import type { AcpMessage } from "../../../shared/types/session-events.js"; import { MAIN_TOKENS } from "../../di/tokens.js"; import { logger } from "../../lib/logger.js"; @@ -171,7 +172,7 @@ interface SessionConfig { /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId?: string; model?: string; - executionMode?: ExecutionMode; + executionMode?: string; adapter?: "claude" | "codex"; /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories?: string[]; @@ -199,6 +200,8 @@ interface ManagedSession { description?: string | null; }>; currentModelId?: string; + availableModes?: SessionMode[]; + currentModeId?: SessionModeId; sessionId: string; } @@ -501,6 +504,8 @@ export class AgentService extends TypedEventEmitter { | Array<{ modelId: string; name: string; description?: string | null }> | undefined; let currentModelId: string | undefined; + let availableModes: SessionMode[] | undefined; + let currentModeId: SessionModeId | undefined; let agentSessionId: string; if (isReconnect && adapter === "codex" && config.sessionId) { @@ -511,6 +516,8 @@ export class AgentService extends TypedEventEmitter { }); availableModels = loadResponse.models?.availableModels; currentModelId = loadResponse.models?.currentModelId; + availableModes = loadResponse.modes?.availableModes; + currentModeId = loadResponse.modes?.currentModeId; agentSessionId = config.sessionId; } else if (isReconnect && adapter !== "codex") { const systemPrompt = this.buildPostHogSystemPrompt(credentials); @@ -541,10 +548,16 @@ export class AgentService extends TypedEventEmitter { availableModels?: typeof availableModels; currentModelId?: string; }; + modes?: { + availableModes?: SessionMode[]; + currentModeId?: SessionModeId; + }; } | undefined; availableModels = resumeMeta?.models?.availableModels; currentModelId = resumeMeta?.models?.currentModelId; + availableModes = resumeMeta?.modes?.availableModes; + currentModeId = resumeMeta?.modes?.currentModeId; agentSessionId = (resumeResponse?.sessionId as string) ?? taskRunId; } else { const systemPrompt = this.buildPostHogSystemPrompt(credentials); @@ -565,6 +578,8 @@ export class AgentService extends TypedEventEmitter { }); availableModels = newSessionResponse.models?.availableModels; currentModelId = newSessionResponse.models?.currentModelId; + availableModes = newSessionResponse.modes?.availableModes; + currentModeId = newSessionResponse.modes?.currentModeId; agentSessionId = newSessionResponse.sessionId; } @@ -585,6 +600,8 @@ export class AgentService extends TypedEventEmitter { promptPending: false, availableModels, currentModelId, + availableModes, + currentModeId, sessionId: agentSessionId, }; @@ -1229,6 +1246,8 @@ For git operations while detached: channel: session.channel, availableModels: session.availableModels, currentModelId: session.currentModelId, + availableModes: session.availableModes, + currentModeId: session.currentModeId, }; } diff --git a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx index bf16156b0..555a68c51 100644 --- a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -1,5 +1,5 @@ import "./message-editor.css"; -import type { ExecutionMode } from "@features/sessions/stores/sessionStore"; +import type { SessionMode } from "@agentclientprotocol/sdk"; import { useConnectivity } from "@hooks/useConnectivity"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; @@ -27,7 +27,8 @@ interface MessageEditorProps { onCancel?: () => void; onAttachFiles?: (files: File[]) => void; autoFocus?: boolean; - currentMode?: ExecutionMode; + currentMode?: SessionMode; + currentModeId?: string; onModeChange?: () => void; adapter?: "claude" | "codex"; } @@ -44,6 +45,7 @@ export const MessageEditor = forwardRef( onAttachFiles, autoFocus = false, currentMode, + currentModeId, onModeChange, adapter, }, @@ -211,8 +213,8 @@ export const MessageEditor = forwardRef( {(onModeChange || adapter) && ( - {onModeChange && currentMode && ( - + {onModeChange && (currentMode || currentModeId) && ( + )} {adapter && } diff --git a/apps/twig/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx b/apps/twig/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx index a2b0e047b..80aaad4fe 100644 --- a/apps/twig/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/ModeIndicatorInput.tsx @@ -1,43 +1,66 @@ -import { LockOpen, Pause, Pencil, ShieldCheck } from "@phosphor-icons/react"; +import type { SessionMode } from "@agentclientprotocol/sdk"; +import { + Circle, + Eye, + LockOpen, + Pause, + Pencil, + ShieldCheck, +} from "@phosphor-icons/react"; import { Flex, Text } from "@radix-ui/themes"; -import type { ExecutionMode } from "@shared/types"; -interface ModeIndicatorInputProps { - mode: ExecutionMode; +interface ModeStyle { + icon: React.ReactNode; + colorVar: string; } -const modeConfig: Record< - ExecutionMode, - { - label: string; - icon: React.ReactNode; - colorVar: string; - } -> = { +const MODE_STYLES: Record = { plan: { - label: "plan mode on", icon: , colorVar: "var(--amber-11)", }, default: { - label: "default mode", icon: , colorVar: "var(--gray-11)", }, acceptEdits: { - label: "auto-accept edits", icon: , colorVar: "var(--green-11)", }, bypassPermissions: { - label: "bypass permissions", icon: , colorVar: "var(--red-11)", }, + auto: { + icon: , + colorVar: "var(--gray-11)", + }, + "read-only": { + icon: , + colorVar: "var(--amber-11)", + }, + "full-access": { + icon: , + colorVar: "var(--red-11)", + }, +}; + +const DEFAULT_STYLE: ModeStyle = { + icon: , + colorVar: "var(--gray-11)", }; -export function ModeIndicatorInput({ mode }: ModeIndicatorInputProps) { - const config = modeConfig[mode]; +interface ModeIndicatorInputProps { + mode?: SessionMode; + modeId?: string; +} + +export function ModeIndicatorInput({ mode, modeId }: ModeIndicatorInputProps) { + const id = mode?.id ?? modeId; + if (!id) return null; + + const style = MODE_STYLES[id] ?? DEFAULT_STYLE; + const label = mode?.name ?? id; return ( @@ -45,15 +68,15 @@ export function ModeIndicatorInput({ mode }: ModeIndicatorInputProps) { - {config.icon} - {config.label} + {style.icon} + {label} { if ( !allowBypassPermissions && - currentMode === "bypassPermissions" && + (currentModeId === "bypassPermissions" || + currentModeId === "full-access") && taskId && !isCloud ) { setSessionMode(taskId, "default"); } - }, [allowBypassPermissions, currentMode, taskId, isCloud, setSessionMode]); + }, [allowBypassPermissions, currentModeId, taskId, isCloud, setSessionMode]); const handleModeChange = useCallback(() => { if (!taskId || isCloud) return; - const nextMode = cycleExecutionMode(currentMode, allowBypassPermissions); + const nextMode = cycleExecutionMode( + currentModeId, + availableModes, + allowBypassPermissions, + ); setSessionMode(taskId, nextMode); - }, [taskId, isCloud, currentMode, allowBypassPermissions, setSessionMode]); + }, [ + taskId, + isCloud, + currentModeId, + availableModes, + allowBypassPermissions, + setSessionMode, + ]); const sessionId = taskId ?? "default"; const setContext = useDraftStore((s) => s.actions.setContext); @@ -111,13 +124,16 @@ export function SessionView({ onCancelPrompt, ]); - // Mode cycling with Shift+Tab useHotkeys( "shift+tab", (e) => { e.preventDefault(); if (!taskId || isCloud) return; - const nextMode = cycleExecutionMode(currentMode, allowBypassPermissions); + const nextMode = cycleExecutionMode( + currentModeId, + availableModes, + allowBypassPermissions, + ); setSessionMode(taskId, nextMode); }, { @@ -127,7 +143,8 @@ export function SessionView({ }, [ taskId, - currentMode, + currentModeId, + availableModes, isCloud, isRunning, setSessionMode, @@ -426,6 +443,7 @@ export function SessionView({ onBashModeChange={setIsBashMode} onCancel={onCancelPrompt} currentMode={currentMode} + currentModeId={currentModeId} onModeChange={!isCloud ? handleModeChange : undefined} adapter={adapter} /> diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts index a3dec29d9..067b63354 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts @@ -1,17 +1,13 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import type { ExecutionMode } from "./sessionStore"; interface SessionModeState { - /** Map of taskId -> last used execution mode */ - taskModes: Record; + taskModes: Record; } interface SessionModeActions { - /** Save the mode for a task */ - setTaskMode: (taskId: string, mode: ExecutionMode) => void; - /** Get the saved mode for a task */ - getTaskMode: (taskId: string) => ExecutionMode | undefined; + setTaskMode: (taskId: string, modeId: string) => void; + getTaskMode: (taskId: string) => string | undefined; } type SessionModeStore = SessionModeState & SessionModeActions; @@ -21,9 +17,9 @@ export const useSessionModeStore = create()( (set, get) => ({ taskModes: {}, - setTaskMode: (taskId, mode) => { + setTaskMode: (taskId, modeId) => { set((state) => ({ - taskModes: { ...state.taskModes, [taskId]: mode }, + taskModes: { ...state.taskModes, [taskId]: modeId }, })); }, @@ -37,17 +33,10 @@ export const useSessionModeStore = create()( ), ); -/** Non-hook accessor for getting task mode */ -export function getPersistedTaskMode( - taskId: string, -): ExecutionMode | undefined { +export function getPersistedTaskMode(taskId: string): string | undefined { return useSessionModeStore.getState().getTaskMode(taskId); } -/** Non-hook accessor for setting task mode */ -export function setPersistedTaskMode( - taskId: string, - mode: ExecutionMode, -): void { - useSessionModeStore.getState().setTaskMode(taskId, mode); +export function setPersistedTaskMode(taskId: string, modeId: string): void { + useSessionModeStore.getState().setTaskMode(taskId, modeId); } diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts index 00135ee9f..23ec8f0d9 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts @@ -1,6 +1,8 @@ import type { AvailableCommand, ContentBlock, + SessionMode, + SessionModeId, SessionNotification, } from "@agentclientprotocol/sdk"; import { useAuthStore } from "@features/auth/stores/authStore"; @@ -41,6 +43,7 @@ const CLOUD_POLLING_INTERVAL_MS = 500; // Re-export for external consumers export type { ExecutionMode, PermissionRequest }; +export type { SessionMode }; export function getExecutionModes( allowBypassPermissions: boolean, @@ -51,16 +54,31 @@ export function getExecutionModes( } export function cycleExecutionMode( - current: ExecutionMode, + currentModeId: string | undefined, + availableModes: SessionMode[] | undefined, allowBypassPermissions: boolean, -): ExecutionMode { - const modes = getExecutionModes(allowBypassPermissions); - const currentIndex = modes.indexOf(current); - if (currentIndex === -1) { - return "default"; +): string { + if (!availableModes || availableModes.length === 0) { + const fallbackModes = getExecutionModes(allowBypassPermissions); + const currentIndex = currentModeId + ? fallbackModes.indexOf(currentModeId as ExecutionMode) + : -1; + if (currentIndex === -1) return fallbackModes[0]; + return fallbackModes[(currentIndex + 1) % fallbackModes.length]; } - const nextIndex = (currentIndex + 1) % modes.length; - return modes[nextIndex]; + + const filteredModes = allowBypassPermissions + ? availableModes + : availableModes.filter( + (m) => m.id !== "bypassPermissions" && m.id !== "full-access", + ); + + if (filteredModes.length === 0) return availableModes[0].id; + + const currentIndex = filteredModes.findIndex((m) => m.id === currentModeId); + if (currentIndex === -1) return filteredModes[0].id; + + return filteredModes[(currentIndex + 1) % filteredModes.length].id; } export interface AgentModelOption { @@ -97,7 +115,8 @@ export interface AgentSession { availableModels?: AgentModelOption[]; framework?: "claude"; adapter?: "claude" | "codex"; - currentMode: ExecutionMode; + availableModes?: SessionMode[]; + currentModeId?: SessionModeId; pendingPermissions: Map; messageQueue: QueuedMessage[]; } @@ -111,7 +130,7 @@ interface SessionActions { task: Task; repoPath: string; initialPrompt?: ContentBlock[]; - executionMode?: ExecutionMode; + executionMode?: string; adapter?: "claude" | "codex"; }) => Promise; disconnectFromTask: (taskId: string) => Promise; @@ -121,7 +140,7 @@ interface SessionActions { ) => Promise<{ stopReason: string }>; cancelPrompt: (taskId: string) => Promise; setSessionModel: (taskId: string, modelId: string) => Promise; - setSessionMode: (taskId: string, modeId: ExecutionMode) => Promise; + setSessionMode: (taskId: string, modeId: string) => Promise; setCodexReasoningLevel: ( taskId: string, level: CodexReasoningLevel, @@ -241,21 +260,15 @@ function subscribeToChannel(taskRunId: string) { }; }; - // Handle mode updates from ExitPlanMode approval + // Handle mode updates from agent if ( params?.update?.sessionUpdate === "current_mode_update" && params.update.currentModeId ) { - const newMode = params.update.currentModeId as ExecutionMode; - if ( - newMode === "plan" || - newMode === "default" || - newMode === "acceptEdits" - ) { - session.currentMode = newMode; - setPersistedTaskMode(session.taskId, newMode); - log.info("Session mode updated", { taskRunId, newMode }); - } + const newModeId = params.update.currentModeId; + session.currentModeId = newModeId; + setPersistedTaskMode(session.taskId, newModeId); + log.info("Session mode updated", { taskRunId, newModeId }); } } @@ -499,7 +512,7 @@ function createBaseSession( taskId: string, taskTitle: string, isCloud: boolean, - executionMode?: ExecutionMode, + executionModeId?: string, ): AgentSession { return { taskRunId, @@ -512,7 +525,7 @@ function createBaseSession( isPromptPending: false, promptStartedAt: null, isCloud, - currentMode: executionMode ?? "default", + currentModeId: executionModeId, pendingPermissions: new Map(), messageQueue: [], }; @@ -680,7 +693,7 @@ const useStore = create()( session.logUrl = logUrl; session.model = model; if (persistedMode) { - session.currentMode = persistedMode; + session.currentModeId = persistedMode; } if (adapter) { session.adapter = adapter; @@ -753,7 +766,7 @@ const useStore = create()( repoPath: string, auth: AuthCredentials, initialPrompt?: ContentBlock[], - executionMode?: ExecutionMode, + executionMode?: string, adapter?: "claude" | "codex", ) => { if (!auth.client) { @@ -794,8 +807,10 @@ const useStore = create()( session.status = "connected"; session.model = result.currentModelId ?? selectedModel; session.availableModels = result.availableModels; - if (persistedMode && !executionMode) { - session.currentMode = persistedMode; + session.availableModes = result.availableModes; + session.currentModeId = result.currentModeId ?? effectiveMode; + if (adapter) { + session.adapter = adapter; } addSession(session); @@ -1270,7 +1285,7 @@ const useStore = create()( sessionId: session.taskRunId, modeId, }); - updateSession(session.taskRunId, { currentMode: modeId }); + updateSession(session.taskRunId, { currentModeId: modeId }); setPersistedTaskMode(taskId, modeId); } catch (error) { log.error("Failed to change session mode", { @@ -1615,12 +1630,49 @@ export function getPendingPermissionsForTask( } /** - * Hook to get the current execution mode for a task. - * Uses taskRunId lookup via a separate selector to ensure proper updates. + * Hook to get the current mode ID for a task. */ export const useCurrentModeForTask = ( taskId: string | undefined, -): ExecutionMode | undefined => { +): string | undefined => { + const taskRunId = useStore((s) => { + if (!taskId) return undefined; + for (const session of Object.values(s.sessions)) { + if (session.taskId === taskId) { + return session.taskRunId; + } + } + return undefined; + }); + + return useStore((s) => { + if (!taskRunId) return undefined; + return s.sessions[taskRunId]?.currentModeId; + }); +}; + +/** + * Hook to get the full current mode object for a task (with name, description). + */ +export const useCurrentModeObjectForTask = ( + taskId: string | undefined, +): SessionMode | undefined => { + return useStore((s) => { + if (!taskId) return undefined; + const session = Object.values(s.sessions).find( + (sess) => sess.taskId === taskId, + ); + if (!session?.currentModeId || !session.availableModes) return undefined; + return session.availableModes.find((m) => m.id === session.currentModeId); + }); +}; + +/** + * Hook to get available modes for a task. + */ +export const useAvailableModesForTask = ( + taskId: string | undefined, +): SessionMode[] | undefined => { const taskRunId = useStore((s) => { if (!taskId) return undefined; for (const session of Object.values(s.sessions)) { @@ -1633,7 +1685,7 @@ export const useCurrentModeForTask = ( return useStore((s) => { if (!taskRunId) return undefined; - return s.sessions[taskRunId]?.currentMode; + return s.sessions[taskRunId]?.availableModes; }); }; diff --git a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx index 9ea89e28d..fa63eb230 100644 --- a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx @@ -1,10 +1,6 @@ import { TorchGlow } from "@components/TorchGlow"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; -import { - cycleExecutionMode, - type ExecutionMode, -} from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; @@ -12,7 +8,7 @@ import { Flex } from "@radix-ui/themes"; import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useTaskDirectoryStore } from "@stores/taskDirectoryStore"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { AdapterSelect } from "./AdapterSelect"; import { SuggestedTasks } from "./SuggestedTasks"; @@ -29,7 +25,6 @@ export function TaskInput() { setLastUsedLocalWorkspaceMode, lastUsedAdapter, setLastUsedAdapter, - allowBypassPermissions, } = useSettingsStore(); const editorRef = useRef(null); @@ -37,7 +32,6 @@ export function TaskInput() { const runMode = "local"; const [editorIsEmpty, setEditorIsEmpty] = useState(true); - const [executionMode, setExecutionMode] = useState("default"); const selectedDirectory = lastUsedDirectory || ""; const workspaceMode = lastUsedLocalWorkspaceMode || "worktree"; @@ -50,18 +44,6 @@ export function TaskInput() { const setAdapter = (newAdapter: AgentAdapter) => setLastUsedAdapter(newAdapter); - useEffect(() => { - if (!allowBypassPermissions && executionMode === "bypassPermissions") { - setExecutionMode("default"); - } - }, [allowBypassPermissions, executionMode]); - - const handleModeChange = useCallback(() => { - setExecutionMode((current) => - cycleExecutionMode(current, allowBypassPermissions), - ); - }, [allowBypassPermissions]); - const { githubIntegration } = useRepositoryIntegration(); useEffect(() => { @@ -83,7 +65,6 @@ export function TaskInput() { workspaceMode: effectiveWorkspaceMode, branch: null, editorIsEmpty, - executionMode: executionMode === "default" ? undefined : executionMode, adapter, }); @@ -171,8 +152,6 @@ export function TaskInput() { onSubmit={handleSubmit} hasDirectory={!!selectedDirectory} onEmptyChange={setEditorIsEmpty} - executionMode={executionMode} - onModeChange={handleModeChange} adapter={adapter} /> diff --git a/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx index 41964689d..e38bbf42c 100644 --- a/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/TaskInputEditor.tsx @@ -1,15 +1,12 @@ import "@features/message-editor/components/message-editor.css"; import { EditorToolbar } from "@features/message-editor/components/EditorToolbar"; import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; -import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; import { useTiptapEditor } from "@features/message-editor/tiptap/useTiptapEditor"; -import type { ExecutionMode } from "@features/sessions/stores/sessionStore"; import { useConnectivity } from "@hooks/useConnectivity"; import { ArrowUp } from "@phosphor-icons/react"; import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; import { EditorContent } from "@tiptap/react"; import { forwardRef, useImperativeHandle } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import type { RunMode } from "./RunModeSelect"; import "./TaskInput.css"; @@ -22,8 +19,6 @@ interface TaskInputEditorProps { onSubmit: () => void; hasDirectory: boolean; onEmptyChange?: (isEmpty: boolean) => void; - executionMode: ExecutionMode; - onModeChange: () => void; adapter?: "claude" | "codex"; } @@ -41,8 +36,6 @@ export const TaskInputEditor = forwardRef< onSubmit, hasDirectory, onEmptyChange, - executionMode, - onModeChange, adapter, }, ref, @@ -51,20 +44,6 @@ export const TaskInputEditor = forwardRef< const { isOnline } = useConnectivity(); const isDisabled = isCreatingTask || !isOnline; - useHotkeys( - "shift+tab", - (e) => { - e.preventDefault(); - onModeChange(); - }, - { - enableOnFormTags: true, - enableOnContentEditable: true, - enabled: !isCreatingTask && !isCloudMode, - }, - [onModeChange, isCreatingTask, isCloudMode], - ); - const { editor, isEmpty, @@ -127,118 +106,115 @@ export const TaskInputEditor = forwardRef< }; return ( - <> + { + const target = e.target as HTMLElement; + if (!target.closest(".ProseMirror")) { + focus(); + } }} > { - const target = e.target as HTMLElement; - if (!target.closest(".ProseMirror")) { - focus(); - } + minWidth: 0, }} > - + > + + {isCreatingTask ? ( - > + Creating task... - {isCreatingTask ? ( - - Creating task... - - ) : ( - - - - )} - + ) : ( + + + + )} + - - + + - - - { - e.stopPropagation(); - onSubmit(); - }} - disabled={!canSubmit || isDisabled} - loading={isCreatingTask} - style={{ - backgroundColor: - !canSubmit || isDisabled ? "var(--accent-a4)" : undefined, - color: - !canSubmit || isDisabled ? "var(--accent-8)" : undefined, - }} - > - - - - + + + { + e.stopPropagation(); + onSubmit(); + }} + disabled={!canSubmit || isDisabled} + loading={isCreatingTask} + style={{ + backgroundColor: + !canSubmit || isDisabled ? "var(--accent-a4)" : undefined, + color: + !canSubmit || isDisabled ? "var(--accent-8)" : undefined, + }} + > + + + - {!isCloudMode && } - + ); }, ); diff --git a/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts index e208a3e6a..d38030d0e 100644 --- a/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -10,7 +10,7 @@ import type { TaskCreationInput, TaskService, } from "@renderer/services/task/service"; -import type { ExecutionMode, WorkspaceMode } from "@shared/types"; +import type { WorkspaceMode } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useCallback, useState } from "react"; import { toast } from "sonner"; @@ -25,7 +25,7 @@ interface UseTaskCreationOptions { workspaceMode: WorkspaceMode; branch?: string | null; editorIsEmpty: boolean; - executionMode?: ExecutionMode; + executionMode?: string; adapter?: "claude" | "codex"; } @@ -80,7 +80,7 @@ function prepareTaskInput( githubIntegrationId?: number; workspaceMode: WorkspaceMode; branch?: string | null; - executionMode?: ExecutionMode; + executionMode?: string; adapter?: "claude" | "codex"; }, ): TaskCreationInput { diff --git a/apps/twig/src/renderer/sagas/task/task-creation.ts b/apps/twig/src/renderer/sagas/task/task-creation.ts index b5274e347..890a50f82 100644 --- a/apps/twig/src/renderer/sagas/task/task-creation.ts +++ b/apps/twig/src/renderer/sagas/task/task-creation.ts @@ -7,12 +7,7 @@ import { logger } from "@renderer/lib/logger"; import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore"; import { trpcVanilla } from "@renderer/trpc"; import { getTaskRepository } from "@renderer/utils/repository"; -import type { - ExecutionMode, - Task, - Workspace, - WorkspaceMode, -} from "@shared/types"; +import type { Task, Workspace, WorkspaceMode } from "@shared/types"; const log = logger.scope("task-creation-saga"); @@ -35,7 +30,7 @@ export interface TaskCreationInput { workspaceMode?: WorkspaceMode; branch?: string | null; githubIntegrationId?: number; - executionMode?: ExecutionMode; + executionMode?: string; adapter?: "claude" | "codex"; } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index df08235e3..d915b3781 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -2,6 +2,7 @@ import { createAcpConnection, type InProcessAcpConnection, } from "./adapters/acp-connection.js"; +import { BLOCKED_MODELS, DEFAULT_GATEWAY_MODEL } from "./gateway-models.js"; import { PostHogAPIClient } from "./posthog-api.js"; import { SessionLogWriter } from "./session-log-writer.js"; import type { AgentConfig, TaskExecutionOptions } from "./types.js"; @@ -65,6 +66,11 @@ export class Agent { this.taskRunId = taskRunId; + const sanitizedModel = + options.model && !BLOCKED_MODELS.has(options.model) + ? options.model + : DEFAULT_GATEWAY_MODEL; + this.acpConnection = createAcpConnection({ adapter: options.adapter, logWriter: this.sessionLogWriter, @@ -79,7 +85,7 @@ export class Agent { apiBaseUrl: `${gatewayConfig.gatewayUrl}/v1`, apiKey: gatewayConfig.apiKey, binaryPath: options.codexBinaryPath, - model: options.model ?? "gpt-5.2", + model: sanitizedModel, } : undefined, }); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 65dc7dcac..c5b365888 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -17,6 +17,8 @@ export interface FetchGatewayModelsOptions { export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-5"; +export const BLOCKED_MODELS = new Set(["gpt-5-mini", "openai/gpt-5-mini"]); + export async function fetchGatewayModels( options?: FetchGatewayModelsOptions, ): Promise { @@ -35,7 +37,8 @@ export async function fetchGatewayModels( } const data = (await response.json()) as GatewayModelsResponse; - return data.data ?? []; + const models = data.data ?? []; + return models.filter((m) => !BLOCKED_MODELS.has(m.id)); } catch { return []; } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 1eb2beb45..8a0fe0e7a 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -25,6 +25,15 @@ export type { export { createAcpConnection } from "./adapters/acp-connection.js"; export type { CodexProcessOptions } from "./adapters/codex/spawn.js"; export { Agent } from "./agent.js"; +export { + BLOCKED_MODELS, + DEFAULT_GATEWAY_MODEL, + type FetchGatewayModelsOptions, + fetchGatewayModels, + formatGatewayModelName, + type GatewayModel, + getProviderName, +} from "./gateway-models.js"; export { PostHogAPIClient } from "./posthog-api.js"; export type { ConversationTurn, From 674497fcd23892d6cb7cd94bb2b9c342052253fb Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 4 Feb 2026 20:44:40 +0100 Subject: [PATCH 3/6] more mode fixes --- .../components/MessageEditor.tsx | 8 ++++++++ .../sessions/components/SessionView.tsx | 15 ++++++++++++--- .../features/sessions/stores/sessionStore.ts | 2 ++ .../agent/src/adapters/claude/claude-agent.ts | 19 ++++++++++++++++--- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx index 555a68c51..4e4f81317 100644 --- a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -216,6 +216,14 @@ export const MessageEditor = forwardRef( {onModeChange && (currentMode || currentModeId) && ( )} + {onModeChange && !currentMode && !currentModeId && ( + + Loading... + + )} {adapter && } diff --git a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx index 8e5c60d97..0122fed8b 100644 --- a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx @@ -86,11 +86,20 @@ export function SessionView({ (currentModeId === "bypassPermissions" || currentModeId === "full-access") && taskId && - !isCloud + !isCloud && + availableModes && + availableModes.length > 0 ) { - setSessionMode(taskId, "default"); + setSessionMode(taskId, availableModes[0].id); } - }, [allowBypassPermissions, currentModeId, taskId, isCloud, setSessionMode]); + }, [ + allowBypassPermissions, + currentModeId, + taskId, + isCloud, + setSessionMode, + availableModes, + ]); const handleModeChange = useCallback(() => { if (!taskId || isCloud) return; diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts index 23ec8f0d9..2767368a1 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts @@ -722,6 +722,8 @@ const useStore = create()( status: "connected", model: sessionModel, availableModels: result.availableModels, + availableModes: result.availableModes, + currentModeId: result.currentModeId, }); if (persistedMode) { diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 000b9f007..14ba8fab9 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -230,7 +230,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await getAvailableSlashCommands(q), ); - return { _meta: { models: await this.getAvailableModels() } }; + return { + models: await this.getAvailableModels(), + modes: { + currentModeId: session.permissionMode, + availableModes: getAvailableModes(), + }, + }; } async prompt(params: PromptRequest): Promise { @@ -271,8 +277,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent { params: Record, ): Promise> { if (method === "_posthog/session/resume") { - await this.resumeSession(params as unknown as LoadSessionRequest); - return {}; + const result = await this.resumeSession( + params as unknown as LoadSessionRequest, + ); + return { + _meta: { + models: result.models, + modes: result.modes, + }, + }; } throw RequestError.methodNotFound(method); From 3b872d9ccc997de7cf51db605736e7dcb7ea218b Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 5 Feb 2026 15:47:53 +0100 Subject: [PATCH 4/6] use session configs for models, mode, and thinking level --- apps/twig/scripts/download-codex-acp.mjs | 26 +- apps/twig/src/main/services/agent/schemas.ts | 61 ++-- apps/twig/src/main/services/agent/service.ts | 102 +----- apps/twig/src/main/trpc/routers/agent.ts | 14 - .../components/MessageEditor.tsx | 14 +- .../components/ModeIndicatorInput.tsx | 31 +- .../sessions/components/ConversationView.tsx | 2 +- .../sessions/components/ModelSelector.tsx | 132 +++---- .../components/ReasoningLevelSelector.tsx | 52 +-- .../sessions/components/SessionView.tsx | 78 ++-- .../session-update/SessionUpdateView.tsx | 2 +- .../sessions/stores/sessionModeStore.ts | 42 --- .../features/sessions/stores/sessionStore.ts | 336 ++++++++---------- .../src/renderer/features/sessions/types.ts | 4 +- .../sessions/utils/parseSessionLogs.ts | 16 +- apps/twig/vite.main.config.mts | 7 +- packages/agent/src/adapters/acp-connection.ts | 155 ++++++++ packages/agent/src/adapters/base-acp-agent.ts | 56 +-- .../agent/src/adapters/claude/claude-agent.ts | 147 ++++++-- .../claude/permissions/permission-handlers.ts | 21 +- packages/agent/src/adapters/claude/types.ts | 3 +- packages/agent/src/agent.ts | 37 +- packages/agent/src/gateway-models.ts | 59 +++ packages/agent/src/index.ts | 3 + 24 files changed, 743 insertions(+), 657 deletions(-) delete mode 100644 apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts diff --git a/apps/twig/scripts/download-codex-acp.mjs b/apps/twig/scripts/download-codex-acp.mjs index 0b0e85d8e..931c0a7c0 100644 --- a/apps/twig/scripts/download-codex-acp.mjs +++ b/apps/twig/scripts/download-codex-acp.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node -import { createWriteStream, existsSync, mkdirSync, chmodSync } from "node:fs"; -import { join, dirname } from "node:path"; +import { execSync } from "node:child_process"; +import { chmodSync, createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; -import { createGunzip } from "node:zlib"; -import { extract } from "tar"; import { fileURLToPath } from "node:url"; -import { execSync } from "node:child_process"; +import { extract } from "tar"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -18,9 +17,7 @@ function getPlatformTarget() { const arch = process.arch; if (platform === "darwin") { - return arch === "arm64" - ? "aarch64-apple-darwin" - : "x86_64-apple-darwin"; + return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"; } if (platform === "linux") { @@ -53,7 +50,9 @@ async function downloadFile(url, destPath) { const response = await fetch(url, { redirect: "follow" }); if (!response.ok) { - throw new Error(`Failed to download: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to download: ${response.status} ${response.statusText}`, + ); } const fileStream = createWriteStream(destPath); @@ -100,7 +99,10 @@ async function main() { const target = getPlatformTarget(); const url = getDownloadUrl(target); const isZip = url.endsWith(".zip"); - const archivePath = join(destDir, isZip ? "codex-acp.zip" : "codex-acp.tar.gz"); + const archivePath = join( + destDir, + isZip ? "codex-acp.zip" : "codex-acp.tar.gz", + ); await downloadFile(url, archivePath); @@ -124,7 +126,9 @@ async function main() { } try { - execSync(`codesign --force --sign - "${binaryPath}"`, { stdio: "inherit" }); + execSync(`codesign --force --sign - "${binaryPath}"`, { + stdio: "inherit", + }); console.log("Ad-hoc signed binary for macOS"); } catch (err) { console.warn("Failed to ad-hoc sign binary:", err.message); diff --git a/apps/twig/src/main/services/agent/schemas.ts b/apps/twig/src/main/services/agent/schemas.ts index 02f40b246..837618734 100644 --- a/apps/twig/src/main/services/agent/schemas.ts +++ b/apps/twig/src/main/services/agent/schemas.ts @@ -22,8 +22,6 @@ export const sessionConfigSchema = z.object({ logUrl: z.string().optional(), /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId: z.string().optional(), - model: z.string().optional(), - executionMode: z.string().optional(), adapter: z.enum(["claude", "codex"]).optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), @@ -42,8 +40,6 @@ export const startSessionInput = z.object({ projectId: z.number(), permissionMode: z.string().optional(), autoProgress: z.boolean().optional(), - model: z.string().optional(), - executionMode: z.string().optional(), runMode: z.enum(["local", "cloud"]).optional(), adapter: z.enum(["claude", "codex"]).optional(), additionalDirectories: z.array(z.string()).optional(), @@ -60,21 +56,45 @@ export const modelOptionSchema = z.object({ export type ModelOption = z.infer; -export const modeOptionSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullish(), -}); +const sessionConfigSelectOptionSchema = z + .object({ + value: z.string(), + name: z.string(), + description: z.string().nullish(), + _meta: z.record(z.string(), z.unknown()).nullish(), + }) + .passthrough(); + +const sessionConfigSelectGroupSchema = z + .object({ + group: z.string(), + name: z.string(), + options: z.array(sessionConfigSelectOptionSchema), + _meta: z.record(z.string(), z.unknown()).nullish(), + }) + .passthrough(); -export type ModeOption = z.infer; +export const sessionConfigOptionSchema = z + .object({ + id: z.string(), + name: z.string(), + type: z.literal("select"), + currentValue: z.string(), + options: z + .array(sessionConfigSelectOptionSchema) + .or(z.array(sessionConfigSelectGroupSchema)), + category: z.string().nullish(), + description: z.string().nullish(), + _meta: z.record(z.string(), z.unknown()).nullish(), + }) + .passthrough(); + +export type SessionConfigOption = z.infer; export const sessionResponseSchema = z.object({ sessionId: z.string(), channel: z.string(), - availableModels: z.array(modelOptionSchema).optional(), - currentModelId: z.string().optional(), - availableModes: z.array(modeOptionSchema).optional(), - currentModeId: z.string().optional(), + configOptions: z.array(sessionConfigOptionSchema).optional(), }); export type SessionResponse = z.infer; @@ -135,7 +155,6 @@ export const reconnectSessionInput = z.object({ logUrl: z.string().optional(), sessionId: z.string().optional(), adapter: z.enum(["claude", "codex"]).optional(), - model: z.string().optional(), /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories: z.array(z.string()).optional(), }); @@ -147,18 +166,6 @@ export const tokenUpdateInput = z.object({ token: z.string(), }); -// Set model input -export const setModelInput = z.object({ - sessionId: z.string(), - modelId: z.string(), -}); - -// Set mode input - accepts any agent-defined mode ID -export const setModeInput = z.object({ - sessionId: z.string(), - modeId: z.string(), -}); - // Set config option input (for Codex reasoning level, etc.) export const setConfigOptionInput = z.object({ sessionId: z.string(), diff --git a/apps/twig/src/main/services/agent/service.ts b/apps/twig/src/main/services/agent/service.ts index 5a30c0522..f166bddff 100644 --- a/apps/twig/src/main/services/agent/service.ts +++ b/apps/twig/src/main/services/agent/service.ts @@ -9,8 +9,7 @@ import { PROTOCOL_VERSION, type RequestPermissionRequest, type RequestPermissionResponse, - type SessionMode, - type SessionModeId, + type SessionConfigOption, } from "@agentclientprotocol/sdk"; import { Agent } from "@posthog/agent/agent"; import { @@ -171,8 +170,6 @@ interface SessionConfig { logUrl?: string; /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ sessionId?: string; - model?: string; - executionMode?: string; adapter?: "claude" | "codex"; /** Additional directories Claude can access beyond cwd (for worktree support) */ additionalDirectories?: string[]; @@ -194,14 +191,7 @@ interface ManagedSession { recreationPromise?: Promise; promptPending: boolean; pendingContext?: string; - availableModels?: Array<{ - modelId: string; - name: string; - description?: string | null; - }>; - currentModelId?: string; - availableModes?: SessionMode[]; - currentModeId?: SessionModeId; + configOptions?: SessionConfigOption[]; sessionId: string; } @@ -421,8 +411,6 @@ export class AgentService extends TypedEventEmitter { credentials, logUrl, sessionId: existingSessionId, - model, - executionMode, adapter, additionalDirectories, } = config; @@ -457,7 +445,6 @@ export class AgentService extends TypedEventEmitter { try { const acpConnection = await agent.run(taskId, taskRunId, { adapter, - model, codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined, processCallbacks: { onProcessSpawned: (info) => { @@ -500,12 +487,7 @@ export class AgentService extends TypedEventEmitter { const mcpServers = adapter === "codex" ? [] : this.buildMcpServers(credentials); - let availableModels: - | Array<{ modelId: string; name: string; description?: string | null }> - | undefined; - let currentModelId: string | undefined; - let availableModes: SessionMode[] | undefined; - let currentModeId: SessionModeId | undefined; + let configOptions: SessionConfigOption[] | undefined; let agentSessionId: string; if (isReconnect && adapter === "codex" && config.sessionId) { @@ -514,10 +496,7 @@ export class AgentService extends TypedEventEmitter { cwd: repoPath, mcpServers, }); - availableModels = loadResponse.models?.availableModels; - currentModelId = loadResponse.models?.currentModelId; - availableModes = loadResponse.modes?.availableModes; - currentModeId = loadResponse.modes?.currentModeId; + configOptions = loadResponse.configOptions ?? undefined; agentSessionId = config.sessionId; } else if (isReconnect && adapter !== "codex") { const systemPrompt = this.buildPostHogSystemPrompt(credentials); @@ -544,20 +523,10 @@ export class AgentService extends TypedEventEmitter { ); const resumeMeta = resumeResponse?._meta as | { - models?: { - availableModels?: typeof availableModels; - currentModelId?: string; - }; - modes?: { - availableModes?: SessionMode[]; - currentModeId?: SessionModeId; - }; + configOptions?: SessionConfigOption[]; } | undefined; - availableModels = resumeMeta?.models?.availableModels; - currentModelId = resumeMeta?.models?.currentModelId; - availableModes = resumeMeta?.modes?.availableModes; - currentModeId = resumeMeta?.modes?.currentModeId; + configOptions = resumeMeta?.configOptions; agentSessionId = (resumeResponse?.sessionId as string) ?? taskRunId; } else { const systemPrompt = this.buildPostHogSystemPrompt(credentials); @@ -566,9 +535,7 @@ export class AgentService extends TypedEventEmitter { mcpServers, _meta: { taskRunId, - model, systemPrompt, - ...(executionMode && { initialModeId: executionMode }), ...(additionalDirectories?.length && { claudeCode: { options: { additionalDirectories }, @@ -576,10 +543,7 @@ export class AgentService extends TypedEventEmitter { }), }, }); - availableModels = newSessionResponse.models?.availableModels; - currentModelId = newSessionResponse.models?.currentModelId; - availableModes = newSessionResponse.modes?.availableModes; - currentModeId = newSessionResponse.modes?.currentModeId; + configOptions = newSessionResponse.configOptions ?? undefined; agentSessionId = newSessionResponse.sessionId; } @@ -598,10 +562,7 @@ export class AgentService extends TypedEventEmitter { config, needsRecreation: false, promptPending: false, - availableModels, - currentModelId, - availableModes, - currentModeId, + configOptions, sessionId: agentSessionId, }; @@ -780,42 +741,6 @@ export class AgentService extends TypedEventEmitter { return this.sessions.get(taskRunId); } - async setSessionModel(sessionId: string, modelId: string): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`Session not found: ${sessionId}`); - } - - try { - await session.clientSideConnection.unstable_setSessionModel({ - sessionId: session.sessionId, - modelId, - }); - log.info("Session model updated", { sessionId, modelId }); - } catch (err) { - log.error("Failed to set session model", { sessionId, modelId, err }); - throw err; - } - } - - async setSessionMode(sessionId: string, modeId: string): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`Session not found: ${sessionId}`); - } - - try { - await session.clientSideConnection.setSessionMode({ - sessionId: session.sessionId, - modeId, - }); - log.info("Session mode updated", { sessionId, modeId }); - } catch (err) { - log.error("Failed to set session mode", { sessionId, modeId, err }); - throw err; - } - } - async setSessionConfigOption( sessionId: string, configId: string, @@ -827,11 +752,12 @@ export class AgentService extends TypedEventEmitter { } try { - await session.clientSideConnection.setSessionConfigOption({ + const result = await session.clientSideConnection.setSessionConfigOption({ sessionId: session.sessionId, configId, value, }); + session.configOptions = result.configOptions ?? session.configOptions; log.info("Session config option updated", { sessionId, configId, value }); } catch (err) { log.error("Failed to set session config option", { @@ -1229,9 +1155,6 @@ For git operations while detached: }, logUrl: "logUrl" in params ? params.logUrl : undefined, sessionId: "sessionId" in params ? params.sessionId : undefined, - model: "model" in params ? params.model : undefined, - executionMode: - "executionMode" in params ? params.executionMode : undefined, adapter: "adapter" in params ? params.adapter : undefined, additionalDirectories: "additionalDirectories" in params @@ -1244,10 +1167,7 @@ For git operations while detached: return { sessionId: session.taskRunId, channel: session.channel, - availableModels: session.availableModels, - currentModelId: session.currentModelId, - availableModes: session.availableModes, - currentModeId: session.currentModeId, + configOptions: session.configOptions, }; } diff --git a/apps/twig/src/main/trpc/routers/agent.ts b/apps/twig/src/main/trpc/routers/agent.ts index 81f436f26..67d4e0b2f 100644 --- a/apps/twig/src/main/trpc/routers/agent.ts +++ b/apps/twig/src/main/trpc/routers/agent.ts @@ -17,8 +17,6 @@ import { respondToPermissionInput, sessionResponseSchema, setConfigOptionInput, - setModeInput, - setModelInput, startSessionInput, subscribeSessionInput, tokenUpdateInput, @@ -60,18 +58,6 @@ export const agentRouter = router({ getService().updateToken(input.token); }), - setModel: publicProcedure - .input(setModelInput) - .mutation(({ input }) => - getService().setSessionModel(input.sessionId, input.modelId), - ), - - setMode: publicProcedure - .input(setModeInput) - .mutation(({ input }) => - getService().setSessionMode(input.sessionId, input.modeId), - ), - setConfigOption: publicProcedure .input(setConfigOptionInput) .mutation(({ input }) => diff --git a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx index 4e4f81317..f2adc4af7 100644 --- a/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx +++ b/apps/twig/src/renderer/features/message-editor/components/MessageEditor.tsx @@ -1,5 +1,5 @@ import "./message-editor.css"; -import type { SessionMode } from "@agentclientprotocol/sdk"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { useConnectivity } from "@hooks/useConnectivity"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; @@ -27,8 +27,7 @@ interface MessageEditorProps { onCancel?: () => void; onAttachFiles?: (files: File[]) => void; autoFocus?: boolean; - currentMode?: SessionMode; - currentModeId?: string; + modeOption?: SessionConfigOption; onModeChange?: () => void; adapter?: "claude" | "codex"; } @@ -44,8 +43,7 @@ export const MessageEditor = forwardRef( onCancel, onAttachFiles, autoFocus = false, - currentMode, - currentModeId, + modeOption, onModeChange, adapter, }, @@ -213,10 +211,10 @@ export const MessageEditor = forwardRef( {(onModeChange || adapter) && ( - {onModeChange && (currentMode || currentModeId) && ( - + {onModeChange && modeOption && ( + )} - {onModeChange && !currentMode && !currentModeId && ( + {onModeChange && !modeOption && ( group.options, + ); + } + return options as SessionConfigSelectOption[]; +} + +export function ModeIndicatorInput({ modeOption }: ModeIndicatorInputProps) { + const id = modeOption.currentValue; const style = MODE_STYLES[id] ?? DEFAULT_STYLE; - const label = mode?.name ?? id; + const option = flattenOptions(modeOption.options).find( + (opt) => opt.value === id, + ); + const label = option?.name ?? id; return ( diff --git a/apps/twig/src/renderer/features/sessions/components/ConversationView.tsx b/apps/twig/src/renderer/features/sessions/components/ConversationView.tsx index 9129cd6af..5d76c96c7 100644 --- a/apps/twig/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/ConversationView.tsx @@ -479,7 +479,7 @@ function processSessionUpdate(turn: Turn, update: SessionUpdate) { case "plan": case "available_commands_update": - case "current_mode_update": + case "config_option_update": turn.items.push(update); break; diff --git a/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx b/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx index 834273389..edac39f2c 100644 --- a/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx +++ b/apps/twig/src/renderer/features/sessions/components/ModelSelector.tsx @@ -1,11 +1,12 @@ +import type { SessionConfigSelectGroup } from "@agentclientprotocol/sdk"; import { Select, Text } from "@radix-ui/themes"; -import { Fragment, useEffect, useMemo } from "react"; +import { Fragment, useMemo } from "react"; import { - type GroupedModels, - type ModelOption, - useModelsStore, -} from "../stores/modelsStore"; -import { useSessionActions, useSessionForTask } from "../stores/sessionStore"; + flattenSelectOptions, + useModelConfigOptionForTask, + useSessionActions, + useSessionForTask, +} from "../stores/sessionStore"; interface ModelSelectorProps { taskId?: string; @@ -14,93 +15,42 @@ interface ModelSelectorProps { adapter?: "claude" | "codex"; } -function getProviderForAdapter(adapter: "claude" | "codex"): string { - return adapter === "claude" ? "Anthropic" : "OpenAI"; -} - -function filterModelsByAdapter( - grouped: GroupedModels[], - adapter?: "claude" | "codex", -): GroupedModels[] { - if (!adapter) return grouped; - const allowedProvider = getProviderForAdapter(adapter); - return grouped.filter((group) => group.provider === allowedProvider); -} - -function isModelCompatibleWithAdapter( - model: ModelOption | undefined, - adapter: "claude" | "codex" | undefined, -): boolean { - if (!model || !adapter) return true; - const allowedProvider = getProviderForAdapter(adapter); - return model.provider === allowedProvider; -} - -function getDefaultModelForAdapter( - models: ModelOption[], - adapter: "claude" | "codex", -): string | undefined { - const allowedProvider = getProviderForAdapter(adapter); - const compatibleModel = models.find((m) => m.provider === allowedProvider); - return compatibleModel?.modelId; -} - -function stripReasoningSuffix(modelId: string | undefined): string | undefined { - if (!modelId) return modelId; - return modelId.replace(/\/(minimal|low|medium|high|xhigh)$/, ""); -} - export function ModelSelector({ taskId, disabled, onModelChange, - adapter, + adapter: _adapter, }: ModelSelectorProps) { - const { setSessionModel } = useSessionActions(); + const { setSessionConfigOption } = useSessionActions(); const session = useSessionForTask(taskId); + const modelOption = useModelConfigOptionForTask(taskId); - const groupedModels = useModelsStore((s) => s.groupedModels); - const models = useModelsStore((s) => s.models); - const selectedModel = useModelsStore((s) => s.selectedModel); - const setSelectedModel = useModelsStore((s) => s.setSelectedModel); - - const effectiveAdapter = adapter ?? session?.adapter; - const filteredGroupedModels = useMemo( - () => filterModelsByAdapter(groupedModels, effectiveAdapter), - [groupedModels, effectiveAdapter], - ); - - const rawSessionModel = session?.model; - const sessionModel = stripReasoningSuffix(rawSessionModel); - const activeModel = sessionModel ?? selectedModel; - const currentModel = models.find((m) => m.modelId === activeModel); - - useEffect(() => { - if (!effectiveAdapter || !models.length) return; - - if (!isModelCompatibleWithAdapter(currentModel, effectiveAdapter)) { - const defaultModel = getDefaultModelForAdapter(models, effectiveAdapter); - if (defaultModel) { - setSelectedModel(defaultModel); - onModelChange?.(defaultModel); - } + const options = modelOption ? flattenSelectOptions(modelOption.options) : []; + const groupedOptions = useMemo(() => { + if (!modelOption || modelOption.options.length === 0) return []; + if ("group" in modelOption.options[0]) { + return modelOption.options as SessionConfigSelectGroup[]; } - }, [effectiveAdapter, currentModel, models, setSelectedModel, onModelChange]); + return []; + }, [modelOption]); + + if (!modelOption || options.length === 0) return null; const handleChange = (value: string) => { - setSelectedModel(value); onModelChange?.(value); if (taskId && session?.status === "connected" && !session.isCloud) { - setSessionModel(taskId, value); + setSessionConfigOption(taskId, modelOption.id, value); } }; - const displayName = currentModel?.name ?? activeModel; + const currentValue = modelOption.currentValue; + const currentLabel = + options.find((opt) => opt.value === currentValue)?.name ?? currentValue; return ( - {displayName} + {currentLabel} - {filteredGroupedModels.map((group, index) => ( - - {index > 0 && } - - {group.provider} - {group.models.map((model) => ( - - {model.name} - - ))} - - - ))} + {groupedOptions.length > 0 + ? groupedOptions.map((group, index) => ( + + {index > 0 && } + + {group.name} + {group.options.map((model) => ( + + {model.name} + + ))} + + + )) + : options.map((model) => ( + + {model.name} + + ))} ); diff --git a/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx b/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx index 6258fc395..aa1219bf5 100644 --- a/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx +++ b/apps/twig/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx @@ -1,9 +1,9 @@ import { Select, Text } from "@radix-ui/themes"; import { - type CodexReasoningLevel, - useCodexReasoningLevelForTask, + flattenSelectOptions, useSessionActions, useSessionForTask, + useThoughtLevelConfigOptionForTask, } from "../stores/sessionStore"; interface ReasoningLevelSelectorProps { @@ -11,50 +11,27 @@ interface ReasoningLevelSelectorProps { disabled?: boolean; } -const REASONING_LEVELS: { value: CodexReasoningLevel; label: string }[] = [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "XHigh" }, -]; - -function supportsConfigurableReasoning(model: string | undefined): boolean { - if (!model) return false; - const normalizedModel = model.toLowerCase(); - return normalizedModel.includes("gpt-5.2"); -} - -function extractReasoningFromModelId( - modelId: string | undefined, -): CodexReasoningLevel | undefined { - if (!modelId) return undefined; - const match = modelId.match(/\/(low|medium|high|xhigh)$/); - return match ? (match[1] as CodexReasoningLevel) : undefined; -} - export function ReasoningLevelSelector({ taskId, disabled, }: ReasoningLevelSelectorProps) { - const { setCodexReasoningLevel } = useSessionActions(); + const { setSessionConfigOption } = useSessionActions(); const session = useSessionForTask(taskId); - const reasoningLevel = useCodexReasoningLevelForTask(taskId); - - const isCodex = session?.adapter === "codex"; - const hasConfigurableReasoning = supportsConfigurableReasoning( - session?.model, - ); + const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); - if (!isCodex || !hasConfigurableReasoning) { + if (!thoughtOption) { return null; } - const levelFromModelId = extractReasoningFromModelId(session?.model); - const activeLevel = reasoningLevel ?? levelFromModelId ?? "medium"; + const options = flattenSelectOptions(thoughtOption.options); + if (options.length === 0) return null; + const activeLevel = thoughtOption.currentValue; + const activeLabel = + options.find((opt) => opt.value === activeLevel)?.name ?? activeLevel; const handleChange = (value: string) => { if (taskId && session?.status === "connected" && !session.isCloud) { - setCodexReasoningLevel(taskId, value as CodexReasoningLevel); + setSessionConfigOption(taskId, thoughtOption.id, value); } }; @@ -77,14 +54,13 @@ export function ReasoningLevelSelector({ }} > - Reasoning:{" "} - {REASONING_LEVELS.find((l) => l.value === activeLevel)?.label} + Reasoning: {activeLabel} - {REASONING_LEVELS.map((level) => ( + {options.map((level) => ( - {level.label} + {level.name} ))} diff --git a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx index 0122fed8b..503e13fc7 100644 --- a/apps/twig/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/SessionView.tsx @@ -5,11 +5,10 @@ import { } from "@features/message-editor/components/MessageEditor"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { - cycleExecutionMode, + cycleModeOption, + flattenSelectOptions, useAdapterForTask, - useAvailableModesForTask, - useCurrentModeForTask, - useCurrentModeObjectForTask, + useModeConfigOptionForTask, usePendingPermissionsForTask, useSessionActions, } from "@features/sessions/stores/sessionStore"; @@ -72,13 +71,15 @@ export function SessionView({ const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); const pendingPermissions = usePendingPermissionsForTask(taskId); - const { respondToPermission, cancelPermission, setSessionMode } = - useSessionActions(); - const currentModeId = useCurrentModeForTask(taskId); - const currentMode = useCurrentModeObjectForTask(taskId); - const availableModes = useAvailableModesForTask(taskId); + const { + respondToPermission, + cancelPermission, + setSessionConfigOptionByCategory, + } = useSessionActions(); + const modeOption = useModeConfigOptionForTask(taskId); const adapter = useAdapterForTask(taskId); const { allowBypassPermissions } = useSettingsStore(); + const currentModeId = modeOption?.currentValue; useEffect(() => { if ( @@ -87,35 +88,39 @@ export function SessionView({ currentModeId === "full-access") && taskId && !isCloud && - availableModes && - availableModes.length > 0 + modeOption ) { - setSessionMode(taskId, availableModes[0].id); + const options = flattenSelectOptions(modeOption.options); + const safeOption = + options.find( + (opt) => + opt.value !== "bypassPermissions" && opt.value !== "full-access", + ) ?? options[0]; + if (safeOption) { + setSessionConfigOptionByCategory(taskId, "mode", safeOption.value); + } } }, [ allowBypassPermissions, currentModeId, taskId, isCloud, - setSessionMode, - availableModes, + modeOption, + setSessionConfigOptionByCategory, ]); const handleModeChange = useCallback(() => { if (!taskId || isCloud) return; - const nextMode = cycleExecutionMode( - currentModeId, - availableModes, - allowBypassPermissions, - ); - setSessionMode(taskId, nextMode); + const nextMode = cycleModeOption(modeOption, allowBypassPermissions); + if (nextMode) { + setSessionConfigOptionByCategory(taskId, "mode", nextMode); + } }, [ taskId, isCloud, - currentModeId, - availableModes, allowBypassPermissions, - setSessionMode, + modeOption, + setSessionConfigOptionByCategory, ]); const sessionId = taskId ?? "default"; @@ -138,25 +143,23 @@ export function SessionView({ (e) => { e.preventDefault(); if (!taskId || isCloud) return; - const nextMode = cycleExecutionMode( - currentModeId, - availableModes, - allowBypassPermissions, - ); - setSessionMode(taskId, nextMode); + const nextMode = cycleModeOption(modeOption, allowBypassPermissions); + if (nextMode) { + setSessionConfigOptionByCategory(taskId, "mode", nextMode); + } }, { enableOnFormTags: true, enableOnContentEditable: true, - enabled: !isCloud && isRunning, + enabled: !isCloud && isRunning && !!modeOption, }, [ taskId, currentModeId, - availableModes, isCloud, isRunning, - setSessionMode, + modeOption, + setSessionConfigOptionByCategory, allowBypassPermissions, ], ); @@ -236,7 +239,7 @@ export function SessionView({ (o) => o.optionId === optionId, ); if (selectedOption?.kind === "allow_always" && !isCloud) { - setSessionMode(taskId, "acceptEdits"); + setSessionConfigOptionByCategory(taskId, "mode", "acceptEdits"); } if (customInput) { @@ -276,7 +279,7 @@ export function SessionView({ respondToPermission, onSendPrompt, isCloud, - setSessionMode, + setSessionConfigOptionByCategory, requestFocus, sessionId, ], @@ -451,9 +454,10 @@ export function SessionView({ onBashCommand={onBashCommand} onBashModeChange={setIsBashMode} onCancel={onCancelPrompt} - currentMode={currentMode} - currentModeId={currentModeId} - onModeChange={!isCloud ? handleModeChange : undefined} + modeOption={modeOption} + onModeChange={ + !isCloud && modeOption ? handleModeChange : undefined + } adapter={adapter} /> diff --git a/apps/twig/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/apps/twig/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx index fa280efcd..2cfadff44 100644 --- a/apps/twig/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx @@ -79,7 +79,7 @@ export const SessionUpdateView = memo(function SessionUpdateView({ return null; case "available_commands_update": return null; - case "current_mode_update": + case "config_option_update": return null; case "console": return ( diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts deleted file mode 100644 index 067b63354..000000000 --- a/apps/twig/src/renderer/features/sessions/stores/sessionModeStore.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -interface SessionModeState { - taskModes: Record; -} - -interface SessionModeActions { - setTaskMode: (taskId: string, modeId: string) => void; - getTaskMode: (taskId: string) => string | undefined; -} - -type SessionModeStore = SessionModeState & SessionModeActions; - -export const useSessionModeStore = create()( - persist( - (set, get) => ({ - taskModes: {}, - - setTaskMode: (taskId, modeId) => { - set((state) => ({ - taskModes: { ...state.taskModes, [taskId]: modeId }, - })); - }, - - getTaskMode: (taskId) => { - return get().taskModes[taskId]; - }, - }), - { - name: "session-mode-storage", - }, - ), -); - -export function getPersistedTaskMode(taskId: string): string | undefined { - return useSessionModeStore.getState().getTaskMode(taskId); -} - -export function setPersistedTaskMode(taskId: string, modeId: string): void { - useSessionModeStore.getState().setTaskMode(taskId, modeId); -} diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts index 2767368a1..a9592c8ea 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts @@ -1,8 +1,9 @@ import type { AvailableCommand, ContentBlock, - SessionMode, - SessionModeId, + SessionConfigOption, + SessionConfigSelectGroup, + SessionConfigSelectOption, SessionNotification, } from "@agentclientprotocol/sdk"; import { useAuthStore } from "@features/auth/stores/authStore"; @@ -12,7 +13,7 @@ import { notifyPermissionRequest, notifyPromptComplete, } from "@renderer/lib/notifications"; -import { EXECUTION_MODES, type ExecutionMode, type Task } from "@shared/types"; +import type { ExecutionMode, Task } from "@shared/types"; import type { AcpMessage, JsonRpcMessage, @@ -34,7 +35,6 @@ import { type PermissionRequest, } from "../utils/parseSessionLogs"; import { useModelsStore } from "./modelsStore"; -import { getPersistedTaskMode, setPersistedTaskMode } from "./sessionModeStore"; const log = logger.scope("session-store"); const CLOUD_POLLING_INTERVAL_MS = 500; @@ -43,49 +43,50 @@ const CLOUD_POLLING_INTERVAL_MS = 500; // Re-export for external consumers export type { ExecutionMode, PermissionRequest }; -export type { SessionMode }; +export type { SessionConfigOption }; -export function getExecutionModes( - allowBypassPermissions: boolean, -): ExecutionMode[] { - return allowBypassPermissions - ? EXECUTION_MODES - : EXECUTION_MODES.filter((m) => m !== "bypassPermissions"); -} - -export function cycleExecutionMode( - currentModeId: string | undefined, - availableModes: SessionMode[] | undefined, - allowBypassPermissions: boolean, -): string { - if (!availableModes || availableModes.length === 0) { - const fallbackModes = getExecutionModes(allowBypassPermissions); - const currentIndex = currentModeId - ? fallbackModes.indexOf(currentModeId as ExecutionMode) - : -1; - if (currentIndex === -1) return fallbackModes[0]; - return fallbackModes[(currentIndex + 1) % fallbackModes.length]; - } - - const filteredModes = allowBypassPermissions - ? availableModes - : availableModes.filter( - (m) => m.id !== "bypassPermissions" && m.id !== "full-access", - ); +type SelectOption = SessionConfigSelectOption; +type SelectGroup = SessionConfigSelectGroup; - if (filteredModes.length === 0) return availableModes[0].id; +function isSelectGroup( + options: SessionConfigOption["options"], +): options is SelectGroup[] { + return Array.isArray(options) && options.length > 0 && "group" in options[0]; +} - const currentIndex = filteredModes.findIndex((m) => m.id === currentModeId); - if (currentIndex === -1) return filteredModes[0].id; +export function flattenSelectOptions( + options: SessionConfigOption["options"], +): SelectOption[] { + if (!isSelectGroup(options)) return options; + return options.flatMap((group) => group.options); +} - return filteredModes[(currentIndex + 1) % filteredModes.length].id; +export function getConfigOptionByCategory( + configOptions: SessionConfigOption[] | undefined, + category: string, +): SessionConfigOption | undefined { + return configOptions?.find((opt) => opt.category === category); } -export interface AgentModelOption { - modelId: string; - name: string; - description?: string | null; - provider?: string; +export function cycleModeOption( + modeOption: SessionConfigOption | undefined, + allowBypassPermissions: boolean, +): string | undefined { + if (!modeOption) return undefined; + const allOptions = flattenSelectOptions(modeOption.options); + const filtered = allowBypassPermissions + ? allOptions + : allOptions.filter( + (opt) => + opt.value !== "bypassPermissions" && opt.value !== "full-access", + ); + if (filtered.length === 0) return allOptions[0]?.value; + + const currentIndex = filtered.findIndex( + (opt) => opt.value === modeOption.currentValue, + ); + if (currentIndex === -1) return filtered[0]?.value; + return filtered[(currentIndex + 1) % filtered.length]?.value; } export interface QueuedMessage { @@ -94,8 +95,6 @@ export interface QueuedMessage { queuedAt: number; } -export type CodexReasoningLevel = "low" | "medium" | "high" | "xhigh"; - export interface AgentSession { taskRunId: string; taskId: string; @@ -110,13 +109,9 @@ export interface AgentSession { isCloud: boolean; logUrl?: string; processedLineCount?: number; - model?: string; - codexReasoningLevel?: CodexReasoningLevel; - availableModels?: AgentModelOption[]; framework?: "claude"; adapter?: "claude" | "codex"; - availableModes?: SessionMode[]; - currentModeId?: SessionModeId; + configOptions?: SessionConfigOption[]; pendingPermissions: Map; messageQueue: QueuedMessage[]; } @@ -139,11 +134,15 @@ interface SessionActions { prompt: string | ContentBlock[], ) => Promise<{ stopReason: string }>; cancelPrompt: (taskId: string) => Promise; - setSessionModel: (taskId: string, modelId: string) => Promise; - setSessionMode: (taskId: string, modeId: string) => Promise; - setCodexReasoningLevel: ( + setSessionConfigOption: ( + taskId: string, + configId: string, + value: string, + ) => Promise; + setSessionConfigOptionByCategory: ( taskId: string, - level: CodexReasoningLevel, + category: string, + value: string, ) => Promise; appendUserShellExecute: ( taskId: string, @@ -251,7 +250,7 @@ function subscribeToChannel(taskRunId: string) { const params = msg.params as { update?: { sessionUpdate?: string; - currentModeId?: string; + configOptions?: SessionConfigOption[]; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; @@ -260,15 +259,11 @@ function subscribeToChannel(taskRunId: string) { }; }; - // Handle mode updates from agent if ( - params?.update?.sessionUpdate === "current_mode_update" && - params.update.currentModeId + params?.update?.sessionUpdate === "config_option_update" && + params.update.configOptions ) { - const newModeId = params.update.currentModeId; - session.currentModeId = newModeId; - setPersistedTaskMode(session.taskId, newModeId); - log.info("Session mode updated", { taskRunId, newModeId }); + session.configOptions = params.update.configOptions; } } @@ -512,7 +507,6 @@ function createBaseSession( taskId: string, taskTitle: string, isCloud: boolean, - executionModeId?: string, ): AgentSession { return { taskRunId, @@ -525,7 +519,6 @@ function createBaseSession( isPromptPending: false, promptStartedAt: null, isCloud, - currentModeId: executionModeId, pendingPermissions: new Map(), messageQueue: [], }; @@ -683,18 +676,14 @@ const useStore = create()( repoPath: string, auth: AuthCredentials, ) => { - const { rawEntries, sessionId, adapter, model } = + const { rawEntries, sessionId, adapter, configOptions } = await fetchSessionLogs(logUrl); const events = convertStoredEntriesToEvents(rawEntries); - const persistedMode = getPersistedTaskMode(taskId); const session = createBaseSession(taskRunId, taskId, taskTitle, false); session.events = events; session.logUrl = logUrl; - session.model = model; - if (persistedMode) { - session.currentModeId = persistedMode; - } + session.configOptions = configOptions; if (adapter) { session.adapter = adapter; } @@ -713,30 +702,28 @@ const useStore = create()( logUrl, sessionId, adapter, - model, }); if (result) { - const sessionModel = result.currentModelId; updateSession(taskRunId, { status: "connected", - model: sessionModel, - availableModels: result.availableModels, - availableModes: result.availableModes, - currentModeId: result.currentModeId, + configOptions: result.configOptions, }); - if (persistedMode) { - try { - await trpcVanilla.agent.setMode.mutate({ - sessionId: taskRunId, - modeId: persistedMode, - }); - } catch (error) { - log.warn("Failed to restore persisted mode after reconnect", { - taskId, - error, - }); + if (configOptions && result.configOptions) { + const known = new Map( + result.configOptions.map((opt) => [opt.id, opt]), + ); + for (const opt of configOptions) { + if (!opt.currentValue) continue; + const live = known.get(opt.id); + if (live && live.currentValue !== opt.currentValue) { + await get().actions.setSessionConfigOption( + taskId, + opt.id, + opt.currentValue, + ); + } } } } else { @@ -782,10 +769,6 @@ const useStore = create()( throw new Error("Failed to create task run. Please try again."); } - const persistedMode = getPersistedTaskMode(taskId); - const effectiveMode = executionMode ?? persistedMode; - const selectedModel = useModelsStore.getState().getEffectiveModel(); - const result = await trpcVanilla.agent.start.mutate({ taskId, taskRunId: taskRun.id, @@ -793,24 +776,13 @@ const useStore = create()( apiKey: auth.apiKey, apiHost: auth.apiHost, projectId: auth.projectId, - model: selectedModel, - executionMode: effectiveMode, adapter, }); - const session = createBaseSession( - taskRun.id, - taskId, - taskTitle, - false, - effectiveMode, - ); + const session = createBaseSession(taskRun.id, taskId, taskTitle, false); session.channel = result.channel; session.status = "connected"; - session.model = result.currentModelId ?? selectedModel; - session.availableModels = result.availableModels; - session.availableModes = result.availableModes; - session.currentModeId = result.currentModeId ?? effectiveMode; + session.configOptions = result.configOptions; if (adapter) { session.adapter = adapter; } @@ -821,9 +793,25 @@ const useStore = create()( track(ANALYTICS_EVENTS.TASK_RUN_STARTED, { task_id: taskId, execution_type: "local", - model: selectedModel, }); + if (executionMode) { + await get().actions.setSessionConfigOptionByCategory( + taskId, + "mode", + executionMode, + ); + } + + const preferredModel = useModelsStore.getState().getEffectiveModel(); + if (preferredModel) { + await get().actions.setSessionConfigOptionByCategory( + taskId, + "model", + preferredModel, + ); + } + if (initialPrompt?.length) { await get().actions.sendPrompt(taskId, initialPrompt); } @@ -1259,64 +1247,58 @@ const useStore = create()( } }, - setSessionModel: async (taskId, modelId) => { + setSessionConfigOption: async (taskId, configId, value) => { const session = getSessionByTaskId(taskId); if (!session || session.isCloud) return; try { - await trpcVanilla.agent.setModel.mutate({ + await trpcVanilla.agent.setConfigOption.mutate({ sessionId: session.taskRunId, - modelId, + configId, + value, }); - updateSession(session.taskRunId, { model: modelId }); + + if (session.configOptions) { + const updated = session.configOptions.map((opt) => + opt.id === configId ? { ...opt, currentValue: value } : opt, + ); + updateSession(session.taskRunId, { configOptions: updated }); + } } catch (error) { - log.error("Failed to change session model", { + log.error("Failed to change session config option", { taskId, - modelId, + configId, + value, error, }); } }, - setSessionMode: async (taskId, modeId) => { + setSessionConfigOptionByCategory: async (taskId, category, value) => { const session = getSessionByTaskId(taskId); if (!session || session.isCloud) return; - try { - await trpcVanilla.agent.setMode.mutate({ - sessionId: session.taskRunId, - modeId, - }); - updateSession(session.taskRunId, { currentModeId: modeId }); - setPersistedTaskMode(taskId, modeId); - } catch (error) { - log.error("Failed to change session mode", { - taskId, - modeId, - error, - }); - } - }, - - setCodexReasoningLevel: async (taskId, level) => { - const session = getSessionByTaskId(taskId); - if (!session || session.isCloud || session.adapter !== "codex") + const option = getConfigOptionByCategory( + session.configOptions, + category, + ); + if (!option) { + log.warn("Config option category not found", { taskId, category }); return; + } - try { - await trpcVanilla.agent.setConfigOption.mutate({ - sessionId: session.taskRunId, - configId: "reasoning_effort", - value: level, - }); - updateSession(session.taskRunId, { codexReasoningLevel: level }); - } catch (error) { - log.error("Failed to change codex reasoning level", { + const selectable = flattenSelectOptions(option.options); + const exists = selectable.some((opt) => opt.value === value); + if (!exists) { + log.warn("Config option value not available", { taskId, - level, - error, + category, + value, }); + return; } + + await get().actions.setSessionConfigOption(taskId, option.id, value); }, appendUserShellExecute: async (taskId, command, cwd, result) => { @@ -1634,61 +1616,35 @@ export function getPendingPermissionsForTask( /** * Hook to get the current mode ID for a task. */ -export const useCurrentModeForTask = ( +export const useConfigOptionForTask = ( taskId: string | undefined, -): string | undefined => { - const taskRunId = useStore((s) => { - if (!taskId) return undefined; - for (const session of Object.values(s.sessions)) { - if (session.taskId === taskId) { - return session.taskRunId; - } - } - return undefined; - }); - - return useStore((s) => { - if (!taskRunId) return undefined; - return s.sessions[taskRunId]?.currentModeId; - }); -}; - -/** - * Hook to get the full current mode object for a task (with name, description). - */ -export const useCurrentModeObjectForTask = ( - taskId: string | undefined, -): SessionMode | undefined => { + category: string, +): SessionConfigOption | undefined => { return useStore((s) => { if (!taskId) return undefined; const session = Object.values(s.sessions).find( (sess) => sess.taskId === taskId, ); - if (!session?.currentModeId || !session.availableModes) return undefined; - return session.availableModes.find((m) => m.id === session.currentModeId); + return getConfigOptionByCategory(session?.configOptions, category); }); }; -/** - * Hook to get available modes for a task. - */ -export const useAvailableModesForTask = ( +export const useModeConfigOptionForTask = ( taskId: string | undefined, -): SessionMode[] | undefined => { - const taskRunId = useStore((s) => { - if (!taskId) return undefined; - for (const session of Object.values(s.sessions)) { - if (session.taskId === taskId) { - return session.taskRunId; - } - } - return undefined; - }); +): SessionConfigOption | undefined => { + return useConfigOptionForTask(taskId, "mode"); +}; - return useStore((s) => { - if (!taskRunId) return undefined; - return s.sessions[taskRunId]?.availableModes; - }); +export const useModelConfigOptionForTask = ( + taskId: string | undefined, +): SessionConfigOption | undefined => { + return useConfigOptionForTask(taskId, "model"); +}; + +export const useThoughtLevelConfigOptionForTask = ( + taskId: string | undefined, +): SessionConfigOption | undefined => { + return useConfigOptionForTask(taskId, "thought_level"); }; /** @@ -1720,15 +1676,3 @@ export const useAdapterForTask = ( return session?.adapter; }); }; - -export const useCodexReasoningLevelForTask = ( - taskId: string | undefined, -): CodexReasoningLevel | undefined => { - return useStore((s) => { - if (!taskId) return undefined; - const session = Object.values(s.sessions).find( - (sess) => sess.taskId === taskId, - ); - return session?.codexReasoningLevel; - }); -}; diff --git a/apps/twig/src/renderer/features/sessions/types.ts b/apps/twig/src/renderer/features/sessions/types.ts index 01ec9428f..04b2ace75 100644 --- a/apps/twig/src/renderer/features/sessions/types.ts +++ b/apps/twig/src/renderer/features/sessions/types.ts @@ -25,7 +25,7 @@ export interface ToolCall { export type SessionUpdate = SessionNotification["update"]; export type Plan = Extract; -export type CurrentModeUpdate = Extract< +export type ConfigOptionUpdate = Extract< SessionUpdate, - { sessionUpdate: "current_mode_update" } + { sessionUpdate: "config_option_update" } >; diff --git a/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts b/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts index 5e91ffe8a..be788e725 100644 --- a/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts +++ b/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts @@ -3,6 +3,7 @@ import { CLIENT_METHODS, type RequestPermissionRequest, + type SessionConfigOption, type SessionNotification, } from "@agentclientprotocol/sdk"; import type { StoredLogEntry as BaseStoredLogEntry } from "@shared/types/session-events"; @@ -17,7 +18,7 @@ export interface ParsedSessionLogs { rawEntries: StoredLogEntry[]; sessionId?: string; adapter?: "claude" | "codex"; - model?: string; + configOptions?: SessionConfigOption[]; } /** @@ -41,7 +42,7 @@ export async function fetchSessionLogs( const rawEntries: StoredLogEntry[] = []; let sessionId: string | undefined; let adapter: "claude" | "codex" | undefined; - let model: string | undefined; + let configOptions: SessionConfigOption[] | undefined; for (const line of content.trim().split("\n")) { try { @@ -74,16 +75,11 @@ export async function fetchSessionLogs( const params = stored.notification.params as { update?: { sessionUpdate?: string; - configOptions?: Array<{ id: string; currentValue?: string }>; + configOptions?: SessionConfigOption[]; }; }; if (params.update?.sessionUpdate === "config_option_update") { - const modelOption = params.update.configOptions?.find( - (opt) => opt.id === "model", - ); - if (modelOption?.currentValue) { - model = modelOption.currentValue; - } + configOptions = params.update.configOptions; } } @@ -113,7 +109,7 @@ export async function fetchSessionLogs( } } - return { notifications, rawEntries, sessionId, adapter, model }; + return { notifications, rawEntries, sessionId, adapter, configOptions }; } catch { return { notifications: [], rawEntries: [] }; } diff --git a/apps/twig/vite.main.config.mts b/apps/twig/vite.main.config.mts index d5a8938d7..24ab7dc13 100644 --- a/apps/twig/vite.main.config.mts +++ b/apps/twig/vite.main.config.mts @@ -147,7 +147,8 @@ function copyCodexAcpBinary(): Plugin { mkdirSync(destDir, { recursive: true }); } - const binaryName = process.platform === "win32" ? "codex-acp.exe" : "codex-acp"; + const binaryName = + process.platform === "win32" ? "codex-acp.exe" : "codex-acp"; const sourceDir = join(__dirname, "resources/codex-acp"); const sourcePath = join(sourceDir, binaryName); @@ -159,7 +160,9 @@ function copyCodexAcpBinary(): Plugin { if (process.platform === "darwin") { try { execSync(`xattr -cr "${destPath}"`, { stdio: "inherit" }); - execSync(`codesign --force --sign - "${destPath}"`, { stdio: "inherit" }); + execSync(`codesign --force --sign - "${destPath}"`, { + stdio: "inherit", + }); console.log("Ad-hoc signed codex-acp binary"); } catch (err) { console.warn("Failed to sign codex-acp binary:", err); diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index 82346300c..21ca1656f 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -25,6 +25,7 @@ export type AcpConnectionConfig = { logger?: Logger; processCallbacks?: ClaudeAcpAgentOptions; codexOptions?: CodexProcessOptions; + allowedModelIds?: Set; }; export type AcpConnection = { @@ -35,6 +36,102 @@ export type AcpConnection = { export type InProcessAcpConnection = AcpConnection; +type ConfigOption = { + id?: string; + category?: string | null; + currentValue?: string; + options?: Array< + { value?: string } | { group?: string; options?: Array<{ value?: string }> } + >; +}; + +function isGroupedOptions( + options: NonNullable, +): options is Array<{ group?: string; options?: Array<{ value?: string }> }> { + return options.length > 0 && "group" in options[0]; +} + +function filterModelConfigOptions( + msg: Record, + allowedModelIds: Set, +): Record | null { + const payload = msg as { + method?: string; + result?: { configOptions?: ConfigOption[] }; + params?: { + update?: { sessionUpdate?: string; configOptions?: ConfigOption[] }; + }; + }; + + const configOptions = + payload.result?.configOptions ?? payload.params?.update?.configOptions; + if (!configOptions) return null; + + const filtered = configOptions.map((opt) => { + if (opt.category !== "model" || !opt.options) return opt; + + const options = opt.options; + if (isGroupedOptions(options)) { + const filteredOptions = options.map((group) => ({ + ...group, + options: (group.options ?? []).filter( + (o) => o?.value && allowedModelIds.has(o.value), + ), + })); + const flat = filteredOptions.flatMap((g) => g.options ?? []); + const currentAllowed = + opt.currentValue && allowedModelIds.has(opt.currentValue); + const nextCurrent = + currentAllowed || flat.length === 0 ? opt.currentValue : flat[0]?.value; + + return { + ...opt, + currentValue: nextCurrent, + options: filteredOptions, + }; + } + + const valueOptions = options as Array<{ value?: string }>; + const filteredOptions = valueOptions.filter( + (o) => o?.value && allowedModelIds.has(o.value), + ); + const currentAllowed = + opt.currentValue && allowedModelIds.has(opt.currentValue); + const nextCurrent = + currentAllowed || filteredOptions.length === 0 + ? opt.currentValue + : filteredOptions[0]?.value; + + return { + ...opt, + currentValue: nextCurrent, + options: filteredOptions, + }; + }); + + if (payload.result?.configOptions) { + return { ...msg, result: { ...payload.result, configOptions: filtered } }; + } + if (payload.params?.update?.configOptions) { + return { + ...msg, + params: { + ...payload.params, + update: { ...payload.params.update, configOptions: filtered }, + }, + }; + } + return null; +} + +function extractReasoningEffort( + configOptions: ConfigOption[] | undefined, +): string | undefined { + if (!configOptions) return undefined; + const option = configOptions.find((opt) => opt.id === "reasoning_effort"); + return option?.currentValue ?? undefined; +} + /** * Creates an ACP connection with the specified agent framework. * @@ -134,6 +231,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { new Logger({ debug: true, prefix: "[CodexConnection]" }); const { logWriter } = config; + const allowedModelIds = config.allowedModelIds; const codexProcess = spawnCodexProcess({ ...config.codexOptions, @@ -147,6 +245,8 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { let loadRequestId: string | number | null = null; let newSessionRequestId: string | number | null = null; let sdkSessionEmitted = false; + const reasoningEffortBySessionId = new Map(); + let injectedConfigId = 0; const decoder = new TextDecoder(); const encoder = new TextEncoder(); @@ -174,6 +274,16 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { try { const msg = JSON.parse(trimmed); + const sessionId = + msg?.params?.sessionId ?? msg?.result?.sessionId ?? null; + const configOptions = + msg?.result?.configOptions ?? msg?.params?.update?.configOptions; + if (sessionId && configOptions) { + const effort = extractReasoningEffort(configOptions); + if (effort) { + reasoningEffortBySessionId.set(sessionId, effort); + } + } if ( !sdkSessionEmitted && @@ -208,6 +318,14 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { shouldFilter = true; } } + + if (!shouldFilter && allowedModelIds && allowedModelIds.size > 0) { + const updated = filterModelConfigOptions(msg, allowedModelIds); + if (updated) { + outputLines.push(JSON.stringify(updated)); + continue; + } + } } catch { // Not valid JSON, pass through } @@ -246,6 +364,43 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { try { const msg = JSON.parse(trimmed); + if ( + msg.method === "session/set_config_option" && + msg.params?.configId === "reasoning_effort" && + msg.params?.sessionId && + msg.params?.value + ) { + reasoningEffortBySessionId.set( + msg.params.sessionId, + msg.params.value, + ); + } + if (msg.method === "session/prompt" && msg.params?.sessionId) { + const effort = reasoningEffortBySessionId.get(msg.params.sessionId); + if (effort) { + const injection = { + jsonrpc: "2.0", + id: `reasoning_effort_${Date.now()}_${injectedConfigId++}`, + method: "session/set_config_option", + params: { + sessionId: msg.params.sessionId, + configId: "reasoning_effort", + value: effort, + }, + }; + const injectionLine = `${JSON.stringify(injection)}\n`; + const writer = originalWritable.getWriter(); + return writer + .write(encoder.encode(injectionLine)) + .then(() => writer.releaseLock()) + .then(() => { + const nextWriter = originalWritable.getWriter(); + return nextWriter + .write(chunk) + .finally(() => nextWriter.releaseLock()); + }); + } + } if (msg.method === "session/new" && msg.id) { logger.debug("session/new detected, tracking request ID"); newSessionRequestId = msg.id; diff --git a/packages/agent/src/adapters/base-acp-agent.ts b/packages/agent/src/adapters/base-acp-agent.ts index 1942d3c6e..6a39f9561 100644 --- a/packages/agent/src/adapters/base-acp-agent.ts +++ b/packages/agent/src/adapters/base-acp-agent.ts @@ -11,7 +11,7 @@ import type { PromptResponse, ReadTextFileRequest, ReadTextFileResponse, - SessionModelState, + SessionConfigSelectOption, SessionNotification, WriteTextFileRequest, WriteTextFileResponse, @@ -20,6 +20,7 @@ import { DEFAULT_GATEWAY_MODEL, fetchGatewayModels, formatGatewayModelName, + isAnthropicModel, } from "@/gateway-models.js"; import { Logger } from "@/utils/logger.js"; @@ -111,30 +112,39 @@ export abstract class BaseAcpAgent implements Agent { throw new Error("Method not implemented."); } - async sendModeUpdate(sessionId: string, modeId: string): Promise { - await this.client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "current_mode_update", - currentModeId: modeId, - }, - }); - } - - async getAvailableModels( - currentModelOverride?: string, - ): Promise { + async getModelConfigOptions(currentModelOverride?: string): Promise<{ + currentModelId: string; + options: SessionConfigSelectOption[]; + }> { const gatewayModels = await fetchGatewayModels(); - const availableModels = gatewayModels.map((model) => ({ - modelId: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - })); + const options = gatewayModels + .filter((model) => isAnthropicModel(model)) + .map((model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + })); + + const isAnthropicModelId = (modelId: string): boolean => + modelId.startsWith("claude-") || modelId.startsWith("anthropic/"); + + let currentModelId = currentModelOverride ?? DEFAULT_GATEWAY_MODEL; + + if (!options.some((opt) => opt.value === currentModelId)) { + if (!isAnthropicModelId(currentModelId)) { + currentModelId = DEFAULT_GATEWAY_MODEL; + } + } + + if (!options.some((opt) => opt.value === currentModelId)) { + options.unshift({ + value: currentModelId, + name: currentModelId, + description: "Custom model", + }); + } - return { - availableModels, - currentModelId: currentModelOverride ?? DEFAULT_GATEWAY_MODEL, - }; + return { currentModelId, options }; } } diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 14ba8fab9..4b66aa522 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -15,9 +15,11 @@ import { type PromptRequest, type PromptResponse, RequestError, - type SetSessionModelRequest, - type SetSessionModeRequest, - type SetSessionModeResponse, + type SessionConfigOption, + type SessionConfigOptionCategory, + type SessionConfigSelectOption, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk"; import { type CanUseTool, @@ -47,7 +49,7 @@ import { import { canUseTool } from "./permissions/permission-handlers.js"; import { getAvailableSlashCommands } from "./session/commands.js"; import { parseMcpServers } from "./session/mcp-config.js"; -import { DEFAULT_MODEL, toSdkModelId } from "./session/models.js"; +import { toSdkModelId } from "./session/models.js"; import { buildSessionOptions, buildSystemPrompt, @@ -78,6 +80,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { clientCapabilities?: ClientCapabilities; private logWriter?: SessionLogWriter; private processCallbacks?: ClaudeAcpAgentOptions; + private lastSentConfigOptions?: SessionConfigOption[]; constructor( client: AgentSideConnection, @@ -136,8 +139,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const meta = params._meta as NewSessionMeta | undefined; const internalSessionId = uuidv7(); - const permissionMode = - (meta?.initialModeId as TwigExecutionMode) ?? "default"; + const permissionMode: TwigExecutionMode = "default"; const mcpServers = parseMcpServers(params); @@ -170,10 +172,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { internalSessionId, meta as Record, ); - - if (meta?.model) { - await this.trySetModel(q, meta.model); - } + const modelOptions = await this.getModelConfigOptions(); + session.modelId = modelOptions.currentModelId; + await this.trySetModel(q, modelOptions.currentModelId); this.sendAvailableCommandsUpdate( internalSessionId, @@ -182,11 +183,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { return { sessionId: internalSessionId, - models: await this.getAvailableModels(meta?.model ?? DEFAULT_MODEL), - modes: { - currentModeId: permissionMode, - availableModes: getAvailableModes(), - }, + configOptions: await this.buildConfigOptions(modelOptions), }; } @@ -231,11 +228,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ); return { - models: await this.getAvailableModels(), - modes: { - currentModeId: session.permissionMode, - availableModes: getAvailableModes(), - }, + configOptions: await this.buildConfigOptions(), }; } @@ -249,23 +242,28 @@ export class ClaudeAcpAgent extends BaseAcpAgent { return this.processMessages(params.sessionId); } - async unstable_setSessionModel(params: SetSessionModelRequest) { - const sdkModelId = toSdkModelId(params.modelId); - await this.session.query.setModel(sdkModelId); - } - - async setSessionMode( - params: SetSessionModeRequest, - ): Promise { - const modeId = params.modeId as TwigExecutionMode; + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const configId = params.configId; + const value = params.value; - if (!TWIG_EXECUTION_MODES.includes(modeId)) { - throw new Error("Invalid Mode"); + if (configId === "mode") { + const modeId = value as TwigExecutionMode; + if (!TWIG_EXECUTION_MODES.includes(modeId)) { + throw new Error("Invalid Mode"); + } + this.session.permissionMode = modeId; + await this.session.query.setPermissionMode(modeId); + } else if (configId === "model") { + await this.setModelWithFallback(this.session.query, value); + this.session.modelId = value; + } else { + throw new Error("Unsupported config option"); } - this.session.permissionMode = modeId; - await this.session.query.setPermissionMode(modeId); - return {}; + await this.emitConfigOptionsUpdate(); + return { configOptions: await this.buildConfigOptions() }; } protected async interruptSession(): Promise { @@ -282,8 +280,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ); return { _meta: { - models: result.models, - modes: result.modes, + configOptions: result.configOptions, }, }; } @@ -371,6 +368,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId, fileContentCache: this.fileContentCache, logger: this.logger, + emitConfigOptionsUpdate: () => this.emitConfigOptionsUpdate(sessionId), }); } @@ -379,10 +377,70 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (this.session) { this.session.permissionMode = newMode; } - await this.sendModeUpdate(sessionId, newMode); + await this.emitConfigOptionsUpdate(sessionId); }; } + private async buildConfigOptions(modelOptionsOverride?: { + currentModelId: string; + options: SessionConfigSelectOption[]; + }): Promise { + const options: SessionConfigOption[] = []; + + const modeOptions = getAvailableModes().map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description ?? undefined, + })); + + options.push({ + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: this.session.permissionMode, + options: modeOptions, + category: "mode" as SessionConfigOptionCategory, + description: "Choose an approval and sandboxing preset for your session", + }); + + const modelOptions = + modelOptionsOverride ?? + (await this.getModelConfigOptions(this.session.modelId)); + this.session.modelId = modelOptions.currentModelId; + + options.push({ + id: "model", + name: "Model", + type: "select", + currentValue: modelOptions.currentModelId, + options: modelOptions.options, + category: "model" as SessionConfigOptionCategory, + description: "Choose which model Claude should use", + }); + + return options; + } + + private async emitConfigOptionsUpdate(sessionId?: string): Promise { + const configOptions = await this.buildConfigOptions(); + const serialized = JSON.stringify(configOptions); + if ( + this.lastSentConfigOptions && + JSON.stringify(this.lastSentConfigOptions) === serialized + ) { + return; + } + + this.lastSentConfigOptions = configOptions; + await this.client.sessionUpdate({ + sessionId: sessionId ?? this.sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions, + }, + }); + } + private checkAuthStatus() { const backupExists = fs.existsSync( path.resolve(os.homedir(), ".claude.json.backup"), @@ -397,12 +455,25 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private async trySetModel(q: Query, modelId: string) { try { - await q.setModel(toSdkModelId(modelId)); + await this.setModelWithFallback(q, modelId); } catch (err) { this.logger.warn("Failed to set model", { modelId, error: err }); } } + private async setModelWithFallback(q: Query, modelId: string): Promise { + try { + await q.setModel(modelId); + return; + } catch (err) { + const fallback = toSdkModelId(modelId); + if (fallback === modelId) { + throw err; + } + await q.setModel(fallback); + } + } + private registerPersistence( sessionId: string, meta: Record | undefined, diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index be8f70c99..dda3af2f5 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -47,6 +47,7 @@ interface ToolHandlerContext { sessionId: string; fileContentCache: { [key: string]: string }; logger: Logger; + emitConfigOptionsUpdate: () => Promise; } async function emitToolDenial( @@ -158,7 +159,7 @@ async function applyPlanApproval( context: ToolHandlerContext, updatedInput: Record, ): Promise { - const { session, client, sessionId } = context; + const { session } = context; if ( response.outcome?.outcome === "selected" && @@ -167,13 +168,7 @@ async function applyPlanApproval( ) { session.permissionMode = response.outcome.optionId; await session.query.setPermissionMode(response.outcome.optionId); - await client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "current_mode_update", - currentModeId: response.outcome.optionId, - }, - }); + await context.emitConfigOptionsUpdate(); return { behavior: "allow", @@ -197,17 +192,11 @@ async function applyPlanApproval( async function handleEnterPlanModeTool( context: ToolHandlerContext, ): Promise { - const { session, client, sessionId, toolInput, logger } = context; + const { session, toolInput, logger } = context; session.permissionMode = "plan"; await session.query.setPermissionMode("plan"); - await client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "current_mode_update", - currentModeId: "plan", - }, - }); + await context.emitConfigOptionsUpdate(); return { behavior: "allow", diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 1d7c61642..b30c60cca 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -26,6 +26,7 @@ export type Session = BaseSession & { query: Query; input: Pushable; permissionMode: TwigExecutionMode; + modelId?: string; cwd: string; taskRunId?: string; sessionId?: string; @@ -51,11 +52,9 @@ export type ToolUpdateMeta = { export type NewSessionMeta = { taskRunId?: string; - initialModeId?: string; disableBuiltInTools?: boolean; systemPrompt?: unknown; sessionId?: string; - model?: string; claudeCode?: { options?: Options; }; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index d915b3781..44d173c6b 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -2,7 +2,11 @@ import { createAcpConnection, type InProcessAcpConnection, } from "./adapters/acp-connection.js"; -import { BLOCKED_MODELS, DEFAULT_GATEWAY_MODEL } from "./gateway-models.js"; +import { + BLOCKED_MODELS, + DEFAULT_GATEWAY_MODEL, + fetchArrayModels, +} from "./gateway-models.js"; import { PostHogAPIClient } from "./posthog-api.js"; import { SessionLogWriter } from "./session-log-writer.js"; import type { AgentConfig, TaskExecutionOptions } from "./types.js"; @@ -66,10 +70,36 @@ export class Agent { this.taskRunId = taskRunId; - const sanitizedModel = + let allowedModelIds: Set | undefined; + let sanitizedModel = options.model && !BLOCKED_MODELS.has(options.model) ? options.model - : DEFAULT_GATEWAY_MODEL; + : undefined; + if (options.adapter === "codex" && gatewayConfig) { + const models = await fetchArrayModels({ + gatewayUrl: gatewayConfig.gatewayUrl, + }); + const codexModelIds = models + .filter((model) => { + if (BLOCKED_MODELS.has(model.id)) return false; + if (model.owned_by) { + return model.owned_by === "openai"; + } + return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); + }) + .map((model) => model.id); + + if (codexModelIds.length > 0) { + allowedModelIds = new Set(codexModelIds); + } + + if (!sanitizedModel || !allowedModelIds?.has(sanitizedModel)) { + sanitizedModel = codexModelIds[0]; + } + } + if (!sanitizedModel) { + sanitizedModel = DEFAULT_GATEWAY_MODEL; + } this.acpConnection = createAcpConnection({ adapter: options.adapter, @@ -78,6 +108,7 @@ export class Agent { taskId, logger: this.logger, processCallbacks: options.processCallbacks, + allowedModelIds, codexOptions: options.adapter === "codex" && gatewayConfig ? { diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index c5b365888..4e819fa86 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -19,6 +19,13 @@ export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-5"; export const BLOCKED_MODELS = new Set(["gpt-5-mini", "openai/gpt-5-mini"]); +type ArrayModelsResponse = + | { + data?: Array<{ id?: string; owned_by?: string }>; + models?: Array<{ id?: string; owned_by?: string }>; + } + | Array<{ id?: string; owned_by?: string }>; + export async function fetchGatewayModels( options?: FetchGatewayModelsOptions, ): Promise { @@ -44,6 +51,58 @@ export async function fetchGatewayModels( } } +export function isAnthropicModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "anthropic"; + } + return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); +} + +export async function fetchArrayModelIds( + options?: FetchGatewayModelsOptions, +): Promise { + const models = await fetchArrayModels(options); + return models.map((model) => model.id); +} + +export interface ArrayModelInfo { + id: string; + owned_by?: string; +} + +export async function fetchArrayModels( + options?: FetchGatewayModelsOptions, +): Promise { + const gatewayUrl = options?.gatewayUrl ?? process.env.ANTHROPIC_BASE_URL; + if (!gatewayUrl) { + return []; + } + + try { + const base = new URL(gatewayUrl); + base.pathname = "/array/v1/models"; + base.search = ""; + base.hash = ""; + const response = await fetch(base.toString()); + if (!response.ok) { + return []; + } + const data = (await response.json()) as ArrayModelsResponse; + const models = Array.isArray(data) + ? data + : (data.data ?? data.models ?? []); + const results: ArrayModelInfo[] = []; + for (const model of models) { + const id = model?.id ? String(model.id) : ""; + if (!id) continue; + results.push({ id, owned_by: model?.owned_by }); + } + return results; + } catch { + return []; + } +} + const PROVIDER_NAMES: Record = { anthropic: "Anthropic", openai: "OpenAI", diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 8a0fe0e7a..bff0c9386 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -26,13 +26,16 @@ export { createAcpConnection } from "./adapters/acp-connection.js"; export type { CodexProcessOptions } from "./adapters/codex/spawn.js"; export { Agent } from "./agent.js"; export { + type ArrayModelInfo, BLOCKED_MODELS, DEFAULT_GATEWAY_MODEL, type FetchGatewayModelsOptions, + fetchArrayModels, fetchGatewayModels, formatGatewayModelName, type GatewayModel, getProviderName, + isAnthropicModel, } from "./gateway-models.js"; export { PostHogAPIClient } from "./posthog-api.js"; export type { From c6eddd4e7eb788763dbdba8db085646c416bbab2 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 5 Feb 2026 16:13:52 +0100 Subject: [PATCH 5/6] more stuff --- apps/twig/package.json | 1 + .../editor/components/MarkdownRenderer.tsx | 8 +- .../components/session-update/ThoughtView.tsx | 20 ++-- .../components/session-update/UserMessage.tsx | 7 +- .../sessions/stores/sessionConfigStore.ts | 34 ++++++ .../features/sessions/stores/sessionStore.ts | 54 +++++++++- .../task-detail/components/AdapterSelect.tsx | 2 +- pnpm-lock.yaml | 101 ++++++++++++------ 8 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 apps/twig/src/renderer/features/sessions/stores/sessionConfigStore.ts diff --git a/apps/twig/package.json b/apps/twig/package.json index ed5d52d58..b67f6af56 100644 --- a/apps/twig/package.json +++ b/apps/twig/package.json @@ -160,6 +160,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "reflect-metadata": "^0.2.2", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tippy.js": "^6.3.7", diff --git a/apps/twig/src/renderer/features/editor/components/MarkdownRenderer.tsx b/apps/twig/src/renderer/features/editor/components/MarkdownRenderer.tsx index 81c7f1d46..a8eee63e2 100644 --- a/apps/twig/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/apps/twig/src/renderer/features/editor/components/MarkdownRenderer.tsx @@ -14,9 +14,11 @@ import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import type { PluggableList } from "unified"; interface MarkdownRendererProps { content: string; + remarkPluginsOverride?: PluggableList; } // Preprocessor to prevent setext heading interpretation of horizontal rules @@ -151,17 +153,19 @@ export const baseComponents: Components = { ), }; -export const remarkPlugins = [remarkGfm]; +export const defaultRemarkPlugins = [remarkGfm]; export const MarkdownRenderer = memo(function MarkdownRenderer({ content, + remarkPluginsOverride, }: MarkdownRendererProps) { const processedContent = useMemo( () => preprocessMarkdown(content), [content], ); + const plugins = remarkPluginsOverride ?? defaultRemarkPlugins; return ( - + {processedContent} ); diff --git a/apps/twig/src/renderer/features/sessions/components/session-update/ThoughtView.tsx b/apps/twig/src/renderer/features/sessions/components/session-update/ThoughtView.tsx index e9007652e..8f775d645 100644 --- a/apps/twig/src/renderer/features/sessions/components/session-update/ThoughtView.tsx +++ b/apps/twig/src/renderer/features/sessions/components/session-update/ThoughtView.tsx @@ -1,5 +1,8 @@ -import { Box, Text } from "@radix-ui/themes"; +import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { Box } from "@radix-ui/themes"; import { memo } from "react"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; interface ThoughtViewProps { content: string; @@ -9,16 +12,11 @@ export const ThoughtView = memo(function ThoughtView({ content, }: ThoughtViewProps) { return ( - - - - Thinking: - {" "} - {content} - + + ); }); diff --git a/apps/twig/src/renderer/features/sessions/components/session-update/UserMessage.tsx b/apps/twig/src/renderer/features/sessions/components/session-update/UserMessage.tsx index c49807ce4..3d7b231df 100644 --- a/apps/twig/src/renderer/features/sessions/components/session-update/UserMessage.tsx +++ b/apps/twig/src/renderer/features/sessions/components/session-update/UserMessage.tsx @@ -1,7 +1,7 @@ import { baseComponents, + defaultRemarkPlugins, MarkdownRenderer, - remarkPlugins, } from "@features/editor/components/MarkdownRenderer"; import { File } from "@phosphor-icons/react"; import { Box, Code, Text } from "@radix-ui/themes"; @@ -33,7 +33,10 @@ const InlineMarkdown = memo(function InlineMarkdown({ content: string; }) { return ( - + {content} ); diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionConfigStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionConfigStore.ts new file mode 100644 index 000000000..a5f9f6cf5 --- /dev/null +++ b/apps/twig/src/renderer/features/sessions/stores/sessionConfigStore.ts @@ -0,0 +1,34 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { electronStorage } from "@/renderer/lib/electronStorage"; + +interface SessionConfigState { + configsByRunId: Record; + setConfigOptions: (taskRunId: string, options: SessionConfigOption[]) => void; + getConfigOptions: (taskRunId: string) => SessionConfigOption[] | undefined; + removeConfigOptions: (taskRunId: string) => void; +} + +export const useSessionConfigStore = create()( + persist( + (set, get) => ({ + configsByRunId: {}, + setConfigOptions: (taskRunId, options) => + set((state) => ({ + configsByRunId: { ...state.configsByRunId, [taskRunId]: options }, + })), + getConfigOptions: (taskRunId) => get().configsByRunId[taskRunId], + removeConfigOptions: (taskRunId) => + set((state) => { + const { [taskRunId]: _removed, ...rest } = state.configsByRunId; + return { configsByRunId: rest }; + }), + }), + { + name: "session-config-storage", + storage: electronStorage, + partialize: (state) => ({ configsByRunId: state.configsByRunId }), + }, + ), +); diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts index a9592c8ea..d6defd9ff 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts @@ -35,6 +35,7 @@ import { type PermissionRequest, } from "../utils/parseSessionLogs"; import { useModelsStore } from "./modelsStore"; +import { useSessionConfigStore } from "./sessionConfigStore"; const log = logger.scope("session-store"); const CLOUD_POLLING_INTERVAL_MS = 500; @@ -61,6 +62,26 @@ export function flattenSelectOptions( return options.flatMap((group) => group.options); } +function mergeConfigOptions( + live: SessionConfigOption[], + stored: SessionConfigOption[] | undefined, +): SessionConfigOption[] { + if (!stored) return live; + const storedById = new Map(stored.map((opt) => [opt.id, opt])); + return live.map((opt) => { + if (!opt.id) return opt; + const storedOpt = storedById.get(opt.id); + if (!storedOpt?.currentValue) return opt; + if (!opt.options) { + return { ...opt, currentValue: storedOpt.currentValue }; + } + const available = flattenSelectOptions(opt.options); + const exists = available.some((o) => o.value === storedOpt.currentValue); + if (!exists) return opt; + return { ...opt, currentValue: storedOpt.currentValue }; + }); +} + export function getConfigOptionByCategory( configOptions: SessionConfigOption[] | undefined, category: string, @@ -264,6 +285,9 @@ function subscribeToChannel(taskRunId: string) { params.update.configOptions ) { session.configOptions = params.update.configOptions; + useSessionConfigStore + .getState() + .setConfigOptions(taskRunId, params.update.configOptions); } } @@ -683,7 +707,10 @@ const useStore = create()( const session = createBaseSession(taskRunId, taskId, taskTitle, false); session.events = events; session.logUrl = logUrl; - session.configOptions = configOptions; + const persistedConfigOptions = useSessionConfigStore + .getState() + .getConfigOptions(taskRunId); + session.configOptions = persistedConfigOptions ?? configOptions; if (adapter) { session.adapter = adapter; } @@ -705,16 +732,27 @@ const useStore = create()( }); if (result) { + const mergedConfigOptions = result.configOptions + ? mergeConfigOptions(result.configOptions, persistedConfigOptions) + : result.configOptions; updateSession(taskRunId, { status: "connected", - configOptions: result.configOptions, + configOptions: mergedConfigOptions, }); - if (configOptions && result.configOptions) { + if (mergedConfigOptions) { + useSessionConfigStore + .getState() + .setConfigOptions(taskRunId, mergedConfigOptions); + } + + const storedConfigOptions = persistedConfigOptions ?? configOptions; + + if (storedConfigOptions && result.configOptions) { const known = new Map( result.configOptions.map((opt) => [opt.id, opt]), ); - for (const opt of configOptions) { + for (const opt of storedConfigOptions) { if (!opt.currentValue) continue; const live = known.get(opt.id); if (live && live.currentValue !== opt.currentValue) { @@ -788,6 +826,11 @@ const useStore = create()( } addSession(session); + if (result.configOptions) { + useSessionConfigStore + .getState() + .setConfigOptions(taskRun.id, result.configOptions); + } subscribeToChannel(taskRun.id); track(ANALYTICS_EVENTS.TASK_RUN_STARTED, { @@ -1263,6 +1306,9 @@ const useStore = create()( opt.id === configId ? { ...opt, currentValue: value } : opt, ); updateSession(session.taskRunId, { configOptions: updated }); + useSessionConfigStore + .getState() + .setConfigOptions(session.taskRunId, updated); } } catch (error) { log.error("Failed to change session config option", { diff --git a/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx b/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx index cda263329..cc008a12c 100644 --- a/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/AdapterSelect.tsx @@ -21,7 +21,7 @@ const ADAPTER_CONFIG: Record< }, codex: { label: "Codex", - description: "OpenAI Codex", + description: "", icon: , }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf7bd7e1e..c149e8ba9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: reflect-metadata: specifier: ^0.2.2 version: 0.2.2 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -469,10 +472,10 @@ importers: version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: 10.2.0 - version: 10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + version: 10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + version: 10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -496,7 +499,7 @@ importers: version: 9.0.8 '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/ui': specifier: ^4.0.10 version: 4.0.17(vitest@4.0.17) @@ -541,13 +544,13 @@ importers: version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.10 - version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) yaml: specifier: ^2.8.1 version: 2.8.2 @@ -7672,6 +7675,9 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -9087,6 +9093,9 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -13207,11 +13216,11 @@ snapshots: '@jimp/types': 1.6.0 tinycolor2: 1.6.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: glob: 11.1.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 @@ -14918,10 +14927,10 @@ snapshots: axe-core: 4.11.1 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/addon-docs@10.2.0(@types/react@19.2.8)(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@19.1.0) - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -14935,27 +14944,27 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 rollup: 4.55.1 - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.104.1(esbuild@0.27.2) '@storybook/global@5.0.0': {} @@ -14971,11 +14980,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': + '@storybook/react-vite@10.2.0(esbuild@0.27.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) + '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(rollup@4.55.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)) '@storybook/react': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -14985,7 +14994,7 @@ snapshots: resolve: 1.22.11 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw @@ -15473,7 +15482,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -15481,7 +15490,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -15542,21 +15551,21 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.45.0) - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.17 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@4.0.17(vite@6.4.1(@types/node@25.0.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -15619,7 +15628,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@2.1.9': dependencies: @@ -18782,6 +18791,11 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -20533,6 +20547,12 @@ snapshots: dependencies: jsesc: 3.1.0 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -21674,13 +21694,13 @@ snapshots: magic-string: 0.30.21 vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -21707,6 +21727,23 @@ snapshots: lightningcss: 1.30.2 terser: 5.45.0 + vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.29 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.2 + terser: 5.45.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -21813,10 +21850,10 @@ snapshots: - supports-color - terser - vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.29)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 - '@vitest/mocker': 4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.17(vite@6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.17 '@vitest/runner': 4.0.17 '@vitest/snapshot': 4.0.17 @@ -21833,7 +21870,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@20.19.29)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@20.19.29)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.45.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 From dc13715610ff0366d13574d25bb03f6b3388f665 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Thu, 5 Feb 2026 21:02:21 +0100 Subject: [PATCH 6/6] fix: local codex editing and session resume --- apps/twig/src/main/services/agent/service.ts | 71 ++++++++++++++++++- .../sessions/stores/sessionAdapterStore.ts | 35 +++++++++ .../features/sessions/stores/sessionStore.ts | 41 ++++++++--- .../sessions/utils/parseSessionLogs.ts | 21 +++++- packages/agent/src/adapters/acp-connection.ts | 1 - packages/agent/src/otel-log-writer.ts | 16 +---- 6 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 apps/twig/src/renderer/features/sessions/stores/sessionAdapterStore.ts diff --git a/apps/twig/src/main/services/agent/service.ts b/apps/twig/src/main/services/agent/service.ts index 5dd543249..b084ad2ae 100644 --- a/apps/twig/src/main/services/agent/service.ts +++ b/apps/twig/src/main/services/agent/service.ts @@ -1,6 +1,6 @@ -import { mkdirSync, rmSync, symlinkSync } from "node:fs"; +import fs, { mkdirSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { type Client, ClientSideConnection, @@ -25,6 +25,7 @@ import type { AcpMessage } from "../../../shared/types/session-events.js"; import { MAIN_TOKENS } from "../../di/tokens.js"; import { logger } from "../../lib/logger.js"; import { TypedEventEmitter } from "../../lib/typed-event-emitter.js"; +import type { FsService } from "../fs/service.js"; import type { ProcessTrackingService } from "../process-tracking/service.js"; import type { SleepService } from "../sleep/service.js"; import { @@ -223,16 +224,20 @@ export class AgentService extends TypedEventEmitter { private pendingPermissions = new Map(); private processTracking: ProcessTrackingService; private sleepService: SleepService; + private fsService: FsService; constructor( @inject(MAIN_TOKENS.ProcessTrackingService) processTracking: ProcessTrackingService, @inject(MAIN_TOKENS.SleepService) sleepService: SleepService, + @inject(MAIN_TOKENS.FsService) + fsService: FsService, ) { super(); this.processTracking = processTracking; this.sleepService = sleepService; + this.fsService = fsService; } public updateToken(newToken: string): void { @@ -1079,6 +1084,38 @@ For git operations while detached: }; }, + async readTextFile(params) { + const session = service.sessions.get(taskRunId); + if (!session) { + throw new Error(`No active session for taskRunId=${taskRunId}`); + } + const repoPath = session.config.repoPath; + const relativePath = service.toRepoRelativePath(repoPath, params.path); + const content = await service.fsService.readRepoFile( + repoPath, + relativePath, + ); + if (content === null) { + throw new Error(`File not found: ${params.path}`); + } + return { content }; + }, + + async writeTextFile(params) { + const session = service.sessions.get(taskRunId); + if (!session) { + throw new Error(`No active session for taskRunId=${taskRunId}`); + } + const repoPath = session.config.repoPath; + const relativePath = service.toRepoRelativePath(repoPath, params.path); + await service.fsService.writeRepoFile( + repoPath, + relativePath, + params.content, + ); + return {}; + }, + async sessionUpdate() { // session/update notifications flow through the tapped stream }, @@ -1154,6 +1191,36 @@ For git operations while detached: } } + private toRepoRelativePath(repoPath: string, filePath: string): string { + const normalize = (inputPath: string): string => { + try { + return fs.realpathSync(inputPath); + } catch { + return resolve(inputPath); + } + }; + + const resolvedRepo = normalize(repoPath); + const resolvedFile = isAbsolute(filePath) + ? resolve(filePath) + : resolve(repoPath, filePath); + const resolvedFileForCheck = fs.existsSync(resolvedFile) + ? normalize(resolvedFile) + : resolve(resolvedFile); + const repoPrefix = resolvedRepo.endsWith(sep) + ? resolvedRepo + : `${resolvedRepo}${sep}`; + + if ( + resolvedFileForCheck === resolvedRepo || + !resolvedFileForCheck.startsWith(repoPrefix) + ) { + throw new Error(`Access denied: path outside repository (${filePath})`); + } + + return relative(resolvedRepo, resolvedFileForCheck); + } + private toSessionConfig( params: StartSessionInput | ReconnectSessionInput, ): SessionConfig { diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionAdapterStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionAdapterStore.ts new file mode 100644 index 000000000..a7e82cf20 --- /dev/null +++ b/apps/twig/src/renderer/features/sessions/stores/sessionAdapterStore.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { electronStorage } from "@/renderer/lib/electronStorage"; + +type AdapterType = "claude" | "codex"; + +interface SessionAdapterState { + adaptersByRunId: Record; + setAdapter: (taskRunId: string, adapter: AdapterType) => void; + getAdapter: (taskRunId: string) => AdapterType | undefined; + removeAdapter: (taskRunId: string) => void; +} + +export const useSessionAdapterStore = create()( + persist( + (set, get) => ({ + adaptersByRunId: {}, + setAdapter: (taskRunId, adapter) => + set((state) => ({ + adaptersByRunId: { ...state.adaptersByRunId, [taskRunId]: adapter }, + })), + getAdapter: (taskRunId) => get().adaptersByRunId[taskRunId], + removeAdapter: (taskRunId) => + set((state) => { + const { [taskRunId]: _removed, ...rest } = state.adaptersByRunId; + return { adaptersByRunId: rest }; + }), + }), + { + name: "session-adapter-storage", + storage: electronStorage, + partialize: (state) => ({ adaptersByRunId: state.adaptersByRunId }), + }, + ), +); diff --git a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts index d6defd9ff..4d1a94759 100644 --- a/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/twig/src/renderer/features/sessions/stores/sessionStore.ts @@ -35,6 +35,7 @@ import { type PermissionRequest, } from "../utils/parseSessionLogs"; import { useModelsStore } from "./modelsStore"; +import { useSessionAdapterStore } from "./sessionAdapterStore"; import { useSessionConfigStore } from "./sessionConfigStore"; const log = logger.scope("session-store"); @@ -303,6 +304,9 @@ function subscribeToChannel(taskRunId: string) { }; if (params.adapter) { session.adapter = params.adapter; + useSessionAdapterStore + .getState() + .setAdapter(taskRunId, params.adapter); log.info("Session adapter updated", { taskRunId, adapter: params.adapter, @@ -702,6 +706,10 @@ const useStore = create()( ) => { const { rawEntries, sessionId, adapter, configOptions } = await fetchSessionLogs(logUrl); + const storedAdapter = useSessionAdapterStore + .getState() + .getAdapter(taskRunId); + const resolvedAdapter = adapter ?? storedAdapter; const events = convertStoredEntriesToEvents(rawEntries); const session = createBaseSession(taskRunId, taskId, taskTitle, false); @@ -711,8 +719,11 @@ const useStore = create()( .getState() .getConfigOptions(taskRunId); session.configOptions = persistedConfigOptions ?? configOptions; - if (adapter) { - session.adapter = adapter; + if (resolvedAdapter) { + session.adapter = resolvedAdapter; + useSessionAdapterStore + .getState() + .setAdapter(taskRunId, resolvedAdapter); } addSession(session); @@ -728,7 +739,7 @@ const useStore = create()( projectId: auth.projectId, logUrl, sessionId, - adapter, + adapter: resolvedAdapter, }); if (result) { @@ -755,13 +766,26 @@ const useStore = create()( for (const opt of storedConfigOptions) { if (!opt.currentValue) continue; const live = known.get(opt.id); - if (live && live.currentValue !== opt.currentValue) { - await get().actions.setSessionConfigOption( + if (!live || live.currentValue === opt.currentValue) continue; + + const selectable = flattenSelectOptions(live.options); + const exists = selectable.some( + (option) => option.value === opt.currentValue, + ); + if (!exists) { + log.warn("Skipping invalid stored config option value", { taskId, - opt.id, - opt.currentValue, - ); + configId: opt.id, + value: opt.currentValue, + }); + continue; } + + await get().actions.setSessionConfigOption( + taskId, + opt.id, + opt.currentValue, + ); } } } else { @@ -823,6 +847,7 @@ const useStore = create()( session.configOptions = result.configOptions; if (adapter) { session.adapter = adapter; + useSessionAdapterStore.getState().setAdapter(taskRun.id, adapter); } addSession(session); diff --git a/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts b/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts index be788e725..9ef273a8b 100644 --- a/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts +++ b/apps/twig/src/renderer/features/sessions/utils/parseSessionLogs.ts @@ -47,6 +47,25 @@ export async function fetchSessionLogs( for (const line of content.trim().split("\n")) { try { const stored = JSON.parse(line) as StoredLogEntry; + if (!stored.notification) { + const maybeMsg = stored as unknown as { + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: unknown; + }; + if ( + typeof maybeMsg === "object" && + maybeMsg !== null && + ("method" in maybeMsg || + "result" in maybeMsg || + "error" in maybeMsg || + "id" in maybeMsg) + ) { + stored.notification = maybeMsg; + } + } const msg = stored.notification; if (msg) { @@ -100,8 +119,6 @@ export async function fetchSessionLogs( } if (params.adapter) { adapter = params.adapter; - } else if (sessionId) { - adapter = "claude"; } } } catch { diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index dd1077d10..7ed935217 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -317,7 +317,6 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { isLoadingSession = false; loadRequestId = null; } else if (msg.method === "session/update") { - logger.debug("Filtering replay session/update during load"); shouldFilter = true; } } diff --git a/packages/agent/src/otel-log-writer.ts b/packages/agent/src/otel-log-writer.ts index 59eb27db0..6b8ad2a75 100644 --- a/packages/agent/src/otel-log-writer.ts +++ b/packages/agent/src/otel-log-writer.ts @@ -7,7 +7,7 @@ import { } from "@opentelemetry/sdk-logs"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { StoredNotification } from "./types.js"; -import { Logger } from "./utils/logger.js"; +import type { Logger } from "./utils/logger.js"; export interface OtelLogConfig { /** PostHog ingest host, e.g., "https://us.i.posthog.com" */ @@ -36,18 +36,12 @@ export interface SessionContext { export class OtelLogWriter { private loggerProvider: LoggerProvider; private logger: ReturnType; - private debugLogger: Logger; - private sessionContext: SessionContext; constructor( config: OtelLogConfig, sessionContext: SessionContext, - debugLogger?: Logger, + _debugLogger?: Logger, ) { - this.debugLogger = - debugLogger ?? new Logger({ debug: false, prefix: "[OtelLogWriter]" }); - this.sessionContext = sessionContext; - const logsPath = config.logsPath ?? "/i/v1/agent-logs"; const exporter = new OTLPLogExporter({ url: `${config.posthogHost}${logsPath}`, @@ -88,12 +82,6 @@ export class OtelLogWriter { event_type: eventType, }, }); - - this.debugLogger.debug("Emitted OTEL log", { - taskId: this.sessionContext.taskId, - runId: this.sessionContext.runId, - eventType, - }); } async flush(): Promise {