source dump of claude code
at main 493 lines 17 kB view raw
1import { feature } from 'bun:bundle' 2import { writeFile } from 'fs/promises' 3import { z } from 'zod/v4' 4import { 5 getAllowedChannels, 6 hasExitedPlanModeInSession, 7 setHasExitedPlanMode, 8 setNeedsAutoModeExitAttachment, 9 setNeedsPlanModeExitAttachment, 10} from '../../bootstrap/state.js' 11import { logEvent } from '../../services/analytics/index.js' 12import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' 13import { 14 buildTool, 15 type Tool, 16 type ToolDef, 17 toolMatchesName, 18} from '../../Tool.js' 19import { formatAgentId, generateRequestId } from '../../utils/agentId.js' 20import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 21import { logForDebugging } from '../../utils/debug.js' 22import { 23 findInProcessTeammateTaskId, 24 setAwaitingPlanApproval, 25} from '../../utils/inProcessTeammateHelpers.js' 26import { lazySchema } from '../../utils/lazySchema.js' 27import { logError } from '../../utils/log.js' 28import { 29 getPlan, 30 getPlanFilePath, 31 persistFileSnapshotIfRemote, 32} from '../../utils/plans.js' 33import { jsonStringify } from '../../utils/slowOperations.js' 34import { 35 getAgentName, 36 getTeamName, 37 isPlanModeRequired, 38 isTeammate, 39} from '../../utils/teammate.js' 40import { writeToMailbox } from '../../utils/teammateMailbox.js' 41import { AGENT_TOOL_NAME } from '../AgentTool/constants.js' 42import { TEAM_CREATE_TOOL_NAME } from '../TeamCreateTool/constants.js' 43import { EXIT_PLAN_MODE_V2_TOOL_NAME } from './constants.js' 44import { EXIT_PLAN_MODE_V2_TOOL_PROMPT } from './prompt.js' 45import { 46 renderToolResultMessage, 47 renderToolUseMessage, 48 renderToolUseRejectedMessage, 49} from './UI.js' 50 51/* eslint-disable @typescript-eslint/no-require-imports */ 52const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') 53 ? (require('../../utils/permissions/autoModeState.js') as typeof import('../../utils/permissions/autoModeState.js')) 54 : null 55const permissionSetupModule = feature('TRANSCRIPT_CLASSIFIER') 56 ? (require('../../utils/permissions/permissionSetup.js') as typeof import('../../utils/permissions/permissionSetup.js')) 57 : null 58/* eslint-enable @typescript-eslint/no-require-imports */ 59 60/** 61 * Schema for prompt-based permission requests. 62 * Used by Claude to request semantic permissions when exiting plan mode. 63 */ 64const allowedPromptSchema = lazySchema(() => 65 z.object({ 66 tool: z.enum(['Bash']).describe('The tool this prompt applies to'), 67 prompt: z 68 .string() 69 .describe( 70 'Semantic description of the action, e.g. "run tests", "install dependencies"', 71 ), 72 }), 73) 74 75export type AllowedPrompt = z.infer<ReturnType<typeof allowedPromptSchema>> 76 77const inputSchema = lazySchema(() => 78 z 79 .strictObject({ 80 // Prompt-based permissions requested by the plan 81 allowedPrompts: z 82 .array(allowedPromptSchema()) 83 .optional() 84 .describe( 85 'Prompt-based permissions needed to implement the plan. These describe categories of actions rather than specific commands.', 86 ), 87 }) 88 .passthrough(), 89) 90type InputSchema = ReturnType<typeof inputSchema> 91 92/** 93 * SDK-facing input schema - includes fields injected by normalizeToolInput. 94 * The internal inputSchema doesn't have these fields because plan is read from disk, 95 * but the SDK/hooks see the normalized version with plan and file path included. 96 */ 97export const _sdkInputSchema = lazySchema(() => 98 inputSchema().extend({ 99 plan: z 100 .string() 101 .optional() 102 .describe('The plan content (injected by normalizeToolInput from disk)'), 103 planFilePath: z 104 .string() 105 .optional() 106 .describe('The plan file path (injected by normalizeToolInput)'), 107 }), 108) 109 110export const outputSchema = lazySchema(() => 111 z.object({ 112 plan: z 113 .string() 114 .nullable() 115 .describe('The plan that was presented to the user'), 116 isAgent: z.boolean(), 117 filePath: z 118 .string() 119 .optional() 120 .describe('The file path where the plan was saved'), 121 hasTaskTool: z 122 .boolean() 123 .optional() 124 .describe('Whether the Agent tool is available in the current context'), 125 planWasEdited: z 126 .boolean() 127 .optional() 128 .describe( 129 'True when the user edited the plan (CCR web UI or Ctrl+G); determines whether the plan is echoed back in tool_result', 130 ), 131 awaitingLeaderApproval: z 132 .boolean() 133 .optional() 134 .describe( 135 'When true, the teammate has sent a plan approval request to the team leader', 136 ), 137 requestId: z 138 .string() 139 .optional() 140 .describe('Unique identifier for the plan approval request'), 141 }), 142) 143type OutputSchema = ReturnType<typeof outputSchema> 144 145export type Output = z.infer<OutputSchema> 146 147export const ExitPlanModeV2Tool: Tool<InputSchema, Output> = buildTool({ 148 name: EXIT_PLAN_MODE_V2_TOOL_NAME, 149 searchHint: 'present plan for approval and start coding (plan mode only)', 150 maxResultSizeChars: 100_000, 151 async description() { 152 return 'Prompts the user to exit plan mode and start coding' 153 }, 154 async prompt() { 155 return EXIT_PLAN_MODE_V2_TOOL_PROMPT 156 }, 157 get inputSchema(): InputSchema { 158 return inputSchema() 159 }, 160 get outputSchema(): OutputSchema { 161 return outputSchema() 162 }, 163 userFacingName() { 164 return '' 165 }, 166 shouldDefer: true, 167 isEnabled() { 168 // When --channels is active the user is likely on Telegram/Discord, not 169 // watching the TUI. The plan-approval dialog would hang. Paired with the 170 // same gate on EnterPlanMode so plan mode isn't a trap. 171 if ( 172 (feature('KAIROS') || feature('KAIROS_CHANNELS')) && 173 getAllowedChannels().length > 0 174 ) { 175 return false 176 } 177 return true 178 }, 179 isConcurrencySafe() { 180 return true 181 }, 182 isReadOnly() { 183 return false // Now writes to disk 184 }, 185 requiresUserInteraction() { 186 // For ALL teammates, no local user interaction needed: 187 // - If isPlanModeRequired(): team lead approves via mailbox 188 // - Otherwise: exits locally without approval (voluntary plan mode) 189 if (isTeammate()) { 190 return false 191 } 192 // For non-teammates, require user confirmation to exit plan mode 193 return true 194 }, 195 async validateInput(_input, { getAppState, options }) { 196 // Teammate AppState may show leader's mode (runAgent.ts skips override in 197 // acceptEdits/bypassPermissions/auto); isPlanModeRequired() is the real source 198 if (isTeammate()) { 199 return { result: true } 200 } 201 // The deferred-tool list announces this tool regardless of mode, so the 202 // model can call it after plan approval (fresh delta on compact/clear). 203 // Reject before checkPermissions to avoid showing the approval dialog. 204 const mode = getAppState().toolPermissionContext.mode 205 if (mode !== 'plan') { 206 logEvent('tengu_exit_plan_mode_called_outside_plan', { 207 model: 208 options.mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 209 mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 210 hasExitedPlanModeInSession: hasExitedPlanModeInSession(), 211 }) 212 return { 213 result: false, 214 message: 215 '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.', 216 errorCode: 1, 217 } 218 } 219 return { result: true } 220 }, 221 async checkPermissions(input, context) { 222 // For ALL teammates, bypass the permission UI to avoid sending permission_request 223 // The call() method handles the appropriate behavior: 224 // - If isPlanModeRequired(): sends plan_approval_request to leader 225 // - Otherwise: exits plan mode locally (voluntary plan mode) 226 if (isTeammate()) { 227 return { 228 behavior: 'allow' as const, 229 updatedInput: input, 230 } 231 } 232 233 // For non-teammates, require user confirmation to exit plan mode 234 return { 235 behavior: 'ask' as const, 236 message: 'Exit plan mode?', 237 updatedInput: input, 238 } 239 }, 240 renderToolUseMessage, 241 renderToolResultMessage, 242 renderToolUseRejectedMessage, 243 async call(input, context) { 244 const isAgent = !!context.agentId 245 246 const filePath = getPlanFilePath(context.agentId) 247 // CCR web UI may send an edited plan via permissionResult.updatedInput. 248 // queryHelpers.ts full-replaces finalInput, so when CCR sends {} (no edit) 249 // input.plan is undefined -> disk fallback. The internal inputSchema omits 250 // `plan` (normally injected by normalizeToolInput), hence the narrowing. 251 const inputPlan = 252 'plan' in input && typeof input.plan === 'string' ? input.plan : undefined 253 const plan = inputPlan ?? getPlan(context.agentId) 254 255 // Sync disk so VerifyPlanExecution / Read see the edit. Re-snapshot 256 // after: the only other persistFileSnapshotIfRemote call (api.ts) runs 257 // in normalizeToolInput, pre-permission — it captured the old plan. 258 if (inputPlan !== undefined && filePath) { 259 await writeFile(filePath, inputPlan, 'utf-8').catch(e => logError(e)) 260 void persistFileSnapshotIfRemote() 261 } 262 263 // Check if this is a teammate that requires leader approval 264 if (isTeammate() && isPlanModeRequired()) { 265 // Plan is required for plan_mode_required teammates 266 if (!plan) { 267 throw new Error( 268 `No plan file found at ${filePath}. Please write your plan to this file before calling ExitPlanMode.`, 269 ) 270 } 271 const agentName = getAgentName() || 'unknown' 272 const teamName = getTeamName() 273 const requestId = generateRequestId( 274 'plan_approval', 275 formatAgentId(agentName, teamName || 'default'), 276 ) 277 278 const approvalRequest = { 279 type: 'plan_approval_request', 280 from: agentName, 281 timestamp: new Date().toISOString(), 282 planFilePath: filePath, 283 planContent: plan, 284 requestId, 285 } 286 287 await writeToMailbox( 288 'team-lead', 289 { 290 from: agentName, 291 text: jsonStringify(approvalRequest), 292 timestamp: new Date().toISOString(), 293 }, 294 teamName, 295 ) 296 297 // Update task state to show awaiting approval (for in-process teammates) 298 const appState = context.getAppState() 299 const agentTaskId = findInProcessTeammateTaskId(agentName, appState) 300 if (agentTaskId) { 301 setAwaitingPlanApproval(agentTaskId, context.setAppState, true) 302 } 303 304 return { 305 data: { 306 plan, 307 isAgent: true, 308 filePath, 309 awaitingLeaderApproval: true, 310 requestId, 311 }, 312 } 313 } 314 315 // Note: Background verification hook is registered in REPL.tsx AFTER context clear 316 // via registerPlanVerificationHook(). Registering here would be cleared during context clear. 317 318 // Ensure mode is changed when exiting plan mode. 319 // This handles cases where permission flow didn't set the mode 320 // (e.g., when PermissionRequest hook auto-approves without providing updatedPermissions). 321 const appState = context.getAppState() 322 // Compute gate-off fallback before setAppState so we can notify the user. 323 // Circuit breaker defense: if prePlanMode was an auto-like mode but the 324 // gate is now off (circuit breaker or settings disable), restore to 325 // 'default' instead. Without this, ExitPlanMode would bypass the circuit 326 // breaker by calling setAutoModeActive(true) directly. 327 let gateFallbackNotification: string | null = null 328 if (feature('TRANSCRIPT_CLASSIFIER')) { 329 const prePlanRaw = appState.toolPermissionContext.prePlanMode ?? 'default' 330 if ( 331 prePlanRaw === 'auto' && 332 !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) 333 ) { 334 const reason = 335 permissionSetupModule?.getAutoModeUnavailableReason() ?? 336 'circuit-breaker' 337 gateFallbackNotification = 338 permissionSetupModule?.getAutoModeUnavailableNotification(reason) ?? 339 'auto mode unavailable' 340 logForDebugging( 341 `[auto-mode gate @ ExitPlanModeV2Tool] prePlanMode=${prePlanRaw} ` + 342 `but gate is off (reason=${reason}) — falling back to default on plan exit`, 343 { level: 'warn' }, 344 ) 345 } 346 } 347 if (gateFallbackNotification) { 348 context.addNotification?.({ 349 key: 'auto-mode-gate-plan-exit-fallback', 350 text: `plan exit → default · ${gateFallbackNotification}`, 351 priority: 'immediate', 352 color: 'warning', 353 timeoutMs: 10000, 354 }) 355 } 356 357 context.setAppState(prev => { 358 if (prev.toolPermissionContext.mode !== 'plan') return prev 359 setHasExitedPlanMode(true) 360 setNeedsPlanModeExitAttachment(true) 361 let restoreMode = prev.toolPermissionContext.prePlanMode ?? 'default' 362 if (feature('TRANSCRIPT_CLASSIFIER')) { 363 if ( 364 restoreMode === 'auto' && 365 !(permissionSetupModule?.isAutoModeGateEnabled() ?? false) 366 ) { 367 restoreMode = 'default' 368 } 369 const finalRestoringAuto = restoreMode === 'auto' 370 // Capture pre-restore state — isAutoModeActive() is the authoritative 371 // signal (prePlanMode/strippedDangerousRules are stale after 372 // transitionPlanAutoMode deactivates mid-plan). 373 const autoWasUsedDuringPlan = 374 autoModeStateModule?.isAutoModeActive() ?? false 375 autoModeStateModule?.setAutoModeActive(finalRestoringAuto) 376 if (autoWasUsedDuringPlan && !finalRestoringAuto) { 377 setNeedsAutoModeExitAttachment(true) 378 } 379 } 380 // If restoring to a non-auto mode and permissions were stripped (either 381 // from entering plan from auto, or from shouldPlanUseAutoMode), 382 // restore them. If restoring to auto, keep them stripped. 383 const restoringToAuto = restoreMode === 'auto' 384 let baseContext = prev.toolPermissionContext 385 if (restoringToAuto) { 386 baseContext = 387 permissionSetupModule?.stripDangerousPermissionsForAutoMode( 388 baseContext, 389 ) ?? baseContext 390 } else if (prev.toolPermissionContext.strippedDangerousRules) { 391 baseContext = 392 permissionSetupModule?.restoreDangerousPermissions(baseContext) ?? 393 baseContext 394 } 395 return { 396 ...prev, 397 toolPermissionContext: { 398 ...baseContext, 399 mode: restoreMode, 400 prePlanMode: undefined, 401 }, 402 } 403 }) 404 405 const hasTaskTool = 406 isAgentSwarmsEnabled() && 407 context.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME)) 408 409 return { 410 data: { 411 plan, 412 isAgent, 413 filePath, 414 hasTaskTool: hasTaskTool || undefined, 415 planWasEdited: inputPlan !== undefined || undefined, 416 }, 417 } 418 }, 419 mapToolResultToToolResultBlockParam( 420 { 421 isAgent, 422 plan, 423 filePath, 424 hasTaskTool, 425 planWasEdited, 426 awaitingLeaderApproval, 427 requestId, 428 }, 429 toolUseID, 430 ) { 431 // Handle teammate awaiting leader approval 432 if (awaitingLeaderApproval) { 433 return { 434 type: 'tool_result', 435 content: `Your plan has been submitted to the team lead for approval. 436 437Plan file: ${filePath} 438 439**What happens next:** 4401. Wait for the team lead to review your plan 4412. You will receive a message in your inbox with approval/rejection 4423. If approved, you can proceed with implementation 4434. If rejected, refine your plan based on the feedback 444 445**Important:** Do NOT proceed until you receive approval. Check your inbox for response. 446 447Request ID: ${requestId}`, 448 tool_use_id: toolUseID, 449 } 450 } 451 452 if (isAgent) { 453 return { 454 type: 'tool_result', 455 content: 456 'User has approved the plan. There is nothing else needed from you now. Please respond with "ok"', 457 tool_use_id: toolUseID, 458 } 459 } 460 461 // Handle empty plan 462 if (!plan || plan.trim() === '') { 463 return { 464 type: 'tool_result', 465 content: 'User has approved exiting plan mode. You can now proceed.', 466 tool_use_id: toolUseID, 467 } 468 } 469 470 const teamHint = hasTaskTool 471 ? `\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.` 472 : '' 473 474 // Always include the plan — extractApprovedPlan() in the Ultraplan CCR 475 // flow parses the tool_result to retrieve the plan text for the local CLI. 476 // Label edited plans so the model knows the user changed something. 477 const planLabel = planWasEdited 478 ? 'Approved Plan (edited by user)' 479 : 'Approved Plan' 480 481 return { 482 type: 'tool_result', 483 content: `User has approved your plan. You can now start coding. Start with updating your todo list if applicable 484 485Your plan has been saved to: ${filePath} 486You can refer back to it if needed during implementation.${teamHint} 487 488## ${planLabel}: 489${plan}`, 490 tool_use_id: toolUseID, 491 } 492 }, 493} satisfies ToolDef<InputSchema, Output>)