2026-02-02 22:30:03 -05:00
/ * *
* @license
* Copyright 2026 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
import {
BaseDeclarativeTool ,
BaseToolInvocation ,
type ToolResult ,
Kind ,
type ToolExitPlanModeConfirmationDetails ,
type ToolConfirmationPayload ,
type ToolExitPlanModeConfirmationPayload ,
ToolConfirmationOutcome ,
} from './tools.js' ;
import type { MessageBus } from '../confirmation-bus/message-bus.js' ;
import path from 'node:path' ;
import type { Config } from '../config/config.js' ;
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js' ;
import { validatePlanPath , validatePlanContent } from '../utils/planUtils.js' ;
import { ApprovalMode } from '../policy/types.js' ;
import { checkExhaustive } from '../utils/checks.js' ;
import { resolveToRealPath , isSubpath } from '../utils/paths.js' ;
2026-02-05 18:46:34 -05:00
import { logPlanExecution } from '../telemetry/loggers.js' ;
import { PlanExecutionEvent } from '../telemetry/types.js' ;
2026-02-02 22:30:03 -05:00
/ * *
* Returns a human - readable description for an approval mode .
* /
function getApprovalModeDescription ( mode : ApprovalMode ) : string {
switch ( mode ) {
case ApprovalMode . AUTO_EDIT :
return 'Auto-Edit mode (edits will be applied automatically)' ;
case ApprovalMode . DEFAULT :
return 'Default mode (edits will require confirmation)' ;
case ApprovalMode . YOLO :
case ApprovalMode . PLAN :
// YOLO and PLAN are not valid modes to enter when exiting plan mode
throw new Error ( ` Unexpected approval mode: ${ mode } ` ) ;
default :
checkExhaustive ( mode ) ;
}
}
export interface ExitPlanModeParams {
plan_path : string ;
}
export class ExitPlanModeTool extends BaseDeclarativeTool <
ExitPlanModeParams ,
ToolResult
> {
constructor (
private config : Config ,
messageBus : MessageBus ,
) {
2026-02-03 14:55:43 -05:00
const plansDir = config . storage . getProjectTempPlansDir ( ) ;
2026-02-02 22:30:03 -05:00
super (
EXIT_PLAN_MODE_TOOL_NAME ,
'Exit Plan Mode' ,
'Signals that the planning phase is complete and requests user approval to start implementation.' ,
Kind . Plan ,
{
type : 'object' ,
required : [ 'plan_path' ] ,
properties : {
plan_path : {
type : 'string' ,
2026-02-03 14:55:43 -05:00
description : ` The file path to the finalized plan (e.g., " ${ plansDir } /feature-x.md"). This path MUST be within the designated plans directory: ${ plansDir } / ` ,
2026-02-02 22:30:03 -05:00
} ,
} ,
} ,
messageBus ,
) ;
}
protected override validateToolParamValues (
params : ExitPlanModeParams ,
) : string | null {
if ( ! params . plan_path || params . plan_path . trim ( ) === '' ) {
return 'plan_path is required.' ;
}
// Since validateToolParamValues is synchronous, we use a basic synchronous check
// for path traversal safety. High-level async validation is deferred to shouldConfirmExecute.
const plansDir = resolveToRealPath (
this . config . storage . getProjectTempPlansDir ( ) ,
) ;
const resolvedPath = path . resolve (
this . config . getTargetDir ( ) ,
params . plan_path ,
) ;
const realPath = resolveToRealPath ( resolvedPath ) ;
if ( ! isSubpath ( plansDir , realPath ) ) {
return ` Access denied: plan path must be within the designated plans directory. ` ;
}
return null ;
}
protected createInvocation (
params : ExitPlanModeParams ,
messageBus : MessageBus ,
toolName : string ,
toolDisplayName : string ,
) : ExitPlanModeInvocation {
return new ExitPlanModeInvocation (
params ,
messageBus ,
toolName ,
toolDisplayName ,
this . config ,
) ;
}
}
export class ExitPlanModeInvocation extends BaseToolInvocation <
ExitPlanModeParams ,
ToolResult
> {
private confirmationOutcome : ToolConfirmationOutcome | null = null ;
private approvalPayload : ToolExitPlanModeConfirmationPayload | null = null ;
private planValidationError : string | null = null ;
constructor (
params : ExitPlanModeParams ,
messageBus : MessageBus ,
toolName : string ,
toolDisplayName : string ,
private config : Config ,
) {
super ( params , messageBus , toolName , toolDisplayName ) ;
}
override async shouldConfirmExecute (
abortSignal : AbortSignal ,
) : Promise < ToolExitPlanModeConfirmationDetails | false > {
const resolvedPlanPath = this . getResolvedPlanPath ( ) ;
const pathError = await validatePlanPath (
this . params . plan_path ,
this . config . storage . getProjectTempPlansDir ( ) ,
this . config . getTargetDir ( ) ,
) ;
if ( pathError ) {
this . planValidationError = pathError ;
return false ;
}
const contentError = await validatePlanContent ( resolvedPlanPath ) ;
if ( contentError ) {
this . planValidationError = contentError ;
return false ;
}
const decision = await this . getMessageBusDecision ( abortSignal ) ;
if ( decision === 'DENY' ) {
throw new Error (
` Tool execution for " ${
this . _toolDisplayName || this . _toolName
} " denied by policy . ` ,
) ;
}
if ( decision === 'ALLOW' ) {
// If policy is allow, auto-approve with default settings and execute.
this . confirmationOutcome = ToolConfirmationOutcome . ProceedOnce ;
this . approvalPayload = {
approved : true ,
approvalMode : ApprovalMode.DEFAULT ,
} ;
return false ;
}
// decision is 'ASK_USER'
return {
type : 'exit_plan_mode' ,
title : 'Plan Approval' ,
planPath : resolvedPlanPath ,
onConfirm : async (
outcome : ToolConfirmationOutcome ,
payload? : ToolConfirmationPayload ,
) = > {
this . confirmationOutcome = outcome ;
if ( payload && 'approved' in payload ) {
this . approvalPayload = payload ;
}
} ,
} ;
}
getDescription ( ) : string {
return ` Requesting plan approval for: ${ this . params . plan_path } ` ;
}
/ * *
* Returns the resolved plan path .
* Note : Validation is done in validateToolParamValues , so this assumes the path is valid .
* /
private getResolvedPlanPath ( ) : string {
return path . resolve ( this . config . getTargetDir ( ) , this . params . plan_path ) ;
}
async execute ( _signal : AbortSignal ) : Promise < ToolResult > {
const resolvedPlanPath = this . getResolvedPlanPath ( ) ;
if ( this . planValidationError ) {
return {
llmContent : this.planValidationError ,
returnDisplay : 'Error: Invalid plan' ,
} ;
}
if ( this . confirmationOutcome === ToolConfirmationOutcome . Cancel ) {
return {
llmContent :
'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.' ,
returnDisplay : 'Cancelled' ,
} ;
}
const payload = this . approvalPayload ;
if ( payload ? . approved ) {
const newMode = payload . approvalMode ? ? ApprovalMode . DEFAULT ;
this . config . setApprovalMode ( newMode ) ;
2026-02-04 12:01:43 -05:00
this . config . setApprovedPlanPath ( resolvedPlanPath ) ;
2026-02-02 22:30:03 -05:00
2026-02-05 18:46:34 -05:00
logPlanExecution ( this . config , new PlanExecutionEvent ( newMode ) ) ;
2026-02-02 22:30:03 -05:00
const description = getApprovalModeDescription ( newMode ) ;
return {
llmContent : ` Plan approved. Switching to ${ description } .
The approved implementation plan is stored at : $ { resolvedPlanPath }
Read and follow the plan strictly during implementation . ` ,
returnDisplay : ` Plan approved: ${ resolvedPlanPath } ` ,
} ;
} else {
const feedback = payload ? . feedback ? . trim ( ) ;
if ( feedback ) {
return {
llmContent : ` Plan rejected. User feedback: ${ feedback }
The plan is stored at : $ { resolvedPlanPath }
Revise the plan based on the feedback . ` ,
returnDisplay : ` Feedback: ${ feedback } ` ,
} ;
} else {
return {
llmContent : ` Plan rejected. No feedback provided.
The plan is stored at : $ { resolvedPlanPath }
Ask the user for specific feedback on how to improve the plan . ` ,
returnDisplay : 'Rejected (no feedback)' ,
} ;
}
}
}
}