From 389dfd34fa0dd292f06e9f66cc13e67802a99a38 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:44:26 -0700 Subject: [PATCH 1/3] fix(coding-agent): serialize session operations Reserve runtime replacements and guard session mutations so overlapping operations cannot corrupt active session state. --- .../coding-agent/src/core/agent-session.ts | 204 ++++++++++++++---- 1 file changed, 163 insertions(+), 41 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a498b85ec..1ca31eb26 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,6 +13,7 @@ * Modes use this class and add their own I/O layer on top. */ +import { AsyncLocalStorage } from "node:async_hooks"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import type { @@ -298,6 +299,16 @@ interface ToolDefinitionEntry { sourceInfo: SourceInfo; } +interface PromptLease { + planSubmitted: boolean; +} + +interface PromptPreflightLease { + settled: Promise; + settle: () => void; + discardedByPlanSubmission: boolean; +} + function estimateMessagesTokens(messages: AgentMessage[]): number { let tokens = 0; for (const message of messages) { @@ -331,6 +342,12 @@ export class AgentSession { private _unsubscribeAgent?: () => void; private _eventListeners: AgentSessionEventListener[] = []; private _isAgentRunActive = false; + private _promptLease: PromptLease | undefined; + private readonly _promptPreflightLeases = new Set(); + private readonly _extensionCommandContext = new AsyncLocalStorage(); + private _activeExtensionCommandOperation: object | undefined; + private _runtimeReplacementLease: object | undefined; + private readonly _runtimeMutationLeases = new Set(); private _idleWaitPromise: Promise | undefined; private _resolveIdleWait: (() => void) | undefined; @@ -644,8 +661,17 @@ export class AgentSession { /** Emit an event to all listeners */ private _emit(event: AgentSessionEvent): void { - for (const l of this._eventListeners) { - l(event); + for (const listener of [...this._eventListeners]) { + try { + const result: unknown = listener(event); + if (result instanceof Promise) { + void result.catch(() => { + // Subscriber failures must not alter session state or agent control flow. + }); + } + } catch { + // Subscriber failures must not alter session state or agent control flow. + } } } @@ -916,6 +942,51 @@ export class AgentSession { } } + private _assertRuntimeReplacementAvailable(): void { + if ( + this._activeExtensionCommandOperation && + this._extensionCommandContext.getStore() !== this._activeExtensionCommandOperation + ) { + throw new Error("Extension command execution is in progress."); + } + if (this._runtimeReplacementLease) { + throw new Error("Session runtime replacement is in progress."); + } + } + + private _beginRuntimeMutation(): () => void { + this._assertRuntimeReplacementAvailable(); + const lease = {}; + this._runtimeMutationLeases.add(lease); + return () => { + this._runtimeMutationLeases.delete(lease); + }; + } + + /** Reserve this idle session for shutdown and runtime replacement. The returned release is idempotent. */ + reserveRuntimeReplacement(): () => void { + this._assertRuntimeReplacementAvailable(); + if ( + this._promptLease || + this._promptPreflightLeases.size > 0 || + !this.isIdle || + this.isCompacting || + this.isRetrying || + this.isBashRunning || + this._runtimeMutationLeases.size > 0 + ) { + throw new Error("Wait for the current operation to finish before replacing the session runtime."); + } + if (this.agent.hasQueuedMessages() || this._pendingNextTurnMessages.length > 0) { + throw new Error("Clear queued messages before replacing the session runtime."); + } + const lease = {}; + this._runtimeReplacementLease = lease; + return () => { + if (this._runtimeReplacementLease === lease) this._runtimeReplacementLease = undefined; + }; + } + /** * Remove all listeners and disconnect from agent. * Call this when completely done with the session. @@ -1175,6 +1246,7 @@ export class AgentSession { /** Update scoped models for cycling */ setScopedModels(scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>): void { + this._assertRuntimeReplacementAvailable(); this._scopedModels = scopedModels; } @@ -1633,6 +1705,7 @@ export class AgentSession { * @throws Error if text is an extension command */ async steer(text: string, images?: ImageContent[]): Promise { + this._assertRuntimeReplacementAvailable(); // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); @@ -1653,6 +1726,7 @@ export class AgentSession { * @throws Error if text is an extension command */ async followUp(text: string, images?: ImageContent[]): Promise { + this._assertRuntimeReplacementAvailable(); // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); @@ -1730,6 +1804,7 @@ export class AgentSession { message: Pick, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { + this._assertRuntimeReplacementAvailable(); const appMessage = { role: "custom" as const, customType: message.customType, @@ -1773,6 +1848,7 @@ export class AgentSession { content: string | (TextContent | ImageContent)[], options?: { deliverAs?: "steer" | "followUp" }, ): Promise { + this._assertRuntimeReplacementAvailable(); // Normalize content to text string + optional images let text: string; let images: ImageContent[] | undefined; @@ -1878,20 +1954,25 @@ export class AgentSession { * @throws Error if no auth is configured for the model */ async setModel(model: Model): Promise { - if (!(await this._modelRuntime.checkAuth(model.provider))) { - throw new Error(`No API key for ${model.provider}/${model.id}`); - } + const releaseMutation = this._beginRuntimeMutation(); + try { + if (!(await this._modelRuntime.checkAuth(model.provider))) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } - const previousModel = this.model; - const thinkingLevel = this._getThinkingLevelForModelSwitch(); - this.agent.state.model = model; - this.sessionManager.appendModelChange(model.provider, model.id); - this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + const previousModel = this.model; + const thinkingLevel = this._getThinkingLevelForModelSwitch(); + this.agent.state.model = model; + this.sessionManager.appendModelChange(model.provider, model.id); + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); - // Re-clamp thinking level for new model's capabilities - this.setThinkingLevel(thinkingLevel); + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); - await this._emitModelSelect(model, previousModel, "set"); + await this._emitModelSelect(model, previousModel, "set"); + } finally { + releaseMutation(); + } } /** @@ -1901,10 +1982,15 @@ export class AgentSession { * @returns The new model info, or undefined if only one model available */ async cycleModel(direction: "forward" | "backward" = "forward"): Promise { - if (this._scopedModels.length > 0) { - return this._cycleScopedModel(direction); + const releaseMutation = this._beginRuntimeMutation(); + try { + if (this._scopedModels.length > 0) { + return await this._cycleScopedModel(direction); + } + return await this._cycleAvailableModel(direction); + } finally { + releaseMutation(); } - return this._cycleAvailableModel(direction); } private async _cycleScopedModel(direction: "forward" | "backward"): Promise { @@ -1976,6 +2062,7 @@ export class AgentSession { * Saves to session and settings only if the level actually changes. */ setThinkingLevel(level: ThinkingLevel): void { + this._assertRuntimeReplacementAvailable(); const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); @@ -2004,6 +2091,7 @@ export class AgentSession { * @returns New level, or undefined if model doesn't support thinking */ cycleThinkingLevel(): ThinkingLevel | undefined { + this._assertRuntimeReplacementAvailable(); if (!this.supportsThinking()) return undefined; const levels = this.getAvailableThinkingLevels(); @@ -2059,6 +2147,7 @@ export class AgentSession { * Saves to settings. */ setSteeringMode(mode: "all" | "one-at-a-time"): void { + this._assertRuntimeReplacementAvailable(); this.agent.steeringMode = mode; this.settingsManager.setSteeringMode(mode); } @@ -2068,6 +2157,7 @@ export class AgentSession { * Saves to settings. */ setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this._assertRuntimeReplacementAvailable(); this.agent.followUpMode = mode; this.settingsManager.setFollowUpMode(mode); } @@ -2086,14 +2176,24 @@ export class AgentSession { } private async _compact(customInstructions?: string): Promise { - await this.abort(); - if (this._disposePromise) { - throw new Error("Compaction cancelled"); + if (this._promptPreflightLeases.size > 0) { + throw new Error("Wait for the current prompt to finish before compacting the session."); + } + const releaseMutation = this._beginRuntimeMutation(); + try { + await this.abort(); + if (this._disposePromise) { + throw new Error("Compaction cancelled"); + } + } catch (error) { + releaseMutation(); + throw error; } this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual" }); try { + const effectiveCustomInstructions = this._compactionInstructions(customInstructions); if (!this.model) { throw new Error(formatNoModelSelectedMessage()); } @@ -2211,6 +2311,7 @@ export class AgentSession { throw error; } finally { this._compactionAbortController = undefined; + releaseMutation(); } } @@ -2545,6 +2646,7 @@ export class AgentSession { * Toggle auto-compaction setting. */ setAutoCompactionEnabled(enabled: boolean): void { + this._assertRuntimeReplacementAvailable(); this.settingsManager.setCompactionEnabled(enabled); } @@ -2554,28 +2656,33 @@ export class AgentSession { } async bindExtensions(bindings: ExtensionBindings): Promise { - if (bindings.uiContext !== undefined) { - this._extensionUIContext = bindings.uiContext; - } - if (bindings.mode !== undefined) { - this._extensionMode = bindings.mode; - } - if (bindings.commandContextActions !== undefined) { - this._extensionCommandContextActions = bindings.commandContextActions; - } - if (bindings.abortHandler !== undefined) { - this._extensionAbortHandler = bindings.abortHandler; - } - if (bindings.shutdownHandler !== undefined) { - this._extensionShutdownHandler = bindings.shutdownHandler; - } - if (bindings.onError !== undefined) { - this._extensionErrorListener = bindings.onError; - } + const releaseMutation = this._beginRuntimeMutation(); + try { + if (bindings.uiContext !== undefined) { + this._extensionUIContext = bindings.uiContext; + } + if (bindings.mode !== undefined) { + this._extensionMode = bindings.mode; + } + if (bindings.commandContextActions !== undefined) { + this._extensionCommandContextActions = bindings.commandContextActions; + } + if (bindings.abortHandler !== undefined) { + this._extensionAbortHandler = bindings.abortHandler; + } + if (bindings.shutdownHandler !== undefined) { + this._extensionShutdownHandler = bindings.shutdownHandler; + } + if (bindings.onError !== undefined) { + this._extensionErrorListener = bindings.onError; + } - this._applyExtensionBindings(this._extensionRunner); - await this._extensionRunner.emit(this._sessionStartEvent); - await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup"); + this._applyExtensionBindings(this._extensionRunner); + await this._extensionRunner.emit(this._sessionStartEvent); + await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup"); + } finally { + releaseMutation(); + } } private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise { @@ -3079,6 +3186,7 @@ export class AgentSession { * Toggle auto-retry setting. */ setAutoRetryEnabled(enabled: boolean): void { + this._assertRuntimeReplacementAvailable(); this.settingsManager.setRetryEnabled(enabled); } @@ -3108,6 +3216,7 @@ export class AgentSession { onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations }, ): Promise { + this._assertRuntimeReplacementAvailable(); const abortController = new AbortController(); this._bashAbortControllers.add(abortController); @@ -3142,6 +3251,7 @@ export class AgentSession { * Used by executeBash and by extensions that handle bash execution themselves. */ recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { + this._assertRuntimeReplacementAvailable(); const bashMessage: BashExecutionMessage = { role: "bashExecution", command, @@ -3212,6 +3322,7 @@ export class AgentSession { * Set a display name for the current session. */ setSessionName(name: string): void { + this._assertRuntimeReplacementAvailable(); this.sessionManager.appendSessionInfo(name); const event = { type: "session_info_changed", name: this.sessionManager.getSessionName() } as const; this._emit(event); @@ -3244,9 +3355,20 @@ export class AgentSession { targetId: string, options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> { - if (this.isStreaming) { + this._assertRuntimeReplacementAvailable(); + if ( + this._promptLease !== undefined || + this._promptPreflightLeases.size > 0 || + this.isStreaming || + this.isCompacting || + this.isRetrying || + this.isBashRunning + ) { throw new Error("Wait for the current response to finish before navigating the session tree."); } + if (this.agent.hasQueuedMessages() || this._pendingNextTurnMessages.length > 0) { + throw new Error("Clear queued messages before navigating the session tree."); + } const oldLeafId = this.sessionManager.getLeafId(); From b2bf0a961a6e416946adfbadaba75aa87edcd400 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:44:38 -0700 Subject: [PATCH 2/3] feat(coding-agent): enforce plan collaboration policy Configure plan-aware tools and prompts while constraining collaboration behavior to the active session mode. --- .../src/core/agent-session-services.ts | 2 + .../coding-agent/src/core/agent-session.ts | 425 +++++++++++++++--- packages/coding-agent/src/core/sdk.ts | 16 +- .../coding-agent/src/core/system-prompt.ts | 23 +- 4 files changed, 412 insertions(+), 54 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 22a7882c2..f36a8a3ed 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -58,6 +58,7 @@ export interface CreateAgentSessionFromServicesOptions { model?: Model; thinkingLevel?: ThinkingLevel; scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + collaborationMode?: CreateAgentSessionOptions["collaborationMode"]; tools?: string[]; excludeTools?: CreateAgentSessionOptions["excludeTools"]; noTools?: CreateAgentSessionOptions["noTools"]; @@ -218,6 +219,7 @@ export async function createAgentSessionFromServices( model: options.model, thinkingLevel: options.thinkingLevel, scopedModels: options.scopedModels, + collaborationMode: options.collaborationMode, tools: options.tools, excludeTools: options.excludeTools, noTools: options.noTools, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1ca31eb26..94018e2af 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -14,6 +14,7 @@ */ import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import type { @@ -99,6 +100,16 @@ import { emitSessionShutdownEvent } from "./extensions/runner.ts"; import type { BashExecutionMessage, CustomMessage } from "./messages.ts"; import { ModelRegistry } from "./model-registry.ts"; import type { ModelRuntime } from "./model-runtime.ts"; +import { + type CollaborationMode, + createPlanImplementationPrompt, + createSubmitPlanToolDefinition, + type PlanArtifact, + type PlanResolution, + parsePlanArtifact, + SUBMIT_PLAN_TOOL_NAME, + type SubmitPlanInput, +} from "./plan-mode.ts"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "./session-manager.ts"; @@ -144,11 +155,7 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null { /** Session-specific events that extend the core AgentEvent */ export type AgentSessionEvent = | Exclude - | { - type: "agent_end"; - messages: AgentMessage[]; - willRetry: boolean; - } + | { type: "agent_end"; messages: AgentMessage[]; willRetry: boolean } | { type: "agent_settled" } | { type: "queue_update"; @@ -159,6 +166,9 @@ export type AgentSessionEvent = | { type: "entry_appended"; entry: SessionEntry } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } + | { type: "collaboration_mode_changed"; mode: CollaborationMode } + | { type: "plan_submitted"; plan: PlanArtifact } + | { type: "plan_resolved"; planId: string; resolution: PlanResolution } | { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; @@ -224,6 +234,8 @@ export interface AgentSessionConfig { modelRuntime: ModelRuntime; /** Initial active built-in tool names. Default: [read, bash, edit, write, update_plan] */ initialActiveToolNames?: string[]; + /** Initial collaboration mode restored from the session or selected by the caller. */ + collaborationMode?: CollaborationMode; /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ allowedToolNames?: string[]; /** Optional denylist of tool names. When provided, these tool names are not exposed. */ @@ -266,6 +278,19 @@ export interface PromptOptions { preflightResult?: (success: boolean) => void; } +/** One-shot implementation continuation returned after a pending plan is accepted. */ +export interface PlanContinuation { + readonly message: string; +} + +/** Host metadata for submitting an accepted plan continuation. */ +export interface PlanContinuationPromptOptions { + /** Source reported to extension lifecycle events. */ + source?: InputSource; + /** Called once prompt ownership is accepted or rejected. */ + preflightResult?: (success: boolean) => void; +} + /** Result from cycleModel() */ export interface ModelCycleResult { model: Model; @@ -326,6 +351,8 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "hi const CURRENT_PLAN_RESTORATION_CUSTOM_TYPE = "diffex.current-plan-restoration"; const CURRENT_PLAN_RESTORATION_REMINDER = "Reminder: restored context is not proof of completion. Verify current state before claiming work is done."; +const PLAN_READ_ONLY_TOOL_NAMES = ["read", "grep", "find", "ls"] as const; +const PLAN_TOOL_NAMES = [...PLAN_READ_ONLY_TOOL_NAMES, SUBMIT_PLAN_TOOL_NAME] as const; // ============================================================================ // AgentSession Class @@ -386,6 +413,10 @@ export class AgentSession { private _cwd: string; private _extensionRunnerRef?: { current?: ExtensionRunner }; private _initialActiveToolNames?: string[]; + private _collaborationMode: CollaborationMode; + private _pendingPlan: PlanArtifact | undefined; + private _pendingPlanContinuation: PlanContinuation | undefined; + private _normalActiveToolNames: string[] = []; private _allowedToolNames?: Set; private _excludedToolNames?: Set; private _baseToolsOverride?: Record; @@ -404,6 +435,10 @@ export class AgentSession { private _toolDefinitions: Map = new Map(); private _toolPromptSnippets: Map = new Map(); private _toolPromptGuidelines: Map = new Map(); + private _planToolRegistry: Map = new Map(); + private _planToolDefinitions: Map = new Map(); + private _planToolPromptSnippets: Map = new Map(); + private _planToolPromptGuidelines: Map = new Map(); // Base system prompt (without extension appends) - used to apply fresh appends each turn private _baseSystemPrompt = ""; @@ -425,6 +460,27 @@ export class AgentSession { this._modelRuntime = config.modelRuntime; this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; + const persistedCollaboration = this.sessionManager.buildSessionContext(); + const collaborationMode = config.collaborationMode ?? persistedCollaboration.collaborationMode; + if (collaborationMode !== "default" && collaborationMode !== "plan") { + throw new Error(`Invalid collaboration mode: ${String(collaborationMode)}`); + } + const pendingPlan = persistedCollaboration.pendingPlan; + const pendingPlanContinuation = persistedCollaboration.pendingPlanContinuation; + if (pendingPlan && collaborationMode !== "plan") { + throw new Error("Cannot leave plan mode while the session has a pending plan."); + } + if (pendingPlanContinuation && collaborationMode !== "default") { + throw new Error("Cannot change collaboration mode while an accepted plan continuation is pending."); + } + if (collaborationMode !== persistedCollaboration.collaborationMode) { + this.sessionManager.appendCollaborationModeChange(collaborationMode); + } + this._collaborationMode = collaborationMode; + this._pendingPlan = pendingPlan ? Object.freeze({ ...pendingPlan }) : undefined; + this._pendingPlanContinuation = pendingPlanContinuation + ? Object.freeze({ message: createPlanImplementationPrompt(pendingPlanContinuation) }) + : undefined; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; @@ -512,6 +568,14 @@ export class AgentSession { } } + private _isPlanToolContext(tools: readonly AgentTool[] | undefined): boolean { + return ( + tools?.some( + (tool) => tool.name === SUBMIT_PLAN_TOOL_NAME && this._planToolRegistry.get(tool.name) === tool, + ) === true + ); + } + /** * Install tool hooks once on the Agent instance. * @@ -521,7 +585,20 @@ export class AgentSession { * happens here instead of in wrappers. */ private _installAgentToolHooks(): void { - this.agent.beforeToolCall = async ({ toolCall, args }) => { + this.agent.beforeToolCall = async ({ toolCall, args, context }) => { + const isPlanToolContext = this._collaborationMode === "plan" || this._isPlanToolContext(context.tools); + if (isPlanToolContext) { + const expectedTool = this._planToolRegistry.get(toolCall.name); + const activeTool = context.tools?.find((tool) => tool.name === toolCall.name); + if (!expectedTool || activeTool !== expectedTool) { + return { + block: true, + reason: `Tool ${toolCall.name} is not available in plan mode.`, + }; + } + return undefined; + } + const runner = this._extensionRunner; if (!runner.hasHandlers("tool_call")) { return undefined; @@ -542,9 +619,10 @@ export class AgentSession { } }; - this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { + this.agent.afterToolCall = async ({ toolCall, args, result, isError, context }) => { + const isPlanToolContext = this._collaborationMode === "plan" || this._isPlanToolContext(context.tools); const runner = this._extensionRunner; - const hookResult = runner.hasHandlers("tool_result") + const hookResult = !isPlanToolContext && runner.hasHandlers("tool_result") ? await runner.emitToolResult({ type: "tool_result", toolName: toolCall.name, @@ -1127,12 +1205,32 @@ export class AgentSession { return this.agent.state.thinkingLevel; } - /** Whether the session is currently processing an agent run or post-run continuation. */ + /** Current collaboration policy. */ + get collaborationMode(): CollaborationMode { + return this._collaborationMode; + } + + /** Submitted plan awaiting a user decision, if any. */ + get pendingPlan(): PlanArtifact | undefined { + return this._pendingPlan ? { ...this._pendingPlan } : undefined; + } + + /** Accepted plan implementation awaiting its exact one-shot user turn, if any. */ + get pendingPlanContinuation(): PlanContinuation | undefined { + return this._pendingPlanContinuation; + } + + /** Whether the session is preparing or processing an agent run or post-run continuation. */ get isStreaming(): boolean { - return this._isAgentRunActive; + return ( + this._activeExtensionCommandOperation !== undefined || + this._promptPreflightLeases.size > 0 || + this._promptLease !== undefined || + this._isAgentRunActive + ); } - /** Whether the session has no active agent run, retry, auto-compaction, or queued continuation. */ + /** Whether the session has no active agent run or post-run continuation. */ get isIdle(): boolean { return !this._isAgentRunActive; } @@ -1159,7 +1257,8 @@ export class AgentSession { * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata. */ getAllTools(): ToolInfo[] { - return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ + const definitions = this._collaborationMode === "plan" ? this._planToolDefinitions : this._toolDefinitions; + return Array.from(definitions.values()).map(({ definition, sourceInfo }) => ({ name: definition.name, description: definition.description, parameters: definition.parameters, @@ -1169,7 +1268,31 @@ export class AgentSession { } getToolDefinition(name: string): ToolDefinition | undefined { - return this._toolDefinitions.get(name)?.definition; + const definitions = this._collaborationMode === "plan" ? this._planToolDefinitions : this._toolDefinitions; + return definitions.get(name)?.definition; + } + + private _applyActiveTools(toolNames: readonly string[], registry: ReadonlyMap): void { + const tools: AgentTool[] = []; + const validToolNames: string[] = []; + for (const name of toolNames) { + const tool = registry.get(name); + if (tool && !validToolNames.includes(name)) { + tools.push(tool); + validToolNames.push(name); + } + } + this.agent.state.tools = tools; + this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); + this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt; + } + + private _applyToolsForCollaborationMode(): void { + if (this._collaborationMode === "plan") { + this._applyActiveTools(PLAN_TOOL_NAMES, this._planToolRegistry); + return; + } + this._applyActiveTools(this._normalActiveToolNames, this._toolRegistry); } /** Current in-memory child-agent roster, sorted by name. */ @@ -1184,20 +1307,152 @@ export class AgentSession { * Changes take effect on the next agent turn. */ setActiveToolsByName(toolNames: string[]): void { - const tools: AgentTool[] = []; + this._assertRuntimeReplacementAvailable(); const validToolNames: string[] = []; for (const name of toolNames) { const tool = this._toolRegistry.get(name); - if (tool) { - tools.push(tool); + if (tool && !validToolNames.includes(name)) { validToolNames.push(name); } + } } - this.agent.state.tools = tools; + this._normalActiveToolNames = validToolNames; + this._applyToolsForCollaborationMode(); + } - // Rebuild base system prompt with new tool set - this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); - this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt; + /** + * Change collaboration policy while the session is idle. + * A submitted plan must be resolved before leaving plan mode. + */ + setCollaborationMode(mode: CollaborationMode): void { + this._assertRuntimeReplacementAvailable(); + if (mode !== "default" && mode !== "plan") { + throw new Error(`Invalid collaboration mode: ${String(mode)}`); + } + if (mode === this._collaborationMode) return; + if (this._pendingPlanContinuation) { + throw new Error("Submit the pending plan continuation before changing collaboration mode."); + } + if ( + this._promptLease !== undefined || + this._promptPreflightLeases.size > 0 || + !this.isIdle || + this.isCompacting || + this.isRetrying || + this.isBashRunning + ) { + throw new Error("Wait for the current operation to finish before changing collaboration mode."); + } + if (this.agent.hasQueuedMessages() || this._pendingNextTurnMessages.length > 0) { + throw new Error("Clear queued messages before changing collaboration mode."); + } + if (mode === "default" && this._pendingPlan) { + throw new Error("Resolve the pending plan before leaving plan mode."); + } + this._setCollaborationMode(mode); + } + + private _setCollaborationMode(mode: CollaborationMode): void { + if (mode === this._collaborationMode) return; + this._applyCollaborationModeTransition(mode, () => { + this.sessionManager.appendCollaborationModeChange(mode); + }); + if (mode === "plan") this._pendingPlanContinuation = undefined; + this._emit({ type: "collaboration_mode_changed", mode }); + } + + private _applyCollaborationModeTransition(mode: CollaborationMode, persist: () => void): void { + const previousMode = this._collaborationMode; + const previousTools = this.agent.state.tools.slice(); + const previousBaseSystemPrompt = this._baseSystemPrompt; + const previousSystemPrompt = this.agent.state.systemPrompt; + const previousBaseSystemPromptOptions = this._baseSystemPromptOptions; + this._collaborationMode = mode; + try { + this._applyToolsForCollaborationMode(); + persist(); + } catch (error) { + this._collaborationMode = previousMode; + this.agent.state.tools = previousTools; + this._baseSystemPrompt = previousBaseSystemPrompt; + this.agent.state.systemPrompt = previousSystemPrompt; + this._baseSystemPromptOptions = previousBaseSystemPromptOptions; + throw error; + } + } + + private _restoreCollaborationState( + mode: CollaborationMode, + pendingPlan: PlanArtifact | undefined, + pendingPlanContinuation: PlanArtifact | undefined, + ): void { + const modeChanged = mode !== this._collaborationMode; + this._collaborationMode = mode; + this._pendingPlan = pendingPlan ? Object.freeze({ ...pendingPlan }) : undefined; + this._pendingPlanContinuation = pendingPlanContinuation + ? Object.freeze({ message: createPlanImplementationPrompt(pendingPlanContinuation) }) + : undefined; + this._applyToolsForCollaborationMode(); + if (modeChanged) { + this._emit({ type: "collaboration_mode_changed", mode }); + } + } + + private _submitPlan(input: SubmitPlanInput): PlanArtifact { + if (this._collaborationMode !== "plan") { + throw new Error("Plans can only be submitted in plan mode."); + } + + const plan = parsePlanArtifact({ + id: randomUUID(), + title: input.title, + markdown: input.markdown, + }); + if (!plan) throw new Error("Invalid plan artifact."); + this.sessionManager.appendPlan(plan); + this._pendingPlan = plan; + this._pendingPlanContinuation = undefined; + if (this._activePromptLease) this._activePromptLease.planSubmitted = true; + this._emit({ type: "plan_submitted", plan }); + return plan; + } + + /** Durably resolve the current plan and return any same-session continuation for the host to submit. */ + resolvePlan(planId: string, resolution: PlanResolution): PlanContinuation | undefined { + this._assertRuntimeReplacementAvailable(); + const plan = this._pendingPlan; + if (!plan || plan.id !== planId) { + throw new Error("Plan is not pending."); + } + if (resolution !== "implement" && resolution !== "discard") { + throw new Error("Invalid plan resolution."); + } + if ( + this._promptLease !== undefined || + this._promptPreflightLeases.size > 0 || + this._isAgentRunActive || + this.isCompacting || + this.isRetrying || + this.isBashRunning + ) { + throw new Error("Wait for the current operation to finish before resolving the plan."); + } + + const previousMode = this._collaborationMode; + this._applyCollaborationModeTransition("default", () => { + this.sessionManager.appendPlanResolution(plan.id, resolution); + }); + + this._pendingPlan = undefined; + this.clearQueue(); + this._pendingNextTurnMessages = []; + this._pendingPlanContinuation = + resolution === "implement" ? Object.freeze({ message: createPlanImplementationPrompt(plan) }) : undefined; + this._emit({ type: "plan_resolved", planId: plan.id, resolution }); + if (previousMode !== "default") { + this._emit({ type: "collaboration_mode_changed", mode: "default" }); + } + return this._pendingPlanContinuation; } /** Whether compaction or branch summarization is currently running */ @@ -1280,18 +1535,23 @@ export class AgentSession { } private _rebuildSystemPrompt(toolNames: string[]): string { - const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); + const toolRegistry = this._collaborationMode === "plan" ? this._planToolRegistry : this._toolRegistry; + const promptSnippets = + this._collaborationMode === "plan" ? this._planToolPromptSnippets : this._toolPromptSnippets; + const promptGuidelines = + this._collaborationMode === "plan" ? this._planToolPromptGuidelines : this._toolPromptGuidelines; + const validToolNames = toolNames.filter((name) => toolRegistry.has(name)); const toolSnippets: Record = {}; - const promptGuidelines: string[] = []; + const activePromptGuidelines: string[] = []; for (const name of validToolNames) { - const snippet = this._toolPromptSnippets.get(name); + const snippet = promptSnippets.get(name); if (snippet) { toolSnippets[name] = snippet; } - const toolGuidelines = this._toolPromptGuidelines.get(name); + const toolGuidelines = promptGuidelines.get(name); if (toolGuidelines) { - promptGuidelines.push(...toolGuidelines); + activePromptGuidelines.push(...toolGuidelines); } } @@ -1308,9 +1568,10 @@ export class AgentSession { contextFiles: loadedContextFiles, customPrompt: loaderSystemPrompt, appendSystemPrompt, + collaborationMode: this._collaborationMode, selectedTools: validToolNames, toolSnippets, - promptGuidelines, + promptGuidelines: activePromptGuidelines, }; return buildSystemPrompt(this._baseSystemPromptOptions); } @@ -2888,7 +3149,7 @@ export class AgentSession { private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void { const previousRegistryNames = new Set(this._toolRegistry.keys()); - const previousActiveToolNames = this.getActiveToolNames(); + const previousActiveToolNames = [...this._normalActiveToolNames]; const allowedToolNames = this._allowedToolNames; const excludedToolNames = this._excludedToolNames; const isAllowedTool = (name: string): boolean => @@ -2947,6 +3208,57 @@ export class AgentSession { })), runner, ); + const planModeDefinitions = [createSubmitPlanToolDefinition({ submitPlan: (input) => this._submitPlan(input) })]; + const registeredPlanModeTools = planModeDefinitions.map((definition) => ({ + definition, + sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + })); + const wrappedPlanModeTools = wrapRegisteredTools(registeredPlanModeTools, runner); + + const planDefinitionRegistry = new Map(); + const planToolRegistry = new Map(); + if (!this._baseToolsOverride) { + const registeredPlanBuiltinTools = PLAN_READ_ONLY_TOOL_NAMES.filter((name) => isAllowedTool(name)) + .map((name) => ({ + definition: this._baseToolDefinitions.get(name), + sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + })) + .filter( + (entry): entry is { definition: ToolDefinition; sourceInfo: SourceInfo } => + entry.definition !== undefined, + ); + const wrappedPlanBuiltinTools = wrapRegisteredTools(registeredPlanBuiltinTools, runner); + for (let i = 0; i < registeredPlanBuiltinTools.length; i++) { + const registered = registeredPlanBuiltinTools[i]; + const tool = wrappedPlanBuiltinTools[i]; + planDefinitionRegistry.set(registered.definition.name, registered); + planToolRegistry.set(tool.name, tool); + } + } + for (let i = 0; i < registeredPlanModeTools.length; i++) { + const registered = registeredPlanModeTools[i]; + const tool = wrappedPlanModeTools[i]; + planDefinitionRegistry.set(registered.definition.name, registered); + planToolRegistry.set(tool.name, tool); + } + this._planToolDefinitions = planDefinitionRegistry; + this._planToolRegistry = planToolRegistry; + this._planToolPromptSnippets = new Map( + Array.from(planDefinitionRegistry.values()) + .map(({ definition }) => { + const snippet = this._normalizePromptSnippet(definition.promptSnippet); + return snippet ? ([definition.name, snippet] as const) : undefined; + }) + .filter((entry): entry is readonly [string, string] => entry !== undefined), + ); + this._planToolPromptGuidelines = new Map( + Array.from(planDefinitionRegistry.values()) + .map(({ definition }) => { + const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines); + return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined; + }) + .filter((entry): entry is readonly [string, string[]] => entry !== undefined), + ); const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool])); for (const tool of wrappedExtensionTools as AgentTool[]) { @@ -3037,30 +3349,38 @@ export class AgentSession { } async reload(options?: { beforeSessionStart?: () => void | Promise }): Promise { - await this._subagentController?.reset(); - const oldRunner = this._extensionRunner; - const previousFlagValues = oldRunner.getFlagValues(); - await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" }); - oldRunner.invalidate(); - await this.settingsManager.reload(); - this.syncQueueModesFromSettings(); - resetApiProviders(); - await this._resourceLoader.reload(); - this._buildRuntime({ - activeToolNames: this.getActiveToolNames(), - flagValues: previousFlagValues, - includeAllExtensionTools: true, - }); + if (this._promptPreflightLeases.size > 0) { + throw new Error("Wait for the current prompt to finish before reloading the session runtime."); + } + const releaseMutation = this._beginRuntimeMutation(); + try { + await this._subagentController?.reset(); + const oldRunner = this._extensionRunner; + const previousFlagValues = oldRunner.getFlagValues(); + await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" }); + oldRunner.invalidate(); + await this.settingsManager.reload(); + this.syncQueueModesFromSettings(); + resetApiProviders(); + await this._resourceLoader.reload(); + this._buildRuntime({ + activeToolNames: [...this._normalActiveToolNames], + flagValues: previousFlagValues, + includeAllExtensionTools: true, + }); - const hasBindings = - this._extensionUIContext || - this._extensionCommandContextActions || - this._extensionShutdownHandler || - this._extensionErrorListener; - if (hasBindings) { - await options?.beforeSessionStart?.(); - await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); - await this.extendResourcesFromExtensions("reload"); + const hasBindings = + this._extensionUIContext || + this._extensionCommandContextActions || + this._extensionShutdownHandler || + this._extensionErrorListener; + if (hasBindings) { + await options?.beforeSessionStart?.(); + await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); + await this.extendResourcesFromExtensions("reload"); + } + } finally { + releaseMutation(); } } @@ -3536,6 +3856,11 @@ export class AgentSession { // Update agent state const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; + this._restoreCollaborationState( + sessionContext.collaborationMode, + sessionContext.pendingPlan, + sessionContext.pendingPlanContinuation, + ); // Emit session_tree event await this._extensionRunner.emit({ diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a72899ef3..88f284edb 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -10,6 +10,7 @@ import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefi import { convertToLlm } from "./messages.ts"; import { findInitialModel } from "./model-resolver.ts"; import { ModelRuntime } from "./model-runtime.ts"; +import type { CollaborationMode } from "./plan-mode.ts"; import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; import type { ResourceLoader } from "./resource-loader.ts"; import { DefaultResourceLoader } from "./resource-loader.ts"; @@ -62,13 +63,17 @@ export interface CreateAgentSessionOptions { thinkingLevel?: ThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + /** Collaboration policy. Existing sessions restore their latest persisted mode by default. */ + collaborationMode?: CollaborationMode; /** * Optional default tool suppression mode when no explicit allowlist is provided. * - * - "all": start with no tools enabled - * - "builtin": disable the default built-in tools (read, bash, edit, write, update_plan) + * - "all": start with no normal-mode or plan-mode inspection tools enabled + * - "builtin": disable all built-in tools * but keep extension/custom tools enabled + * + * Plan mode always retains its intrinsic `submit_plan` tool. */ noTools?: "all" | "builtin"; /** @@ -77,9 +82,13 @@ export interface CreateAgentSessionOptions { * When omitted, Diffex enables the default built-in tools (read, bash, edit, write, update_plan) * and leaves extension/custom tools enabled unless `noTools` changes that default. * When provided, only the listed tool names are enabled. + * Plan mode always retains its intrinsic `submit_plan` tool. */ tools?: string[]; - /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ + /** + * Optional denylist of tool names to disable. Applies after `tools` when both are provided. + * Plan mode always retains its intrinsic `submit_plan` tool. + */ excludeTools?: string[]; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; @@ -448,6 +457,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} customTools, modelRuntime, initialActiveToolNames, + collaborationMode: options.collaborationMode, allowedToolNames, excludedToolNames, extensionRunnerRef, diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 81c581aa0..9f5cc3d82 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -3,6 +3,7 @@ */ import { getDocsPath, getExamplesPath, getReadmePath } from "../config.ts"; +import type { CollaborationMode } from "./plan-mode.ts"; import { formatSkillsForPrompt, type Skill } from "./skills.ts"; export interface BuildSystemPromptOptions { @@ -10,6 +11,8 @@ export interface BuildSystemPromptOptions { customPrompt?: string; /** Tools to include in prompt. Default: [read, bash, edit, write, update_plan] */ selectedTools?: string[]; + /** Active collaboration policy for this session. */ + collaborationMode?: CollaborationMode; /** Optional one-line tool snippets keyed by tool name. */ toolSnippets?: Record; /** Additional guideline bullets appended to the default system prompt guidelines. */ @@ -42,6 +45,18 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { const contextFiles = providedContextFiles ?? []; const skills = providedSkills ?? []; + const planModeRules = + options.collaborationMode === "plan" + ? ` +Plan mode is active: +- Plan implementation requests instead of executing them. Do not modify files or external state. +- Inspect the relevant repository context before asking questions. Resolve discoverable facts with read-only tools. +- Ask concise ordinary questions only for material user preferences or tradeoffs, and distinguish them from discoverable facts. +- Stabilize both user intent and implementation detail before submitting a plan. +- Submit only a decision-complete plan covering relevant interfaces, tests, and explicit assumptions. +- Finish by calling submit_plan with the complete plan. +- Call submit_plan by itself, without other tool calls in the same response.` + : ""; if (customPrompt) { let prompt = customPrompt; @@ -49,6 +64,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { if (appendSection) { prompt += appendSection; } + if (planModeRules) { + prompt += `${planModeRules}\nNo other tools are available in plan mode.`; + } // Append project context files if (contextFiles.length > 0) { @@ -123,7 +141,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { Available tools: ${toolsList} -In addition to the tools above, you may have access to other custom tools depending on the project. +${options.collaborationMode === "plan" ? "No other tools are available in plan mode." : "In addition to the tools above, you may have access to other custom tools depending on the project."} Guidelines: ${guidelines} @@ -140,6 +158,9 @@ Diffex documentation (read only when the user asks about Diffex itself, its SDK, if (appendSection) { prompt += appendSection; } + if (planModeRules) { + prompt += planModeRules; + } // Append project context files if (contextFiles.length > 0) { From 0023bbd63b56243b3719b83a0181327d9bcf4a22 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:44:45 -0700 Subject: [PATCH 3/3] feat(coding-agent): continue accepted plans Persist submitted plan state and resume accepted plans through the normal prompt lifecycle. --- .../coding-agent/src/core/agent-session.ts | 276 ++++++++++++++++-- 1 file changed, 245 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 94018e2af..a6e8a2758 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -334,6 +334,12 @@ interface PromptPreflightLease { discardedByPlanSubmission: boolean; } +interface ActivePlanContinuation { + continuation: PlanContinuation; + userMessage: AgentMessage; + persisted: boolean; +} + function estimateMessagesTokens(messages: AgentMessage[]): number { let tokens = 0; for (const message of messages) { @@ -370,6 +376,7 @@ export class AgentSession { private _eventListeners: AgentSessionEventListener[] = []; private _isAgentRunActive = false; private _promptLease: PromptLease | undefined; + private _activePromptLease: PromptLease | undefined; private readonly _promptPreflightLeases = new Set(); private readonly _extensionCommandContext = new AsyncLocalStorage(); private _activeExtensionCommandOperation: object | undefined; @@ -416,6 +423,7 @@ export class AgentSession { private _collaborationMode: CollaborationMode; private _pendingPlan: PlanArtifact | undefined; private _pendingPlanContinuation: PlanContinuation | undefined; + private _activePlanContinuation: ActivePlanContinuation | undefined; private _normalActiveToolNames: string[] = []; private _allowedToolNames?: Set; private _excludedToolNames?: Set; @@ -495,6 +503,7 @@ export class AgentSession { this._installAgentToolHooks(); this._installAgentContextRestoration(); this._installAgentNextTurnRefresh(); + this._installAgentPlanStop(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, @@ -733,6 +742,14 @@ export class AgentSession { return [...messages, restoredContext]; } + private _installAgentPlanStop(): void { + const previousShouldStopAfterTurn = this.agent.shouldStopAfterTurn; + this.agent.shouldStopAfterTurn = async (context, signal) => { + if (this._activePromptLease?.planSubmitted) return true; + return (await previousShouldStopAfterTurn?.(context, signal)) === true; + }; + } + // ========================================================================= // Event Subscription // ========================================================================= @@ -821,7 +838,7 @@ export class AgentSession { // Emit to extensions first await this._emitExtensionEvent(event); - // Notify all listeners + // Notify all listeners. this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event); // Handle session persistence @@ -842,6 +859,9 @@ export class AgentSession { ) { // Regular LLM message - persist as SessionMessageEntry this.sessionManager.appendMessage(event.message); + if (event.message.role === "user" && event.message === this._activePlanContinuation?.userMessage) { + this._activePlanContinuation.persisted = true; + } } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere @@ -952,7 +972,12 @@ export class AgentSession { type: "message_end", message: event.message, }; - const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent); + // The accepted-plan continuation is already the result of an explicit user decision. + // Preserve that exact initial user message as the durable replay-consumption boundary. + const replacement = + event.message === this._activePlanContinuation?.userMessage + ? undefined + : await this._extensionRunner.emitMessageEnd(extensionEvent); if (replacement) { // Untyped extension handlers can return messages with null/missing content; // normalize so it never enters agent state or session history. @@ -1580,18 +1605,59 @@ export class AgentSession { // Prompting // ========================================================================= - private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise { + private _acquirePromptLease(): PromptLease { + if (this._promptLease || this._isAgentRunActive) { + throw new Error("Agent is already preparing or processing another prompt."); + } + const lease: PromptLease = { planSubmitted: false }; + this._promptLease = lease; + return lease; + } + + private _releasePromptLease(lease: PromptLease): void { + if (this._promptLease === lease) this._promptLease = undefined; + } + + private _isPromptBusyFor(lease: PromptLease | undefined): boolean { + return this._isAgentRunActive || (this._promptLease !== undefined && this._promptLease !== lease); + } + + private _getEarlierPromptPreflight(lease: PromptPreflightLease): PromptPreflightLease | undefined { + for (const candidate of this._promptPreflightLeases) { + if (candidate === lease) return undefined; + return candidate; + } + return undefined; + } + + private async _runAgentPrompt(messages: AgentMessage | AgentMessage[], lease: PromptLease): Promise { + if (this._promptLease !== lease || this._activePromptLease) { + throw new Error("Prompt ownership was lost before the agent run started."); + } + this._activePromptLease = lease; this._agentRunAborted = false; this._isAgentRunActive = true; try { await this.agent.prompt(messages); - while (await this._handlePostAgentRun()) { - await this.agent.continue(); + if (!lease.planSubmitted) { + while (await this._handlePostAgentRun()) { + await this.agent.continue(); + if (lease.planSubmitted) break; + } } } finally { - this._systemPromptOverride = undefined; - this._flushPendingBashMessages(); - await this._emitAgentSettled(); + try { + if (lease.planSubmitted) { + this._lastAssistantMessage = undefined; + this.clearQueue(); + } + this._systemPromptOverride = undefined; + this._flushPendingBashMessages(); + } finally { + if (this._activePromptLease === lease) this._activePromptLease = undefined; + this._releasePromptLease(lease); + await this._emitAgentSettled(); + } } } @@ -1739,20 +1805,115 @@ export class AgentSession { * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { + if (this._pendingPlanContinuation || (this._activePlanContinuation && !this._activePlanContinuation.persisted)) { + try { + options?.preflightResult?.(false); + } catch { + // Observation callbacks must not replace the authoritative rejection. + } + throw new Error("Submit the pending plan continuation before sending another prompt."); + } + if ((options?.expandPromptTemplates ?? true) && text.startsWith("/")) { + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + if (this._extensionRunner.getCommand(commandName)) { + let success = false; + let commandOperation: object | undefined; + try { + this._assertRuntimeReplacementAvailable(); + commandOperation = {}; + this._activeExtensionCommandOperation = commandOperation; + await this._extensionCommandContext.run(commandOperation, () => this._tryExecuteExtensionCommand(text)); + success = true; + } finally { + if (this._activeExtensionCommandOperation === commandOperation) { + this._activeExtensionCommandOperation = undefined; + } + try { + options?.preflightResult?.(success); + } catch { + // Observation callbacks must not alter extension command control flow. + } + } + return; + } + } + await this._prompt(text, options); + } + + /** Submit the exact one-shot continuation returned by resolvePlan("implement"). */ + async promptPlanContinuation( + continuation: PlanContinuation, + options?: PlanContinuationPromptOptions, + ): Promise { + if (this._pendingPlanContinuation !== continuation) { + try { + options?.preflightResult?.(false); + } catch { + // Observation callbacks must not replace the authoritative rejection. + } + throw new Error("Plan continuation is no longer pending."); + } + await this._prompt( + continuation.message, + { + expandPromptTemplates: false, + source: options?.source, + preflightResult: options?.preflightResult, + }, + continuation, + ); + } + + private async _prompt( + text: string, + options: PromptOptions | undefined, + trustedContinuation?: PlanContinuation, + ): Promise { + let settlePreflight!: () => void; + const preflightLease: PromptPreflightLease = { + settled: new Promise((resolve) => { + settlePreflight = resolve; + }), + settle: () => settlePreflight(), + discardedByPlanSubmission: false, + }; + this._promptPreflightLeases.add(preflightLease); + let preflightReleased = false; + const releasePreflight = (): void => { + if (preflightReleased) return; + preflightReleased = true; + this._promptPreflightLeases.delete(preflightLease); + preflightLease.settle(); + }; + const queuedBehindLease = this._isPromptBusyFor(undefined) ? this._promptLease : undefined; const expandPromptTemplates = options?.expandPromptTemplates ?? true; const preflightResult = options?.preflightResult; let messages: AgentMessage[] | undefined; + let promptLease: PromptLease | undefined; + let leaseTransferred = false; + let preflightReported = false; + const reportPreflight = (success: boolean): void => { + if (preflightReported) return; + preflightReported = true; + try { + preflightResult?.(success); + } catch { + // Observation callbacks must not interrupt an accepted run or mask its rejection. + } + }; + const discardAfterTerminalPlanSubmission = (): boolean => { + if (!queuedBehindLease?.planSubmitted) return false; + preflightLease.discardedByPlanSubmission = true; + releasePreflight(); + reportPreflight(true); + return true; + }; try { - // Handle extension commands first (execute immediately, even during streaming) - // Extension commands manage their own LLM interaction via diffex.sendMessage() - if (expandPromptTemplates && text.startsWith("/")) { - const handled = await this._tryExecuteExtensionCommand(text); - if (handled) { - // Extension command executed, no prompt to send - preflightResult?.(true); - return; - } + this._assertRuntimeReplacementAvailable(); + if (!this._isAgentRunActive && !this._promptLease && !this._getEarlierPromptPreflight(preflightLease)) { + promptLease = this._acquirePromptLease(); } if (this._compactionAbortController !== undefined) { @@ -1764,15 +1925,16 @@ export class AgentSession { // Emit input event for extension interception (before skill/template expansion) let currentText = text; let currentImages = options?.images; - if (this._extensionRunner.hasHandlers("input")) { + if (!trustedContinuation && this._extensionRunner.hasHandlers("input")) { const inputResult = await this._extensionRunner.emitInput( currentText, currentImages, options?.source ?? "interactive", - this.isStreaming ? options?.streamingBehavior : undefined, + this._isPromptBusyFor(promptLease) ? options?.streamingBehavior : undefined, ); if (inputResult.action === "handled") { - preflightResult?.(true); + releasePreflight(); + reportPreflight(true); return; } if (inputResult.action === "transform") { @@ -1787,9 +1949,25 @@ export class AgentSession { expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); } + if (discardAfterTerminalPlanSubmission()) return; + while (true) { + const earlierPreflight = this._getEarlierPromptPreflight(preflightLease); + if (!earlierPreflight) break; + await earlierPreflight.settled; + if (earlierPreflight.discardedByPlanSubmission) { + preflightLease.discardedByPlanSubmission = true; + releasePreflight(); + reportPreflight(true); + return; + } + } + if (discardAfterTerminalPlanSubmission()) return; // If streaming, queue via steer() or followUp() based on option - if (this.isStreaming) { + if (this._isPromptBusyFor(promptLease)) { + if (trustedContinuation) { + throw new Error("Agent is already preparing or processing another prompt."); + } if (!options?.streamingBehavior) { throw new Error( "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", @@ -1800,10 +1978,13 @@ export class AgentSession { } else { await this._queueSteer(expandedText, currentImages); } - preflightResult?.(true); + releasePreflight(); + reportPreflight(true); return; } + promptLease ??= this._acquirePromptLease(); + // Flush any pending bash messages before the new prompt this._flushPendingBashMessages(); @@ -1884,17 +2065,50 @@ export class AgentSession { this._systemPromptOverride = undefined; this.agent.state.systemPrompt = this._baseSystemPrompt; } + + if (trustedContinuation) { + if (this._pendingPlanContinuation !== trustedContinuation) { + throw new Error("Plan continuation is no longer pending."); + } + const userMessage = messages[0]; + if (!userMessage || userMessage.role !== "user") { + throw new Error("Plan continuation did not produce an initial user message."); + } + if (Array.isArray(userMessage.content)) { + for (const part of userMessage.content) Object.freeze(part); + Object.freeze(userMessage.content); + } + Object.freeze(userMessage); + this._activePlanContinuation = { continuation: trustedContinuation, userMessage, persisted: false }; + this._pendingPlanContinuation = undefined; + } + + const run = this._runAgentPrompt(messages, promptLease); + leaseTransferred = true; + releasePreflight(); + reportPreflight(true); + await run; + if (trustedContinuation && !this._activePlanContinuation?.persisted) { + throw new Error("Plan continuation user message was not persisted. Retry the pending continuation."); + } } catch (error) { - preflightResult?.(false); + reportPreflight(false); throw error; + } finally { + const activePlanContinuation = this._activePlanContinuation; + if (trustedContinuation && activePlanContinuation?.continuation === trustedContinuation) { + if (!activePlanContinuation.persisted && !this._pendingPlanContinuation) { + const replayContext = this.sessionManager.buildSessionContext(); + if (replayContext.pendingPlanContinuation) { + this._pendingPlanContinuation = trustedContinuation; + this.agent.state.messages = replayContext.messages; + } + } + this._activePlanContinuation = undefined; + } + if (promptLease && !leaseTransferred) this._releasePromptLease(promptLease); + releasePreflight(); } - - if (!messages) { - return; - } - - preflightResult?.(true); - await this._runAgentPrompt(messages); } /** @@ -2084,7 +2298,7 @@ export class AgentSession { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { - await this._runAgentPrompt(appMessage); + await this._runAgentPrompt(appMessage, this._acquirePromptLease()); } else { this.agent.state.messages.push(appMessage); this.sessionManager.appendCustomMessageEntry(