ULTRAPLAN is Claude Code's remote planning mode. When you type /ultraplan
or embed the word ultraplan anywhere in a prompt, the CLI spawns a full
Claude Code session in the cloud (CCR — Claude Code on the web), running on the most
powerful available model (Opus), with a 30-minute window to iterate a plan with you via
the browser. Your local terminal stays free the entire time.
commands/ultraplan.tsx →
utils/ultraplan/ccrSession.ts →
utils/ultraplan/keyword.ts →
utils/teleport.tsx →
tasks/RemoteAgentTask/RemoteAgentTask.tsx
At the highest level ULTRAPLAN has four phases, each managed by different modules:
Trigger Detection
Keyword scanner (keyword.ts) finds "ultraplan" in freeform input or the slash command routes it directly.
CCR Session Launch
teleportToRemote() in teleport.tsx creates the remote session, uploads a git bundle, and returns a session ID.
Long-Poll
pollForApprovedExitPlanMode() in ccrSession.ts polls the event stream every 3 s for up to 30 min, tracking phase transitions.
Plan Delivery
On approval the plan lands locally via UltraplanChoiceDialog (teleport path) or stays in CCR (remote-execute path).
The diagram traces the exact code path from user keystroke to plan delivery:
Most Claude features require an explicit /command. ULTRAPLAN is unusual:
it fires from freeform text. The word ultraplan anywhere in your prompt
triggers the launch — unless context makes clear it is not a directive.
The logic lives entirely in utils/ultraplan/keyword.ts in the
findKeywordTriggerPositions() function. It builds a list of "quoted ranges"
by walking the input character-by-character, then filters word-boundary matches against
those ranges plus several additional guards:
// keyword.ts — what is filtered OUT (never triggers)
// 1. Slash-command input — /ultraplan is routed to the command handler, not here
if (text.startsWith('/')) return []
// 2. Inside paired delimiters: `backticks`, "quotes", <tags>, {braces}, [brackets], (parens)
// e.g. `src/ultraplan/foo.ts` or <ultraplan> in HTML do NOT trigger
// 3. Path / identifier context: preceded or followed by / \ -
// e.g. src/ultraplan/ or --ultraplan-mode do NOT trigger
// 4. Followed by ? — a question about the feature shouldn't invoke it
// e.g. "what is ultraplan?" does NOT trigger
// 5. Followed by . + word char (file extension)
// e.g. ultraplan.tsx does NOT trigger
The same engine handles ultrareview. Both export a typed
TriggerPosition[] so the PromptInput component can highlight the matched
word with a rainbow effect and show a tooltip "will launch ultraplan" before the user
even submits.
When the keyword is detected and the prompt forwarded to the remote session, the local prompt is rewritten so the word "ultraplan" becomes "plan" — keeping the forwarded prompt grammatical:
// keyword.ts
export function replaceUltraplanKeyword(text: string): string {
const [trigger] = findUltraplanTriggerPositions(text)
if (!trigger) return text
const before = text.slice(0, trigger.start)
const after = text.slice(trigger.end)
if (!(before + after).trim()) return ''
// Preserves user casing: "ultraplan" → "plan", "Ultraplan" → "Plan"
return before + trigger.word.slice('ultra'.length) + after
}
The "detached" pattern
launchUltraplan() returns a user-facing message immediately, before
the remote session exists. All the async work — eligibility check, Haiku title generation,
git bundle creation, POST to CCR — runs in a void async closure called
launchDetached(). The caller never awaits it.
// ultraplan.tsx — returns to the REPL in milliseconds
export async function launchUltraplan(opts): Promise<string> {
// Synchronously set ultraplanLaunching to block duplicate launches
setAppState(prev => prev.ultraplanLaunching ? prev : { ...prev, ultraplanLaunching: true })
void launchDetached({ blurb, seedPlan, getAppState, setAppState, signal, onSessionReady })
return buildLaunchMessage(disconnectedBridge)
// ↑ "◇ ultraplan\nStarting Claude Code on the web…"
}
ultraplanLaunching flag is set synchronously before the
detached flow even starts. This closes the window where two rapid keypresses could both
pass the guard check before either has called teleportToRemote(). The flag
is cleared as soon as the session URL arrives (or on any error).
Prompt construction and the system-reminder trick
The initial CCR message has a deliberate layering. The blurb and seed plan go
outside the <system-reminder> tag so the CCR browser renders
them visibly to the user. The scaffolding instructions (loaded from
utils/ultraplan/prompt.txt at build time) go inside the tag, so the remote
model sees them but the browser UI hides them. And the word "ultraplan" is deliberately
absent from the prompt text to avoid the remote CCR CLI self-triggering another session:
// ultraplan.tsx
export function buildUltraplanPrompt(blurb: string, seedPlan?: string): string {
const parts: string[] = []
if (seedPlan) {
parts.push('Here is a draft plan to refine:', '', seedPlan, '')
}
parts.push(ULTRAPLAN_INSTRUCTIONS) // from prompt.txt, wrapped in <system-reminder>
if (blurb) {
parts.push('', blurb)
}
return parts.join('\n')
}
Eligibility checks
Before spending time on network calls, checkRemoteAgentEligibility()
validates several preconditions. A failure surfaces as a notification (not a thrown
error) so the terminal doesn't crash:
// RemoteAgentTask.tsx — formatted error messages
case 'not_logged_in':
return 'Please run /login and sign in with your Claude.ai account (not Console).'
case 'no_remote_environment':
return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup'
case 'not_in_git_repo':
return 'Background tasks require a git repository.'
case 'no_git_remote':
return 'Background tasks require a GitHub remote.'
case 'github_app_not_installed':
return 'The Claude GitHub app must be installed on this repository first.'
case 'policy_blocked':
return "Remote sessions are disabled by your organization's policy."
teleportToRemote() — the CCR session factory
The heavy lifting of creating the remote session is done by teleportToRemote()
in utils/teleport.tsx. For ULTRAPLAN specifically, it is called with
permissionMode: 'plan' and ultraplan: true. This causes the
CCR session to be created in plan mode — the remote agent can only plan, not execute.
The session also gets the local git repo cloned in via a git bundle:
// ultraplan.tsx — call site
const session = await teleportToRemote({
initialMessage: prompt,
description: blurb || 'Refine local plan',
model: getUltraplanModel(), // opus4.6 from GrowthBook flag
permissionMode: 'plan',
ultraplan: true,
signal,
useDefaultEnvironment: true,
onBundleFail: msg => { bundleFailMsg = msg }
})
Inside teleportToRemote(), Claude Haiku generates a short session title and
claude/<slug> branch name from the description. Then it POSTs to
/v1/sessions with OAuth headers, the git source, and the initial message.
The returned session.id is the anchor for everything that follows.
Once the session is live, startDetachedPoll() calls
pollForApprovedExitPlanMode() — a 30-minute cursor-based event poll.
The function lives in utils/ultraplan/ccrSession.ts and is deliberately
free of side effects: it is a pure async loop that delegates all classification to
ExitPlanModeScanner.
Cursor-based pagination
Every tick calls pollRemoteSessionEvents(sessionId, cursor) which hits
GET /v1/sessions/{id}/events?after_id={cursor}, fetching up to 50 pages
of events in a single call. The cursor advances to response.lastEventId.
This means the poller never re-reads events — each tick only fetches new activity.
// ccrSession.ts
const POLL_INTERVAL_MS = 3000
const MAX_CONSECUTIVE_FAILURES = 5 // ~600 calls over 30min; tolerate transient 5xx
while (Date.now() < deadline) {
if (shouldStop?.()) throw new UltraplanPollError('poll stopped by caller', 'stopped', ...)
const resp = await pollRemoteSessionEvents(sessionId, cursor)
cursor = resp.lastEventId
const result = scanner.ingest(resp.newEvents)
// ... classify result and update phase ...
await sleep(POLL_INTERVAL_MS)
}
ExitPlanModeScanner — pure stateful classifier
The scanner accumulates event batches and classifies the session's ExitPlanMode state.
It tracks three internal collections: exitPlanCalls (tool_use IDs for
ExitPlanMode), results (tool_result blocks keyed by ID), and
rejectedIds (IDs the user rejected in the browser). On each ingest it
scans from newest to oldest to find the first non-rejected call and its resolution:
// ccrSession.ts — ScanResult kinds
// 'approved' → plan in tool_result with is_error=false, marker "## Approved Plan:"
// 'teleport' → is_error=true but contains ULTRAPLAN_TELEPORT_SENTINEL marker
// 'rejected' → is_error=true, no sentinel — user said "revise this"
// 'pending' → tool_use seen, no tool_result yet (browser showing approval dialog)
// 'terminated' → result(non-success) — remote session crashed or hit max turns
// 'unchanged' → no new relevant events
type:'result', subtype:'error_during_execution'), the approved
plan is returned — the code comment explicitly documents this precedence:
"approved > terminated > rejected > pending > unchanged".
Phase transitions and the pill badge
The poller surfaces three phases to the UI via onPhaseChange():
running
Default. Remote is executing turns. No special badge.
needs_input
Remote asked a clarifying question and is idle. Badge: "needs input". User must reply in browser.
plan_ready
ExitPlanMode tool_use exists with no tool_result. Badge: "plan ready". Browser is showing the approval dialog.
The transition to needs_input uses a careful heuristic: sessionStatus
=== 'idle' AND newEvents.length === 0. The second condition is crucial
— CCR flips to "idle" briefly between tool turns, so the poller only trusts it when there
is no activity on the same tick:
// ccrSession.ts — quiet-idle heuristic
const quietIdle =
(sessionStatus === 'idle' || sessionStatus === 'requires_action') &&
newEvents.length === 0
const phase: UltraplanPhase = scanner.hasPendingPlan
? 'plan_ready'
: quietIdle
? 'needs_input'
: 'running'
When the poll resolves, the executionTarget field determines where the plan
runs. There are exactly two outcomes:
Path A: Remote execution ("approved" in CCR)
The user clicked "Execute" inside the CCR browser PlanModal. The remote session is already in coding mode. The local CLI must NOT archive the session (archiving stops it), and must NOT show a choice dialog. It simply marks the task completed and enqueues a notification:
// ultraplan.tsx — remote execution path
if (executionTarget === 'remote') {
updateTaskState(taskId, setAppState, t => ({
...t, status: 'completed', endTime: Date.now()
}))
enqueuePendingNotification({
value: [
`Ultraplan approved — executing in Claude Code on the web. Follow along at: ${url}`,
'',
'Results will land as a pull request when the remote session finishes.',
'There is nothing to do here.'
].join('\n'),
mode: 'task-notification'
})
}
Path B: Teleport ("teleport back to terminal")
The user clicked "Teleport back to terminal" in the PlanModal browser. The browser sends
an is_error=true tool_result with the sentinel string
__ULTRAPLAN_TELEPORT_LOCAL__ as a prefix, followed by the plan text. The
scanner detects this and returns { kind: 'teleport', plan }. The poll
resolves with executionTarget: 'local'.
The local CLI then sets ultraplanPendingChoice in AppState, which causes
the REPL to mount the UltraplanChoiceDialog. The dialog owns archiving the
remote session and clearing state on user choice:
// ccrSession.ts — teleport plan extraction
export const ULTRAPLAN_TELEPORT_SENTINEL = '__ULTRAPLAN_TELEPORT_LOCAL__'
function extractTeleportPlan(content): string | null {
const text = contentToText(content)
const marker = `${ULTRAPLAN_TELEPORT_SENTINEL}\n`
const idx = text.indexOf(marker)
if (idx === -1) return null // no sentinel → normal rejection
return text.slice(idx + marker.length).trimEnd()
}
// approved path uses a different extractor
function extractApprovedPlan(content): string {
// Checks "## Approved Plan (edited by user):\n" first,
// then "## Approved Plan:\n"
}
Stopping ULTRAPLAN is coordinated: the CLI archives the remote session (which halts it
but keeps the URL viewable), kills the local task entry, and clears all related AppState
fields. The detached poll's shouldStop callback detects the killed status
on its next tick and throws a UltraplanPollError with reason
'stopped' — which the poll's catch block handles by early-returning
(no extra notification).
// ultraplan.tsx
export async function stopUltraplan(taskId, sessionId, setAppState): Promise<void> {
await RemoteAgentTask.kill(taskId, setAppState) // archives session internally
setAppState(prev => ({
...prev,
ultraplanSessionUrl: undefined,
ultraplanPendingChoice: undefined,
ultraplanLaunching: undefined
}))
// Enqueue two notifications: one for the user, one meta-instruction
// for the model so it doesn't try to respond to the stop event
enqueuePendingNotification({ value: `Ultraplan stopped.\n\nSession: ${url}`, ... })
enqueuePendingNotification({
value: 'The user stopped the ultraplan session above. Do not respond...',
mode: 'task-notification',
isMeta: true // ← model-only instruction, not shown to user
})
}
teleportToRemote() succeeds but
before the poll loop is healthy, the catch block archives the session explicitly.
Without this, the remote container would sit running for 30 minutes with no poller
watching it. The local sessionId variable is hoisted above the try block
precisely so the catch can reference it.
ULTRAPLAN sessions are registered as RemoteAgentTaskState entries in the
unified task framework. This is what drives the pill in the REPL status bar. The
isUltraplan: true flag distinguishes them from regular remote-agent tasks so
the generic poller (startRemoteSessionPolling) knows not to declare
completion on its own — ULTRAPLAN lifecycle is owned by startDetachedPoll:
// RemoteAgentTask.tsx — state shape (relevant fields)
type RemoteAgentTaskState = TaskStateBase & {
type: 'remote_agent'
remoteTaskType: RemoteTaskType // 'ultraplan' | 'ultrareview' | 'remote-agent' | ...
sessionId: string
isUltraplan?: boolean
// Scanner-derived pill badge state
ultraplanPhase?: Exclude<UltraplanPhase, 'running'> // 'needs_input' | 'plan_ready'
log: SDKMessage[] // populated by the generic poller for the detail view
todoList: TodoList
}
The session is also persisted to a sidecar file on disk via
writeRemoteAgentMetadata(). This means if you close the terminal and reopen
it with claude --resume, the ULTRAPLAN session is restored: the sidecar is
read, CCR status is fetched, and polling restarts if the session is still running.
The remote model is not hardcoded. It is read at call time from a GrowthBook feature flag, falling back to the Opus 4.6 first-party ID:
// ultraplan.tsx
function getUltraplanModel(): string {
return getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_ultraplan_model',
ALL_MODEL_CONFIGS.opus46.firstParty // fallback
)
}
Note the comment in the source: "Read at call time, not module load: the GrowthBook cache is empty at import and /config Gates can flip it between invocations." This is why the function is not a module-level constant.
There is also a dev-only prompt override: when built as an ant (internal)
build and the ULTRAPLAN_PROMPT_FILE env var points to a file, that file
replaces the bundled prompt.txt. This path is dead-code-eliminated from
external builds at compile time.
The three-phase state machine for a running ULTRAPLAN session is documented in the source comments and is worth visualising explicitly:
Deep dive: ExitPlanModeScanner ingest() logic step by step
The scanner processes event batches in two passes. First pass: it walks every event in the batch and updates three internal data structures:
- For
type:'assistant'messages — any tool_use block with nameexit_plan_mode_v2has its ID pushed toexitPlanCalls[] - For
type:'user'messages — tool_result blocks are stored inresultsmap keyed bytool_use_id - For
type:'result'with a non-success subtype — setsterminatedflag with the subtype string
Second pass (scan): iterates exitPlanCalls from newest to oldest,
skipping rejected IDs. For each candidate:
- No tool_result yet →
{ kind: 'pending' } - tool_result with
is_error=true+ sentinel →{ kind: 'teleport' } - tool_result with
is_error=true, no sentinel →{ kind: 'rejected' } - tool_result with
is_error=false→{ kind: 'approved' }
Precedence resolution: approved/teleport are returned immediately (no terminated check). Rejected IDs are added to the set and a rescan is scheduled for the next tick. Terminated takes precedence over rejected and pending but not over approved/teleport.
The rescanAfterRejection flag is a performance optimisation: when
nothing happened (no new events, no rejection last tick) the scan is skipped entirely
— the result cannot have changed.
What this lesson covered
- ULTRAPLAN offloads multi-agent planning to a remote CCR session (plan-mode only, Opus model, 30-min window) while keeping the local terminal fully free.
- The keyword scanner in
keyword.tsfilters quoted contexts, file paths, slash commands, and question marks before firing the trigger — freeform text just works. - The "detached" pattern in
launchUltraplan()returns a message immediately and runs all async work in avoidclosure — the terminal never blocks. - The
ultraplanLaunchingflag is set synchronously before any async call, closing the double-launch race window. - The poll loop uses cursor-based pagination (up to 50 pages per tick), tolerates up to 5 consecutive network failures, and drives a three-state phase machine (
running → needs_input → plan_ready). - The
ExitPlanModeScanneris a side-effect-free stateful classifier: it can be replayed offline from recorded event logs for debugging. - Two delivery paths: remote (user approves in browser, CCR executes, result lands as a PR) and teleport (user clicks "back to terminal", plan is embedded in the rejection tool_result via a sentinel string).
- Stopping clears three AppState fields, archives the remote session, and sends a
isMeta:truemodel-only notification so the local model doesn't try to respond to the stop event. - Sessions survive
--resume: metadata is persisted to a sidecar file and restored on restart if the CCR session is still live.
Knowledge Check
ultraplanLaunching set synchronously before the detached async flow starts?quietIdle heuristic require before transitioning to needs_input?