feat(listen): isolate permission mode per conversation [LET-8050] (#1425)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Shubham Naik
2026-03-17 18:10:40 -07:00
committed by GitHub
parent ef7defe5d9
commit a405a59520
14 changed files with 327 additions and 32 deletions

View File

@@ -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({

View File

@@ -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<TContext = ApprovalContext | null> = {
requireArgsForAutoApprove?: boolean;
missingArgsReason?: (missing: string[]) => string;
workingDirectory?: string;
permissionModeState?: PermissionModeState;
};
export async function getMissingRequiredArgs(
@@ -80,6 +85,7 @@ export async function classifyApprovals<TContext = ApprovalContext | null>(
toolName,
parsedArgs,
opts.workingDirectory,
opts.permissionModeState,
);
const context = opts.getContext
? await opts.getContext(toolName, parsedArgs, opts.workingDirectory)

View File

@@ -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<PermissionCheckResult> {
// 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

View File

@@ -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<string, unknown>,
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";

View File

@@ -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<EnterPlanModeResult> {
// 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, "/")

View File

@@ -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);
}

View File

@@ -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<string, ExternalToolDefinition>;
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),
);

View File

@@ -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<string, string>;
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<string, string>;
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) => {

View File

@@ -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 });
}
}

View File

@@ -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,

View File

@@ -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,
),
},
);

View File

@@ -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,
},
);

View File

@@ -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;

View File

@@ -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<string, string>;
/** Per-conversation permission mode state. Mirrors workingDirectoryByConversation. */
permissionModeByConversation: Map<
string,
import("./permissionMode").ConversationPermissionModeState
>;
connectionId: string | null;
connectionName: string | null;
conversationRuntimes: Map<string, ConversationRuntime>;