494 lines
17 KiB
TypeScript
494 lines
17 KiB
TypeScript
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>)
|