import { feature } from 'bun:bundle' import { writeFile } from 'fs/promises' import { z } from 'zod/v4' import { getAllowedChannels, hasExitedPlanModeInSession, setHasExitedPlanMode, setNeedsAutoModeExitAttachment, setNeedsPlanModeExitAttachment, } from '../../bootstrap/state.js' import { logEvent } from '../../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' import { buildTool, type Tool, type ToolDef, toolMatchesName, } from '../../Tool.js' import { formatAgentId, generateRequestId } from '../../utils/agentId.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { logForDebugging } from '../../utils/debug.js' import { findInProcessTeammateTaskId, setAwaitingPlanApproval, } from '../../utils/inProcessTeammateHelpers.js' import { lazySchema } from '../../utils/lazySchema.js' import { logError } from '../../utils/log.js' import { getPlan, getPlanFilePath, persistFileSnapshotIfRemote, } from '../../utils/plans.js' import { jsonStringify } from '../../utils/slowOperations.js' import { getAgentName, getTeamName, isPlanModeRequired, isTeammate, } from '../../utils/teammate.js' import { writeToMailbox } from '../../utils/teammateMailbox.js' import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' import { TEAM_CREATE_TOOL_NAME } from '../TeamCreateTool/constants.js' import { EXIT_PLAN_MODE_V2_TOOL_NAME } from './constants.js' import { EXIT_PLAN_MODE_V2_TOOL_PROMPT } from './prompt.js' import { renderToolResultMessage, renderToolUseMessage, renderToolUseRejectedMessage, } from './UI.js' /* eslint-disable @typescript-eslint/no-require-imports */ const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('../../utils/permissions/autoModeState.js') as typeof import('../../utils/permissions/autoModeState.js')) : null const permissionSetupModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('../../utils/permissions/permissionSetup.js') as typeof import('../../utils/permissions/permissionSetup.js')) : null /* eslint-enable @typescript-eslint/no-require-imports */ /** * Schema for prompt-based permission requests. * Used by Claude to request semantic permissions when exiting plan mode. */ const allowedPromptSchema = lazySchema(() => z.object({ tool: z.enum(['Bash']).describe('The tool this prompt applies to'), prompt: z .string() .describe( 'Semantic description of the action, e.g. "run tests", "install dependencies"', ), }), ) export type AllowedPrompt = z.infer> const inputSchema = lazySchema(() => z .strictObject({ // Prompt-based permissions requested by the plan allowedPrompts: z .array(allowedPromptSchema()) .optional() .describe( 'Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.', ), }) .passthrough(), ) type InputSchema = ReturnType /** * SDK-facing input schema - includes fields injected by normalizeToolInput. * The internal inputSchema doesn't have these fields because plan is read from disk, * but the SDK/hooks see the normalized version with plan and file path included. */ export const _sdkInputSchema = lazySchema(() => inputSchema().extend({ plan: z .string() .optional() .describe('The plan content (injected by normalizeToolInput from disk)'), planFilePath: z .string() .optional() .describe('The plan file path (injected by normalizeToolInput)'), }), ) export const outputSchema = lazySchema(() => z.object({ plan: z .string() .nullable() .describe('The plan that was presented to the user'), isAgent: z.boolean(), filePath: z .string() .optional() .describe('The file path where the plan was saved'), hasTaskTool: z .boolean() .optional() .describe('Whether the Agent tool is available in the current context'), planWasEdited: z .boolean() .optional() .describe( 'True when the user edited the plan (CCR web UI or Ctrl+G); determines whether the plan is echoed back in tool_result', ), awaitingLeaderApproval: z .boolean() .optional() .describe( 'When true, the teammate has sent a plan approval request to the team leader', ), requestId: z .string() .optional() .describe('Unique identifier for the plan approval request'), }), ) type OutputSchema = ReturnType export type Output = z.infer export const ExitPlanModeV2Tool: Tool = buildTool({ name: EXIT_PLAN_MODE_V2_TOOL_NAME, searchHint: 'present plan for approval and start coding (plan mode only)', maxResultSizeChars: 100_000, async description() { return 'Prompts the user to exit plan mode and start coding' }, async prompt() { return EXIT_PLAN_MODE_V2_TOOL_PROMPT }, get inputSchema(): InputSchema { return inputSchema() }, get outputSchema(): OutputSchema { return outputSchema() }, userFacingName() { return '' }, shouldDefer: true, isEnabled() { // When --channels is active the user is likely on Telegram/Discord, not // watching the TUI. The plan-approval dialog would hang. Paired with the // same gate on EnterPlanMode so plan mode isn't a trap. if ( (feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0 ) { return false } return true }, isConcurrencySafe() { return true }, isReadOnly() { return false // Now writes to disk }, requiresUserInteraction() { // For ALL teammates, no local user interaction needed: // - If isPlanModeRequired(): team lead approves via mailbox // - Otherwise: exits locally without approval (voluntary plan mode) if (isTeammate()) { return false } // For non-teammates, require user confirmation to exit plan mode return true }, async validateInput(_input, { getAppState, options }) { // Teammate AppState may show leader's mode (runAgent.ts skips override in // acceptEdits/bypassPermissions/auto); isPlanModeRequired() is the real source if (isTeammate()) { return { result: true } } // The deferred-tool list announces this tool regardless of mode, so the // model can call it after plan approval (fresh delta on compact/clear). // Reject before checkPermissions to avoid showing the approval dialog. const mode = getAppState().toolPermissionContext.mode if (mode !== 'plan') { logEvent('tengu_exit_plan_mode_called_outside_plan', { model: options.mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, hasExitedPlanModeInSession: hasExitedPlanModeInSession(), }) return { result: false, message: 'You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation.', errorCode: 1, } } return { result: true } }, async checkPermissions(input, context) { // For ALL teammates, bypass the permission UI to avoid sending permission_request // The call() method handles the appropriate behavior: // - If isPlanModeRequired(): sends plan_approval_request to leader // - Otherwise: exits plan mode locally (voluntary plan mode) if (isTeammate()) { return { behavior: 'allow' as const, updatedInput: input, } } // For non-teammates, require user confirmation to exit plan mode return { behavior: 'ask' as const, message: 'Exit plan mode?', updatedInput: input, } }, renderToolUseMessage, renderToolResultMessage, renderToolUseRejectedMessage, async call(input, context) { const isAgent = !!context.agentId const filePath = getPlanFilePath(context.agentId) // CCR web UI may send an edited plan via permissionResult.updatedInput. // queryHelpers.ts full-replaces finalInput, so when CCR sends {} (no edit) // input.plan is undefined -> disk fallback. The internal inputSchema omits // `plan` (normally injected by normalizeToolInput), hence the narrowing. const inputPlan = 'plan' in input && typeof input.plan === 'string' ? input.plan : undefined const plan = inputPlan ?? getPlan(context.agentId) // Sync disk so VerifyPlanExecution / Read see the edit. Re-snapshot // after: the only other persistFileSnapshotIfRemote call (api.ts) runs // in normalizeToolInput, pre-permission — it captured the old plan. if (inputPlan !== undefined && filePath) { await writeFile(filePath, inputPlan, 'utf-8').catch(e => logError(e)) void persistFileSnapshotIfRemote() } // Check if this is a teammate that requires leader approval if (isTeammate() && isPlanModeRequired()) { // Plan is required for plan_mode_required teammates if (!plan) { throw new Error( `No plan file found at ${filePath}. Please write your plan to this file before calling ExitPlanMode.`, ) } const agentName = getAgentName() || 'unknown' const teamName = getTeamName() const requestId = generateRequestId( 'plan_approval', formatAgentId(agentName, teamName || 'default'), ) const approvalRequest = { type: 'plan_approval_request', from: agentName, timestamp: new Date().toISOString(), planFilePath: filePath, planContent: plan, requestId, } await writeToMailbox( 'team-lead', { from: agentName, text: jsonStringify(approvalRequest), timestamp: new Date().toISOString(), }, teamName, ) // Update task state to show awaiting approval (for in-process teammates) const appState = context.getAppState() const agentTaskId = findInProcessTeammateTaskId(agentName, appState) if (agentTaskId) { setAwaitingPlanApproval(agentTaskId, context.setAppState, true) } return { data: { plan, isAgent: true, filePath, awaitingLeaderApproval: true, requestId, }, } } // Note: Background verification hook is registered in REPL.tsx AFTER context clear // via registerPlanVerificationHook(). Registering here would be cleared during context clear. // Ensure mode is changed when exiting plan mode. // This handles cases where permission flow didn't set the mode // (e.g., when PermissionRequest hook auto-approves without providing updatedPermissions). const appState = context.getAppState() // Compute gate-off fallback before setAppState so we can notify the user. // Circuit breaker defense: if prePlanMode was an auto-like mode but the // gate is now off (circuit breaker or settings disable), restore to // 'default' instead. Without this, ExitPlanMode would bypass the circuit // breaker by calling setAutoModeActive(true) directly. let gateFallbackNotification: string | null = null if (feature('TRANSCRIPT_CLASSIFIER')) { const prePlanRaw = appState.toolPermissionContext.prePlanMode ?? 'default' if ( prePlanRaw === 'auto' && !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) ) { const reason = permissionSetupModule?.getAutoModeUnavailableReason() ?? 'circuit-breaker' gateFallbackNotification = permissionSetupModule?.getAutoModeUnavailableNotification(reason) ?? 'auto mode unavailable' logForDebugging( `[auto-mode gate @ ExitPlanModeV2Tool] prePlanMode=${prePlanRaw} ` + `but gate is off (reason=${reason}) — falling back to default on plan exit`, { level: 'warn' }, ) } } if (gateFallbackNotification) { context.addNotification?.({ key: 'auto-mode-gate-plan-exit-fallback', text: `plan exit → default · ${gateFallbackNotification}`, priority: 'immediate', color: 'warning', timeoutMs: 10000, }) } context.setAppState(prev => { if (prev.toolPermissionContext.mode !== 'plan') return prev setHasExitedPlanMode(true) setNeedsPlanModeExitAttachment(true) let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default' if (feature('TRANSCRIPT_CLASSIFIER')) { if ( restoreMode === 'auto' && !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) ) { restoreMode = 'default' } const finalRestoringAuto = restoreMode === 'auto' // Capture pre-restore state — isAutoModeActive() is the authoritative // signal (prePlanMode/strippedDangerousRules are stale after // transitionPlanAutoMode deactivates mid-plan). const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false autoModeStateModule?.setAutoModeActive(finalRestoringAuto) if (autoWasUsedDuringPlan && !finalRestoringAuto) { setNeedsAutoModeExitAttachment(true) } } // If restoring to a non-auto mode and permissions were stripped (either // from entering plan from auto, or from shouldPlanUseAutoMode), // restore them. If restoring to auto, keep them stripped. const restoringToAuto = restoreMode === 'auto' let baseContext = prev.toolPermissionContext if (restoringToAuto) { baseContext = permissionSetupModule?.stripDangerousPermissionsForAutoMode( baseContext, ) ?? baseContext } else if (prev.toolPermissionContext.strippedDangerousRules) { baseContext = permissionSetupModule?.restoreDangerousPermissions(baseContext) ?? baseContext } return { ...prev, toolPermissionContext: { ...baseContext, mode: restoreMode, prePlanMode: undefined, }, } }) const hasTaskTool = isAgentSwarmsEnabled() && context.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME)) return { data: { plan, isAgent, filePath, hasTaskTool: hasTaskTool || undefined, planWasEdited: inputPlan !== undefined || undefined, }, } }, mapToolResultToToolResultBlockParam( { isAgent, plan, filePath, hasTaskTool, planWasEdited, awaitingLeaderApproval, requestId, }, toolUseID, ) { // Handle teammate awaiting leader approval if (awaitingLeaderApproval) { return { type: 'tool_result', content: `Your plan has been submitted to the team lead for approval. Plan file: ${filePath} **What happens next:** 1. Wait for the team lead to review your plan 2. You will receive a message in your inbox with approval/rejection 3. If approved, you can proceed with implementation 4. If rejected, refine your plan based on the feedback **Important:** Do NOT proceed until you receive approval. Check your inbox for response. Request ID: ${requestId}`, tool_use_id: toolUseID, } } if (isAgent) { return { type: 'tool_result', content: 'User has approved the plan. There is nothing else needed from you now. Please respond with "ok"', tool_use_id: toolUseID, } } // Handle empty plan if (!plan || plan.trim() === '') { return { type: 'tool_result', content: 'User has approved exiting plan mode. You can now proceed.', tool_use_id: toolUseID, } } const teamHint = hasTaskTool ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` : '' // Always include the plan — extractApprovedPlan() in the Ultraplan CCR // flow parses the tool_result to retrieve the plan text for the local CLI. // Label edited plans so the model knows the user changed something. const planLabel = planWasEdited ? 'Approved Plan (edited by user)' : 'Approved Plan' return { type: 'tool_result', content: `User has approved your plan. You can now start coding. Start with updating your todo list if applicable Your plan has been saved to: ${filePath} You can refer back to it if needed during implementation.${teamHint} ## ${planLabel}: ${plan}`, tool_use_id: toolUseID, } }, } satisfies ToolDef)