diff --git a/src/agent/message.ts b/src/agent/message.ts index 972ca0b..644aae9 100644 --- a/src/agent/message.ts +++ b/src/agent/message.ts @@ -12,6 +12,7 @@ import type { MessageCreateParams as ConversationMessageCreateParams } from "@le import { type ClientTool, captureToolExecutionContext, + type PermissionModeState, waitForToolsetReady, } from "../tools/manager"; import { debugLog, debugWarn, isDebugEnabled } from "../utils/debug"; @@ -58,6 +59,9 @@ export type SendMessageStreamOptions = { agentId?: string; // Required when conversationId is "default" approvalNormalization?: ApprovalNormalizationOptions; workingDirectory?: string; + /** Per-conversation permission mode state. When provided, tool execution uses + * this scoped state instead of the global permissionMode singleton. */ + permissionModeState?: PermissionModeState; }; export function buildConversationMessagesCreateRequestBody( @@ -123,6 +127,7 @@ export async function sendMessageStream( await waitForToolsetReady(); const { clientTools, contextId } = captureToolExecutionContext( opts.workingDirectory, + opts.permissionModeState, ); const { clientSkills, errors: clientSkillDiscoveryErrors } = await buildClientSkillsPayload({ diff --git a/src/cli/helpers/approvalClassification.ts b/src/cli/helpers/approvalClassification.ts index 1bd1eb6..9131191 100644 --- a/src/cli/helpers/approvalClassification.ts +++ b/src/cli/helpers/approvalClassification.ts @@ -1,5 +1,9 @@ import type { ApprovalContext } from "../../permissions/analyzer"; -import { checkToolPermission, getToolSchema } from "../../tools/manager"; +import { + checkToolPermission, + getToolSchema, + type PermissionModeState, +} from "../../tools/manager"; import { safeJsonParseOr } from "./safeJsonParse"; import type { ApprovalRequest } from "./streamProcessor"; @@ -33,6 +37,7 @@ export type ClassifyApprovalsOptions = { requireArgsForAutoApprove?: boolean; missingArgsReason?: (missing: string[]) => string; workingDirectory?: string; + permissionModeState?: PermissionModeState; }; export async function getMissingRequiredArgs( @@ -80,6 +85,7 @@ export async function classifyApprovals( toolName, parsedArgs, opts.workingDirectory, + opts.permissionModeState, ); const context = opts.getContext ? await opts.getContext(toolName, parsedArgs, opts.workingDirectory) diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index d2a288a..e19c383 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -4,6 +4,7 @@ import { relative, resolve } from "node:path"; import { getCurrentAgentId } from "../agent/context"; import { runPermissionRequestHooks } from "../hooks"; +import type { PermissionModeState } from "../tools/manager"; import { canonicalToolName, isShellToolName } from "./canonical"; import { cliPermissions } from "./cli"; import { @@ -133,6 +134,7 @@ export function checkPermission( toolArgs: ToolArgs, permissions: PermissionRules, workingDirectory: string = process.cwd(), + modeState?: PermissionModeState, ): PermissionCheckResult { const engine: PermissionEngine = isPermissionsV2Enabled() ? "v2" : "v1"; const primary = checkPermissionForEngine( @@ -141,6 +143,7 @@ export function checkPermission( toolArgs, permissions, workingDirectory, + modeState, ); let result: PermissionCheckResult = primary.result; @@ -170,6 +173,7 @@ export function checkPermission( toolArgs, permissions, workingDirectory, + modeState, ); const mismatch = @@ -244,6 +248,7 @@ function checkPermissionForEngine( toolArgs: ToolArgs, permissions: PermissionRules, workingDirectory: string, + modeState?: PermissionModeState, ): { result: PermissionCheckResult; trace: PermissionCheckTrace } { const canonicalTool = canonicalToolName(toolName); const queryTool = engine === "v2" ? canonicalTool : toolName; @@ -298,22 +303,27 @@ function checkPermissionForEngine( } } + // Use the scoped permission mode state when available (listener/remote mode), + // otherwise fall back to the global singleton (local/CLI mode). + const effectiveMode = modeState?.mode ?? permissionMode.getMode(); + const effectivePlanFilePath = + modeState?.planFilePath ?? permissionMode.getPlanFilePath(); const modeOverride = permissionMode.checkModeOverride( toolName, toolArgs, workingDirectory, + effectiveMode, + effectivePlanFilePath, ); if (modeOverride) { - const currentMode = permissionMode.getMode(); - let reason = `Permission mode: ${currentMode}`; - if (currentMode === "plan" && modeOverride === "deny") { - const planFilePath = permissionMode.getPlanFilePath(); - const applyPatchRelativePath = planFilePath - ? relative(workingDirectory, planFilePath).replace(/\\/g, "/") + let reason = `Permission mode: ${effectiveMode}`; + if (effectiveMode === "plan" && modeOverride === "deny") { + const applyPatchRelativePath = effectivePlanFilePath + ? relative(workingDirectory, effectivePlanFilePath).replace(/\\/g, "/") : null; reason = `Plan mode is active. You can only use read-only tools (Read, Grep, Glob, etc.) and write to the plan file. ` + - `Write your plan to: ${planFilePath || "(error: plan file path not configured)"}. ` + + `Write your plan to: ${effectivePlanFilePath || "(error: plan file path not configured)"}. ` + (applyPatchRelativePath ? `If using apply_patch, use this exact relative path in patch headers: ${applyPatchRelativePath}. ` : "") + @@ -323,7 +333,7 @@ function checkPermissionForEngine( return { result: { decision: modeOverride, - matchedRule: `${currentMode} mode`, + matchedRule: `${effectiveMode} mode`, reason, }, trace, @@ -765,6 +775,7 @@ export async function checkPermissionWithHooks( toolArgs: ToolArgs, permissions: PermissionRules, workingDirectory: string = process.cwd(), + modeState?: PermissionModeState, ): Promise { // First, check permission using normal rules const result = checkPermission( @@ -772,6 +783,7 @@ export async function checkPermissionWithHooks( toolArgs, permissions, workingDirectory, + modeState, ); // If decision is "ask", run PermissionRequest hooks to see if they auto-allow/deny diff --git a/src/permissions/mode.ts b/src/permissions/mode.ts index 2bc1a98..db72b2e 100644 --- a/src/permissions/mode.ts +++ b/src/permissions/mode.ts @@ -246,15 +246,25 @@ class PermissionModeManager { } /** - * Check if a tool should be auto-allowed based on current mode - * Returns null if mode doesn't apply to this tool + * Check if a tool should be auto-allowed based on current mode. + * Accepts explicit `mode` and `planFilePath` overrides so callers with a + * scoped PermissionModeState (listener/remote mode) can bypass the global + * singleton without requiring a temporary mutation of global state. + * Returns null if mode doesn't apply to this tool. */ checkModeOverride( toolName: string, toolArgs?: Record, workingDirectory: string = process.cwd(), + modeOverride?: PermissionMode, + planFilePathOverride?: string | null, ): "allow" | "deny" | null { - switch (this.currentMode) { + const effectiveMode = modeOverride ?? this.currentMode; + const effectivePlanFilePath = + planFilePathOverride !== undefined + ? planFilePathOverride + : this.getPlanFilePath(); + switch (effectiveMode) { case "bypassPermissions": // Auto-allow everything (except explicit deny rules checked earlier) return "allow"; diff --git a/src/tools/impl/EnterPlanMode.ts b/src/tools/impl/EnterPlanMode.ts index 6624ae3..aa7fa9a 100644 --- a/src/tools/impl/EnterPlanMode.ts +++ b/src/tools/impl/EnterPlanMode.ts @@ -1,9 +1,11 @@ import { relative } from "node:path"; import { generatePlanFilePath } from "../../cli/helpers/planName"; import { permissionMode } from "../../permissions/mode"; +import { getExecutionContextPermissionModeState } from "../manager"; interface EnterPlanModeArgs { - [key: string]: never; + /** Injected by executeTool — do not pass manually */ + _executionContextId?: string; } interface EnterPlanModeResult { @@ -11,22 +13,40 @@ interface EnterPlanModeResult { } export async function enter_plan_mode( - _args: EnterPlanModeArgs, + args: EnterPlanModeArgs, ): Promise { + // Resolve the permission mode state: prefer the per-conversation scoped + // state when an execution context is present (listener/remote mode); + // fall back to a wrapper around the global singleton for local/CLI mode. + const scopedState = args._executionContextId + ? getExecutionContextPermissionModeState(args._executionContextId) + : undefined; + // Normally this is handled by handleEnterPlanModeApprove in the UI layer, // which sets up state and returns a precomputed result (so this function // never runs). But if the generic approval flow is used for any reason, // we need to set up state here as a defensive fallback. - if ( - permissionMode.getMode() !== "plan" || - !permissionMode.getPlanFilePath() - ) { - const planFilePath = generatePlanFilePath(); - permissionMode.setMode("plan"); - permissionMode.setPlanFilePath(planFilePath); + if (scopedState) { + if (scopedState.mode !== "plan" || !scopedState.planFilePath) { + const planFilePath = generatePlanFilePath(); + scopedState.modeBeforePlan = + scopedState.modeBeforePlan ?? scopedState.mode; + scopedState.mode = "plan"; + scopedState.planFilePath = planFilePath; + } + } else { + if ( + permissionMode.getMode() !== "plan" || + !permissionMode.getPlanFilePath() + ) { + const planFilePath = generatePlanFilePath(); + permissionMode.setMode("plan"); + permissionMode.setPlanFilePath(planFilePath); + } } - const planFilePath = permissionMode.getPlanFilePath(); + const planFilePath = + scopedState?.planFilePath ?? permissionMode.getPlanFilePath(); const cwd = process.env.USER_CWD || process.cwd(); const applyPatchRelativePath = planFilePath ? relative(cwd, planFilePath).replace(/\\/g, "/") diff --git a/src/tools/impl/ExitPlanMode.ts b/src/tools/impl/ExitPlanMode.ts index 14e4afc..b17bcec 100644 --- a/src/tools/impl/ExitPlanMode.ts +++ b/src/tools/impl/ExitPlanMode.ts @@ -4,12 +4,33 @@ */ import { permissionMode } from "../../permissions/mode"; +import { getExecutionContextPermissionModeState } from "../manager"; + +interface ExitPlanModeArgs { + /** Injected by executeTool — do not pass manually */ + _executionContextId?: string; +} + +export async function exit_plan_mode( + args: ExitPlanModeArgs = {}, +): Promise<{ message: string }> { + // Resolve the permission mode state: prefer the per-conversation scoped + // state when an execution context is present (listener/remote mode); + // fall back to a wrapper around the global singleton for local/CLI mode. + const scopedState = args._executionContextId + ? getExecutionContextPermissionModeState(args._executionContextId) + : undefined; -export async function exit_plan_mode(): Promise<{ message: string }> { // In interactive mode, the UI restores mode before calling this tool. // In headless/bidirectional mode, there is no UI layer to do that, so // restore here as a fallback to avoid getting stuck in plan mode. - if (permissionMode.getMode() === "plan") { + if (scopedState) { + if (scopedState.mode === "plan") { + scopedState.mode = scopedState.modeBeforePlan ?? "default"; + scopedState.modeBeforePlan = null; + scopedState.planFilePath = null; + } + } else if (permissionMode.getMode() === "plan") { const restoredMode = permissionMode.getModeBeforePlan() ?? "default"; permissionMode.setMode(restoredMode); } diff --git a/src/tools/manager.ts b/src/tools/manager.ts index a5c07f4..feaa711 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -8,6 +8,10 @@ import { runPostToolUseHooks, runPreToolUseHooks, } from "../hooks"; +import { + permissionMode as globalPermissionMode, + type PermissionMode, +} from "../permissions/mode"; import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider"; import { telemetry } from "../telemetry"; import { debugLog } from "../utils/debug"; @@ -303,11 +307,25 @@ function getSwitchLock(): SwitchLockState { const toolRegistry = getRegistry(); let toolExecutionContextCounter = 0; +/** + * Mutable, shared-by-reference permission mode state. + * Stored in each ToolExecutionContextSnapshot so tools like EnterPlanMode + * and ExitPlanMode can update the mode without touching the global singleton. + * Listener mode populates this from ConversationRuntime; CLI mode uses a + * wrapper around the global permissionMode singleton. + */ +export type PermissionModeState = { + mode: PermissionMode; + planFilePath: string | null; + modeBeforePlan: PermissionMode | null; +}; + type ToolExecutionContextSnapshot = { toolRegistry: ToolRegistry; externalTools: Map; externalExecutor?: ExternalToolExecutor; workingDirectory: string; + permissionModeState: PermissionModeState; }; export type CapturedToolExecutionContext = { @@ -346,6 +364,17 @@ function getExecutionContextById( return getExecutionContexts().get(contextId); } +/** + * Returns the mutable PermissionModeState for an execution context. + * EnterPlanMode / ExitPlanMode use this to update the per-conversation + * state without touching the global singleton. + */ +export function getExecutionContextPermissionModeState( + contextId: string, +): PermissionModeState | undefined { + return getExecutionContextById(contextId)?.permissionModeState; +} + export function clearCapturedToolExecutionContexts(): void { getExecutionContexts().clear(); } @@ -618,12 +647,37 @@ export function getClientToolsFromRegistry(): ClientTool[] { */ export function captureToolExecutionContext( workingDirectory: string = process.env.USER_CWD || process.cwd(), + permissionModeState?: PermissionModeState, ): CapturedToolExecutionContext { + // When no scoped state is provided (local/CLI mode), create a live proxy to + // the global singleton so EnterPlanMode/ExitPlanMode still work correctly. + const effectivePermissionModeState: PermissionModeState = + permissionModeState ?? { + get mode() { + return globalPermissionMode.getMode(); + }, + set mode(value: PermissionMode) { + globalPermissionMode.setMode(value); + }, + get planFilePath() { + return globalPermissionMode.getPlanFilePath(); + }, + set planFilePath(value: string | null) { + globalPermissionMode.setPlanFilePath(value); + }, + get modeBeforePlan() { + return globalPermissionMode.getModeBeforePlan(); + }, + set modeBeforePlan(_value: PermissionMode | null) { + // managed internally by globalPermissionMode + }, + }; const snapshot: ToolExecutionContextSnapshot = { toolRegistry: new Map(toolRegistry), externalTools: new Map(getExternalToolsRegistry()), externalExecutor: getExternalToolExecutor(), workingDirectory, + permissionModeState: effectivePermissionModeState, }; const contextId = saveExecutionContext(snapshot); @@ -699,6 +753,7 @@ export async function checkToolPermission( toolName: string, toolArgs: ToolArgs, workingDirectory: string = process.cwd(), + permissionModeStateArg?: PermissionModeState, ): Promise<{ decision: "allow" | "deny" | "ask"; matchedRule?: string; @@ -713,6 +768,7 @@ export async function checkToolPermission( toolArgs, permissions, workingDirectory, + permissionModeStateArg, ); } @@ -1285,6 +1341,21 @@ export async function executeTool( enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId }; } + // Inject the execution context id for plan-mode tools so they can update + // the per-conversation PermissionModeState without touching the global singleton. + const PLAN_MODE_TOOL_NAMES = new Set([ + "EnterPlanMode", + "enter_plan_mode", + "ExitPlanMode", + "exit_plan_mode", + ]); + if (PLAN_MODE_TOOL_NAMES.has(internalName) && options?.toolContextId) { + enhancedArgs = { + ...enhancedArgs, + _executionContextId: options.toolContextId, + }; + } + const result = await withExecutionWorkingDirectory(workingDirectory, () => tool.fn(enhancedArgs), ); diff --git a/src/websocket/listener/client.ts b/src/websocket/listener/client.ts index d44c6ae..47f07cc 100644 --- a/src/websocket/listener/client.ts +++ b/src/websocket/listener/client.ts @@ -11,7 +11,6 @@ import WebSocket from "ws"; import { getClient } from "../../agent/client"; import { generatePlanFilePath } from "../../cli/helpers/planName"; import { INTERRUPTED_BY_USER } from "../../constants"; -import { permissionMode } from "../../permissions/mode"; import { type DequeuedBatch, QueueRuntime } from "../../queue/queueRuntime"; import { createSharedReminderState } from "../../reminders/state"; import { settingsManager } from "../../settings-manager"; @@ -47,6 +46,10 @@ import { populateInterruptQueue, stashRecoveredApprovalInterrupts, } from "./interrupts"; +import { + getConversationPermissionModeState, + setConversationPermissionModeState, +} from "./permissionMode"; import { parseServerMessage } from "./protocol-inbound"; import { buildDeviceStatus, @@ -106,7 +109,10 @@ import type { } from "./types"; /** - * Handle mode change request from cloud + * Handle mode change request from cloud. + * Stores the new mode in ListenerRuntime.permissionModeByConversation so + * each agent/conversation is isolated and the state outlives the ephemeral + * ConversationRuntime (which gets evicted between turns). */ function handleModeChange( msg: ModeChangePayload, @@ -118,13 +124,34 @@ function handleModeChange( }, ): void { try { - permissionMode.setMode(msg.mode); + const agentId = scope?.agent_id ?? null; + const conversationId = scope?.conversation_id ?? "default"; + const current = getConversationPermissionModeState( + runtime, + agentId, + conversationId, + ); - // If entering plan mode, generate and set plan file path - if (msg.mode === "plan" && !permissionMode.getPlanFilePath()) { - const planFilePath = generatePlanFilePath(); - permissionMode.setPlanFilePath(planFilePath); + const next = { ...current }; + + // Track previous mode so ExitPlanMode can restore it + if (msg.mode === "plan" && current.mode !== "plan") { + next.modeBeforePlan = current.mode; } + next.mode = msg.mode; + + // Generate plan file path when entering plan mode + if (msg.mode === "plan" && !current.planFilePath) { + next.planFilePath = generatePlanFilePath(); + } + + // Clear plan-related state when leaving plan mode + if (msg.mode !== "plan") { + next.planFilePath = null; + next.modeBeforePlan = null; + } + + setConversationPermissionModeState(runtime, agentId, conversationId, next); emitDeviceStatusUpdate(socket, runtime, scope); @@ -385,6 +412,7 @@ function createRuntime(): ListenerRuntime { reminderState: createSharedReminderState(), bootWorkingDirectory, workingDirectoryByConversation: loadPersistedCwdMap(), + permissionModeByConversation: new Map(), connectionId: null, connectionName: null, conversationRuntimes: new Map(), @@ -1004,6 +1032,7 @@ function createLegacyTestRuntime(): ConversationRuntime & { activeConversationId: string; socket: WebSocket | null; workingDirectoryByConversation: Map; + permissionModeByConversation: ListenerRuntime["permissionModeByConversation"]; bootWorkingDirectory: string; connectionId: string | null; connectionName: string | null; @@ -1031,6 +1060,7 @@ function createLegacyTestRuntime(): ConversationRuntime & { activeConversationId: string; socket: WebSocket | null; workingDirectoryByConversation: Map; + permissionModeByConversation: ListenerRuntime["permissionModeByConversation"]; bootWorkingDirectory: string; connectionId: string | null; connectionName: string | null; @@ -1064,6 +1094,12 @@ function createLegacyTestRuntime(): ConversationRuntime & { listener.workingDirectoryByConversation = value; }, }, + permissionModeByConversation: { + get: () => listener.permissionModeByConversation, + set: (value: ListenerRuntime["permissionModeByConversation"]) => { + listener.permissionModeByConversation = value; + }, + }, bootWorkingDirectory: { get: () => listener.bootWorkingDirectory, set: (value: string) => { diff --git a/src/websocket/listener/permissionMode.ts b/src/websocket/listener/permissionMode.ts new file mode 100644 index 0000000..435c729 --- /dev/null +++ b/src/websocket/listener/permissionMode.ts @@ -0,0 +1,65 @@ +/** + * Per-conversation permission mode storage. + * + * Mirrors the CWD isolation pattern in cwd.ts: + * - State is stored in a Map on the long-lived ListenerRuntime (not on the + * ephemeral ConversationRuntime, which gets evicted between turns). + * - A scope key derived from agentId + conversationId is used as the map key. + */ + +import type { PermissionMode } from "../../permissions/mode"; +import { permissionMode as globalPermissionMode } from "../../permissions/mode"; +import { normalizeConversationId, normalizeCwdAgentId } from "./scope"; +import type { ListenerRuntime } from "./types"; + +export type ConversationPermissionModeState = { + mode: PermissionMode; + planFilePath: string | null; + modeBeforePlan: PermissionMode | null; +}; + +export function getPermissionModeScopeKey( + agentId?: string | null, + conversationId?: string | null, +): string { + const normalizedConversationId = normalizeConversationId(conversationId); + const normalizedAgentId = normalizeCwdAgentId(agentId); + if (normalizedConversationId === "default") { + return `agent:${normalizedAgentId ?? "__unknown__"}::conversation:default`; + } + return `conversation:${normalizedConversationId}`; +} + +export function getConversationPermissionModeState( + runtime: ListenerRuntime, + agentId?: string | null, + conversationId?: string | null, +): ConversationPermissionModeState { + const scopeKey = getPermissionModeScopeKey(agentId, conversationId); + return ( + runtime.permissionModeByConversation.get(scopeKey) ?? { + mode: globalPermissionMode.getMode(), + planFilePath: null, + modeBeforePlan: null, + } + ); +} + +export function setConversationPermissionModeState( + runtime: ListenerRuntime, + agentId: string | null, + conversationId: string, + state: ConversationPermissionModeState, +): void { + const scopeKey = getPermissionModeScopeKey(agentId, conversationId); + // Only store if different from the global default to keep the map lean. + if ( + state.mode === globalPermissionMode.getMode() && + state.planFilePath === null && + state.modeBeforePlan === null + ) { + runtime.permissionModeByConversation.delete(scopeKey); + } else { + runtime.permissionModeByConversation.set(scopeKey, { ...state }); + } +} diff --git a/src/websocket/listener/protocol-outbound.ts b/src/websocket/listener/protocol-outbound.ts index c38d927..db1ab9a 100644 --- a/src/websocket/listener/protocol-outbound.ts +++ b/src/websocket/listener/protocol-outbound.ts @@ -22,6 +22,7 @@ import type { } from "../../types/protocol_v2"; import { SYSTEM_REMINDER_RE } from "./constants"; import { getConversationWorkingDirectory } from "./cwd"; +import { getConversationPermissionModeState } from "./permissionMode"; import { getConversationRuntime, getPendingControlRequests, @@ -120,12 +121,18 @@ export function buildDeviceStatus( return "auto" as const; } })(); + // Read mode from the persistent ListenerRuntime map (outlives ConversationRuntime). + const conversationPermissionModeState = getConversationPermissionModeState( + listener, + scopedAgentId, + scopedConversationId, + ); return { current_connection_id: listener.connectionId, connection_name: listener.connectionName, is_online: listener.socket?.readyState === WebSocket.OPEN, is_processing: !!conversationRuntime?.isProcessing, - current_permission_mode: permissionMode.getMode(), + current_permission_mode: conversationPermissionModeState.mode, current_working_directory: getConversationWorkingDirectory( listener, scopedAgentId, diff --git a/src/websocket/listener/send.ts b/src/websocket/listener/send.ts index fdae2d7..2171470 100644 --- a/src/websocket/listener/send.ts +++ b/src/websocket/listener/send.ts @@ -41,6 +41,7 @@ import { emitToolExecutionFinishedEvents, emitToolExecutionStartedEvents, } from "./interrupts"; +import { getConversationPermissionModeState } from "./permissionMode"; import { emitRetryDelta, emitRuntimeStateUpdates, @@ -144,6 +145,11 @@ async function resolveStaleApprovals( requireArgsForAutoApprove: true, missingNameReason: "Tool call incomplete - missing name", workingDirectory: recoveryWorkingDirectory, + permissionModeState: getConversationPermissionModeState( + runtime.listener, + runtime.agentId, + runtime.conversationId, + ), }, ); diff --git a/src/websocket/listener/turn-approval.ts b/src/websocket/listener/turn-approval.ts index 571a058..f356f13 100644 --- a/src/websocket/listener/turn-approval.ts +++ b/src/websocket/listener/turn-approval.ts @@ -84,6 +84,7 @@ export async function handleApprovalStop(params: { agentId: string; conversationId: string; turnWorkingDirectory: string; + turnPermissionModeState: import("../../tools/manager").PermissionModeState; dequeuedBatchId: string; runId?: string; msgRunIds: string[]; @@ -101,6 +102,7 @@ export async function handleApprovalStop(params: { agentId, conversationId, turnWorkingDirectory, + turnPermissionModeState, dequeuedBatchId, runId, msgRunIds, @@ -161,6 +163,7 @@ export async function handleApprovalStop(params: { requireArgsForAutoApprove: true, missingNameReason: "Tool call incomplete - missing name", workingDirectory: turnWorkingDirectory, + permissionModeState: turnPermissionModeState, }, ); diff --git a/src/websocket/listener/turn.ts b/src/websocket/listener/turn.ts index 84a985d..426d1f0 100644 --- a/src/websocket/listener/turn.ts +++ b/src/websocket/listener/turn.ts @@ -42,6 +42,10 @@ import { normalizeToolReturnWireMessage, populateInterruptQueue, } from "./interrupts"; +import { + getConversationPermissionModeState, + setConversationPermissionModeState, +} from "./permissionMode"; import { emitCanonicalMessageDelta, emitInterruptedStatusDelta, @@ -91,6 +95,18 @@ export async function handleIncomingMessage( normalizedAgentId, conversationId, ); + + // Build a mutable permission mode state object for this turn, seeded from the + // persistent ListenerRuntime map. Tool implementations (EnterPlanMode, ExitPlanMode) + // mutate it in place; we sync the final value back to the map after the turn. + const turnPermissionModeState = { + ...getConversationPermissionModeState( + runtime.listener, + normalizedAgentId, + conversationId, + ), + }; + const msgRunIds: string[] = []; let postStopApprovalRecoveryRetries = 0; let llmApiErrorRetries = 0; @@ -197,6 +213,7 @@ export async function handleIncomingMessage( streamTokens: true, background: true, workingDirectory: turnWorkingDirectory, + permissionModeState: turnPermissionModeState, ...(pendingNormalizationInterruptedToolCallIds.length > 0 ? { approvalNormalization: { @@ -641,6 +658,7 @@ export async function handleIncomingMessage( agentId, conversationId, turnWorkingDirectory, + turnPermissionModeState, dequeuedBatchId, runId, msgRunIds, @@ -742,6 +760,15 @@ export async function handleIncomingMessage( console.error("[Listen] Error handling message:", error); } } finally { + // Sync any permission mode changes made by tools (EnterPlanMode/ExitPlanMode) + // back to the persistent ListenerRuntime map so the state survives eviction. + setConversationPermissionModeState( + runtime.listener, + normalizedAgentId, + conversationId, + turnPermissionModeState, + ); + runtime.activeAbortController = null; runtime.cancelRequested = false; runtime.isRecoveringApprovals = false; diff --git a/src/websocket/listener/types.ts b/src/websocket/listener/types.ts index 14bcf9a..2876331 100644 --- a/src/websocket/listener/types.ts +++ b/src/websocket/listener/types.ts @@ -3,6 +3,7 @@ import type { ApprovalCreate } from "@letta-ai/letta-client/resources/agents/mes import type WebSocket from "ws"; import type { ApprovalResult } from "../../agent/approval-execution"; import type { ApprovalRequest } from "../../cli/helpers/stream"; + import type { DequeuedBatch, QueueBlockedReason, @@ -148,6 +149,11 @@ export type ListenerRuntime = { reminderState: SharedReminderState; bootWorkingDirectory: string; workingDirectoryByConversation: Map; + /** Per-conversation permission mode state. Mirrors workingDirectoryByConversation. */ + permissionModeByConversation: Map< + string, + import("./permissionMode").ConversationPermissionModeState + >; connectionId: string | null; connectionName: string | null; conversationRuntimes: Map;