forked from ZeenSong/claude-code
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathExitPlanModeV2Tool.ts
More file actions
493 lines (461 loc) · 16.6 KB
/
ExitPlanModeV2Tool.ts
File metadata and controls
493 lines (461 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
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<ReturnType<typeof allowedPromptSchema>>
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<typeof inputSchema>
/**
* 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<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const ExitPlanModeV2Tool: Tool<InputSchema, Output> = 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<InputSchema, Output>)