init claude-code
This commit is contained in:
@@ -0,0 +1,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>)
|
||||
Reference in New Issue
Block a user