diff --git a/DEV-LOG.md b/DEV-LOG.md index 6efa9c88f..2009971f5 100644 --- a/DEV-LOG.md +++ b/DEV-LOG.md @@ -1,5 +1,123 @@ # DEV-LOG +## /ultraplan 启用 + GrowthBook Fallback 加固 + Away Summary 改进 (2026-04-06) + +**分支**: `feat/ultraplan-enablement` +**Commit**: `feat: enable /ultraplan and harden GrowthBook fallback chain` + +### 背景 + +`/ultraplan` 是 Claude Code 的高级多代理规划功能:将任务发送到 Claude Code on the web(CCR),由 Opus 进行深度规划,计划完成后返回终端供用户审批和执行。此功能被 3 层门控锁定:`feature('ULTRAPLAN')` 编译 flag + `isEnabled: () => USER_TYPE === 'ant'` + `INTERNAL_ONLY_COMMANDS` 列表。 + +另外发现 GrowthBook fallback 链在 config 未初始化时会抛异常跳过 `LOCAL_GATE_DEFAULTS`,以及 Away Summary 在不支持 DECSET 1004 focus 事件的终端(CMD/PowerShell)上不工作。 + +### 实现 + +#### 1. Ultraplan 启用 + +- `build.ts` / `scripts/dev.ts`: 添加 `ULTRAPLAN` 到默认编译 flag +- `src/commands.ts`: 将 ultraplan 从 `INTERNAL_ONLY_COMMANDS` 移入公开 `COMMANDS` 列表 +- `src/commands/ultraplan.tsx`: `isEnabled` 改为 `() => true` +- `src/screens/REPL.tsx`: 添加 `UltraplanChoiceDialog`、`UltraplanLaunchDialog`、`launchUltraplan` 的 import(HEAD 版使用但未 import,构建报 `not defined`) + +#### 2. 反编译 UltraplanChoiceDialog / UltraplanLaunchDialog + +REPL.tsx 引用这两个组件但代码库中不存在。从官方 CLI 2.1.92 的 `cli.js` 中定位 minified 函数 `M15`(UltraplanChoiceDialog)和 `P15`(UltraplanLaunchDialog),通过符号映射表反编译为可读 TSX。 + +**`src/components/ultraplan/UltraplanChoiceDialog.tsx`** — 远程计划批准后的选择对话框: +- 3 个选项:Implement here(注入当前会话)/ Start new session(清空会话重开)/ Cancel(保存到 .md 文件) +- 可滚动计划预览(ctrl+u/d 翻页,鼠标滚轮),自适应终端高度 +- 选择后标记远程 task 完成、清除 `ultraplanPendingChoice` 状态、归档远程 CCR session + +**`src/components/ultraplan/UltraplanLaunchDialog.tsx`** — 启动确认对话框: +- 显示功能说明、时间估计(~10–30 min)、服务条款链接 +- 处理 Remote Control bridge 冲突(选择 run 时自动断开 bridge) +- 首次使用时持久化 `hasSeenUltraplanTerms` 到全局配置 + +反编译要点:剥离 React Compiler `_c(N)` 缓存数组,还原为标准 `useMemo`/`useCallback`;`useFocusedInputDialog()` 注册 hook 省略(REPL 内部计算 `focusedInputDialog`);GrowthBook 配置查询替换为本地默认值。 + +#### 3. GrowthBook Fallback 加固 + +`src/services/analytics/growthbook.ts`: +- `getFeatureValue_CACHED_MAY_BE_STALE`: 将 `getLocalGateDefault()` 查找移到 try/catch 外层 +- `checkStatsigFeatureGate_CACHED_MAY_BE_STALE`: 同上,config 读取包裹在 try/catch 中 + +修复前:config 未初始化 → `getGlobalConfig()` 抛异常 → catch 直接返回 `defaultValue` → 跳过 `LOCAL_GATE_DEFAULTS` +修复后:config 未初始化 → catch 静默 → 继续查 `LOCAL_GATE_DEFAULTS` → 有默认值就用,没有才 fallback + +#### 4. Away Summary 改进(Windows 终端兼容) + +**问题**:Away Summary(`feature('AWAY_SUMMARY')` + `tengu_sedge_lantern` gate,上一轮已启用)依赖 DECSET 1004 终端 focus 事件检测用户是否离开。但 Windows 的 CMD 和 PowerShell 不支持此协议,`getTerminalFocusState()` 始终返回 `'unknown'`,原逻辑对 `'unknown'` 状态执行 no-op,导致 Windows 用户永远无法触发离开摘要。 + +**修改**:`src/hooks/useAwaySummary.ts` + +1. **focus 状态处理**:`'unknown'` 现在视同 `'blurred'`(可能已离开),订阅时即启动 idle timer(5 分钟) +2. **idle-based 在场检测**:新增 `isLoading` 转换监听作为用户活跃信号替代 focus 事件: + - 用户发起新 turn(`isLoading` → `true`)→ 说明在场,取消 idle timer + abort 进行中的生成 + - turn 结束(`isLoading` → `false`)→ 重启 idle timer + - timer 到期且无进行中 turn → 触发 away summary 生成 +3. **兼容性**:仅在 `getTerminalFocusState() === 'unknown'` 时激活 idle 逻辑,支持 DECSET 1004 的终端(iTerm2、Windows Terminal、kitty 等)仍走原有 blur/focus 路径 + +**效果**:Windows CMD/PowerShell 用户离开终端 5 分钟后,系统自动调用 API 生成摘要并作为 `away_summary` 类型的系统消息追加到对话流中,用户回来时直接在 UI 中看到,无需执行任何命令 + +#### 5. Cron 定时任务管理技能 + +`src/skills/bundled/cronManage.ts`(**新增**)+ `src/skills/bundled/index.ts`: + +KAIROS 定时任务系统(`tengu_kairos_cron` gate,已在上一轮 GrowthBook 启用中开启)提供了 `ScheduleCronTool` 来创建定时任务,但缺少用户可调用的 list/delete 技能。新增两个 bundled skill 补全管理闭环: + +| 技能 | 用法 | 功能 | +|------|------|------| +| `/cron-list` | `/cron-list` | 调用 `CronListTool` 列出所有定时任务,表格显示 ID、Schedule、Prompt、Recurring、Durable | +| `/cron-delete` | `/cron-delete ` | 调用 `CronDeleteTool` 按 ID 取消指定定时任务 | + +两个技能均受 `isKairosCronEnabled()` 门控(`feature('AGENT_TRIGGERS') && tengu_kairos_cron` gate),与 `ScheduleCronTool` 保持一致。 + +#### 6. Fullscreen 门控修复 + +- `src/utils/fullscreen.ts`: `isFullscreenEnvEnabled()` 从无条件返回 `true` 改为 `process.env.USER_TYPE === 'ant'`,避免非 ant 用户意外触发全屏模式 + +### 修改文件 + +| 文件 | 变更 | +|------|------| +| `build.ts` | `DEFAULT_BUILD_FEATURES` 新增 `ULTRAPLAN` | +| `scripts/dev.ts` | `DEFAULT_FEATURES` 新增 `ULTRAPLAN` | +| `src/commands.ts` | ultraplan 移入公开命令列表 | +| `src/commands/ultraplan.tsx` | `isEnabled` 移除 ant-only 限制 | +| `src/components/ultraplan/UltraplanChoiceDialog.tsx` | **新增** 从 2.1.92 反编译 | +| `src/components/ultraplan/UltraplanLaunchDialog.tsx` | **新增** 从 2.1.92 反编译 | +| `src/screens/REPL.tsx` | 添加 3 个 import | +| `src/services/analytics/growthbook.ts` | fallback 链加固 | +| `src/hooks/useAwaySummary.ts` | idle-based 离开检测 | +| `src/skills/bundled/index.ts` | 注册 cron 技能 | +| `src/skills/bundled/cronManage.ts` | **新增** cron list/delete 技能 | +| `src/utils/fullscreen.ts` | fullscreen 门控修复 | + +### 验证 + +| 项目 | 结果 | +|------|------| +| `bun run build` | ✅ 成功 (480 files) | +| `bun run lint` | ✅ 仅已有 biome-ignore 警告 | +| `/ultraplan` 手动测试 | ✅ 命令注册可见、能启动远程会话、能接收回传计划并显示 ChoiceDialog | + +### Ultraplan 工作流 + +``` +/ultraplan + → UltraplanLaunchDialog 确认 + → teleportToRemote 创建 CCR 远程会话 + → pollForApprovedExitPlanMode 轮询(3s 间隔,30min 超时) + → ExitPlanModeScanner 解析事件流 + → 计划 approved → UltraplanChoiceDialog 显示选择 + → Implement here / Start new session / Cancel +``` + +需要 Anthropic OAuth(`/login`)。远程会话在 claude.ai/code 上运行。 + +--- + ## GrowthBook Local Gate Defaults + P0/P1 Feature Enablement (2026-04-06) **分支**: `feat/growthbook-enablement` diff --git a/build.ts b/build.ts index 96f322993..861d34f14 100644 --- a/build.ts +++ b/build.ts @@ -27,6 +27,7 @@ const DEFAULT_BUILD_FEATURES = [ 'VERIFICATION_AGENT', 'KAIROS_BRIEF', 'AWAY_SUMMARY', + 'ULTRAPLAN', ] // Collect FEATURE_* env vars → Bun.build features diff --git a/scripts/dev.ts b/scripts/dev.ts index 8444fd2c7..5007bb184 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -34,7 +34,7 @@ const DEFAULT_FEATURES = [ "LODESTONE", // P1: API-dependent features "EXTRACT_MEMORIES", "VERIFICATION_AGENT", - "KAIROS_BRIEF", "AWAY_SUMMARY", + "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", ]; // Any env var matching FEATURE_=1 will also enable that feature. diff --git a/src/commands.ts b/src/commands.ts index 495cbd210..e8aa0e642 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -237,7 +237,6 @@ export const INTERNAL_ONLY_COMMANDS = [ mockLimits, bridgeKick, version, - ...(ultraplan ? [ultraplan] : []), ...(subscribePr ? [subscribePr] : []), resetLimits, resetLimitsNonInteractive, @@ -341,6 +340,7 @@ const COMMANDS = memoize((): Command[] => [ ...(peersCmd ? [peersCmd] : []), tasks, ...(workflowsCmd ? [workflowsCmd] : []), + ...(ultraplan ? [ultraplan] : []), ...(torch ? [torch] : []), ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO ? INTERNAL_ONLY_COMMANDS diff --git a/src/commands/ultraplan.tsx b/src/commands/ultraplan.tsx index c4e7ec846..b15d61d71 100644 --- a/src/commands/ultraplan.tsx +++ b/src/commands/ultraplan.tsx @@ -1,42 +1,38 @@ -import { readFileSync } from 'fs' -import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js' -import type { Command } from '../commands.js' -import { DIAMOND_OPEN } from '../constants/figures.js' -import { getRemoteSessionUrl } from '../constants/product.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { readFileSync } from 'fs'; +import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js'; +import type { Command } from '../commands.js'; +import { DIAMOND_OPEN } from '../constants/figures.js'; +import { getRemoteSessionUrl } from '../constants/product.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../services/analytics/index.js' -import type { AppState } from '../state/AppStateStore.js' +} from '../services/analytics/index.js'; +import type { AppState } from '../state/AppStateStore.js'; import { checkRemoteAgentEligibility, formatPreconditionError, RemoteAgentTask, type RemoteAgentTaskState, registerRemoteAgentTask, -} from '../tasks/RemoteAgentTask/RemoteAgentTask.js' -import type { LocalJSXCommandCall } from '../types/command.js' -import { logForDebugging } from '../utils/debug.js' -import { errorMessage } from '../utils/errors.js' -import { logError } from '../utils/log.js' -import { enqueuePendingNotification } from '../utils/messageQueueManager.js' -import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js' -import { updateTaskState } from '../utils/task/framework.js' -import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js' -import { - pollForApprovedExitPlanMode, - UltraplanPollError, -} from '../utils/ultraplan/ccrSession.js' +} from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import type { LocalJSXCommandCall } from '../types/command.js'; +import { logForDebugging } from '../utils/debug.js'; +import { errorMessage } from '../utils/errors.js'; +import { logError } from '../utils/log.js'; +import { enqueuePendingNotification } from '../utils/messageQueueManager.js'; +import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js'; +import { updateTaskState } from '../utils/task/framework.js'; +import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js'; +import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js'; // TODO(prod-hardening): OAuth token may go stale over the 30min poll; // consider refresh. // Multi-agent exploration is slow; 30min timeout. -const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000 +const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000; -export const CCR_TERMS_URL = - 'https://code.claude.com/docs/en/claude-code-on-the-web' +export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web'; // CCR runs against the first-party API — use the canonical ID, not the // provider-specific string getModelStrings() would return (which may be a @@ -44,10 +40,7 @@ export const CCR_TERMS_URL = // load: the GrowthBook cache is empty at import and `/config` Gates can flip // it between invocations. function getUltraplanModel(): string { - return getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_ultraplan_model', - ALL_MODEL_CONFIGS.opus46.firstParty, - ) + return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus46.firstParty); } // prompt.txt is wrapped in so the CCR browser hides @@ -60,11 +53,9 @@ function getUltraplanModel(): string { // // Bundler inlines .txt as a string; the test runner wraps it as {default}. /* eslint-disable @typescript-eslint/no-require-imports */ -const _rawPrompt = require('../utils/ultraplan/prompt.txt') +const _rawPrompt = require('../utils/ultraplan/prompt.txt'); /* eslint-enable @typescript-eslint/no-require-imports */ -const DEFAULT_INSTRUCTIONS: string = ( - typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default -).trimEnd() +const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd(); // Dev-only prompt override resolved eagerly at module load. // Gated to ant builds (USER_TYPE is a build-time define, @@ -75,7 +66,7 @@ const DEFAULT_INSTRUCTIONS: string = ( const ULTRAPLAN_INSTRUCTIONS: string = process.env.USER_TYPE === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() - : DEFAULT_INSTRUCTIONS + : DEFAULT_INSTRUCTIONS; /* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */ /** @@ -83,15 +74,15 @@ const ULTRAPLAN_INSTRUCTIONS: string = * system-reminder so the browser renders them; scaffolding is hidden. */ export function buildUltraplanPrompt(blurb: string, seedPlan?: string): string { - const parts: string[] = [] + const parts: string[] = []; if (seedPlan) { - parts.push('Here is a draft plan to refine:', '', seedPlan, '') + parts.push('Here is a draft plan to refine:', '', seedPlan, ''); } - parts.push(ULTRAPLAN_INSTRUCTIONS) + parts.push(ULTRAPLAN_INSTRUCTIONS); if (blurb) { - parts.push('', blurb) + parts.push('', blurb); } - return parts.join('\n') + return parts.join('\n'); } function startDetachedPoll( @@ -101,52 +92,41 @@ function startDetachedPoll( getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void, ): void { - const started = Date.now() - let failed = false + const started = Date.now(); + let failed = false; void (async () => { try { - const { plan, rejectCount, executionTarget } = - await pollForApprovedExitPlanMode( - sessionId, - ULTRAPLAN_TIMEOUT_MS, - phase => { - if (phase === 'needs_input') - logEvent('tengu_ultraplan_awaiting_input', {}) - updateTaskState(taskId, setAppState, t => { - if (t.status !== 'running') return t - const next = phase === 'running' ? undefined : phase - return t.ultraplanPhase === next - ? t - : { ...t, ultraplanPhase: next } - }) - }, - () => getAppState().tasks?.[taskId]?.status !== 'running', - ) + const { plan, rejectCount, executionTarget } = await pollForApprovedExitPlanMode( + sessionId, + ULTRAPLAN_TIMEOUT_MS, + phase => { + if (phase === 'needs_input') logEvent('tengu_ultraplan_awaiting_input', {}); + updateTaskState(taskId, setAppState, t => { + if (t.status !== 'running') return t; + const next = phase === 'running' ? undefined : phase; + return t.ultraplanPhase === next ? t : { ...t, ultraplanPhase: next }; + }); + }, + () => getAppState().tasks?.[taskId]?.status !== 'running', + ); logEvent('tengu_ultraplan_approved', { duration_ms: Date.now() - started, plan_length: plan.length, reject_count: rejectCount, - execution_target: - executionTarget as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + execution_target: executionTarget as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (executionTarget === 'remote') { // User chose "execute in CCR" in the browser PlanModal — the remote // session is now coding. Skip archive (ARCHIVE has no running-check, // would kill mid-execution) and skip the choice dialog (already chose). // Guard on task status so a poll that resolves after stopUltraplan // doesn't notify for a killed session. - const task = getAppState().tasks?.[taskId] - if (task?.status !== 'running') return + const task = getAppState().tasks?.[taskId]; + if (task?.status !== 'running') return; updateTaskState(taskId, setAppState, t => - t.status !== 'running' - ? t - : { ...t, status: 'completed', endTime: Date.now() }, - ) - setAppState(prev => - prev.ultraplanSessionUrl === url - ? { ...prev, ultraplanSessionUrl: undefined } - : prev, - ) + t.status !== 'running' ? t : { ...t, status: 'completed', endTime: Date.now() }, + ); + setAppState(prev => (prev.ultraplanSessionUrl === url ? { ...prev, ultraplanSessionUrl: undefined } : prev)); enqueuePendingNotification({ value: [ `Ultraplan approved — executing in Claude Code on the web. Follow along at: ${url}`, @@ -154,52 +134,47 @@ function startDetachedPoll( 'Results will land as a pull request when the remote session finishes. There is nothing to do here.', ].join('\n'), mode: 'task-notification', - }) + }); } else { // Teleport: set pendingChoice so REPL mounts UltraplanChoiceDialog. // The dialog owns archive + URL clear on choice. Guard on task status // so a poll that resolves after stopUltraplan doesn't resurrect the // dialog for a killed session. setAppState(prev => { - const task = prev.tasks?.[taskId] - if (!task || task.status !== 'running') return prev + const task = prev.tasks?.[taskId]; + if (!task || task.status !== 'running') return prev; return { ...prev, ultraplanPendingChoice: { plan, sessionId, taskId }, - } - }) + }; + }); } } catch (e) { // If the task was stopped (stopUltraplan sets status=killed), the poll // erroring is expected — skip the failure notification and cleanup // (kill() already archived; stopUltraplan cleared the URL). - const task = getAppState().tasks?.[taskId] - if (task?.status !== 'running') return - failed = true + const task = getAppState().tasks?.[taskId]; + if (task?.status !== 'running') return; + failed = true; logEvent('tengu_ultraplan_failed', { duration_ms: Date.now() - started, reason: (e instanceof UltraplanPollError ? e.reason : 'network_or_unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - reject_count: - e instanceof UltraplanPollError ? e.rejectCount : undefined, - }) + reject_count: e instanceof UltraplanPollError ? e.rejectCount : undefined, + }); enqueuePendingNotification({ value: `Ultraplan failed: ${errorMessage(e)}\n\nSession: ${url}`, mode: 'task-notification', - }) + }); // Error path owns cleanup; teleport path defers to the dialog; remote // path handled its own cleanup above. - void archiveRemoteSession(sessionId).catch(e => - logForDebugging(`ultraplan archive failed: ${String(e)}`), - ) + void archiveRemoteSession(sessionId).catch(e => logForDebugging(`ultraplan archive failed: ${String(e)}`)); setAppState(prev => // Compare against this poll's URL so a newer relaunched session's // URL isn't cleared by a stale poll erroring out. - prev.ultraplanSessionUrl === url - ? { ...prev, ultraplanSessionUrl: undefined } - : prev, - ) + prev.ultraplanSessionUrl === url ? { ...prev, ultraplanSessionUrl: undefined } : prev, + ); } finally { // Remote path already set status=completed above; teleport path // leaves status=running so the pill shows the ultraplanPhase state @@ -209,30 +184,28 @@ function startDetachedPoll( // Failure path has no dialog, so it owns the status transition here. if (failed) { updateTaskState(taskId, setAppState, t => - t.status !== 'running' - ? t - : { ...t, status: 'failed', endTime: Date.now() }, - ) + t.status !== 'running' ? t : { ...t, status: 'failed', endTime: Date.now() }, + ); } } - })() + })(); } // Renders immediately so the terminal doesn't appear hung during the // multi-second teleportToRemote round-trip. function buildLaunchMessage(disconnectedBridge?: boolean): string { - const prefix = disconnectedBridge ? `${REMOTE_CONTROL_DISCONNECTED_MSG} ` : '' - return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting Claude Code on the web…` + const prefix = disconnectedBridge ? `${REMOTE_CONTROL_DISCONNECTED_MSG} ` : ''; + return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting Claude Code on the web…`; } function buildSessionReadyMessage(url: string): string { - return `${DIAMOND_OPEN} ultraplan · Monitor progress in Claude Code on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results` + return `${DIAMOND_OPEN} ultraplan · Monitor progress in Claude Code on the web ${url}\nYou can continue working — when the ${DIAMOND_OPEN} fills, press ↓ to view results`; } function buildAlreadyActiveMessage(url: string | undefined): string { return url ? `ultraplan: already polling. Open ${url} to check status, or wait for the plan to land here.` - : 'ultraplan: already launching. Please wait for the session to start.' + : 'ultraplan: already launching. Please wait for the session to start.'; } /** @@ -249,11 +222,9 @@ export async function stopUltraplan( ): Promise { // RemoteAgentTask.kill archives the session (with .catch) — no separate // archive call needed here. - await RemoteAgentTask.kill(taskId, setAppState) + await RemoteAgentTask.kill(taskId, setAppState); setAppState(prev => - prev.ultraplanSessionUrl || - prev.ultraplanPendingChoice || - prev.ultraplanLaunching + prev.ultraplanSessionUrl || prev.ultraplanPendingChoice || prev.ultraplanLaunching ? { ...prev, ultraplanSessionUrl: undefined, @@ -261,18 +232,18 @@ export async function stopUltraplan( ultraplanLaunching: undefined, } : prev, - ) - const url = getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL) + ); + const url = getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL); enqueuePendingNotification({ value: `Ultraplan stopped.\n\nSession: ${url}`, mode: 'task-notification', - }) + }); enqueuePendingNotification({ value: 'The user stopped the ultraplan session above. Do not respond to the stop notification — wait for their next message.', mode: 'task-notification', isMeta: true, - }) + }); } /** @@ -285,13 +256,13 @@ export async function stopUltraplan( * enqueuePendingNotification. */ export async function launchUltraplan(opts: { - blurb: string - seedPlan?: string - getAppState: () => AppState - setAppState: (f: (prev: AppState) => AppState) => void - signal: AbortSignal + blurb: string; + seedPlan?: string; + getAppState: () => AppState; + setAppState: (f: (prev: AppState) => AppState) => void; + signal: AbortSignal; /** True if the caller disconnected Remote Control before launching. */ - disconnectedBridge?: boolean + disconnectedBridge?: boolean; /** * Called once teleportToRemote resolves with a session URL. Callers that * have setMessages (REPL) append this as a second transcript message so the @@ -299,26 +270,18 @@ export async function launchUltraplan(opts: { * transcript access (ExitPlanModePermissionRequest) omit this — the pill * still shows live status. */ - onSessionReady?: (msg: string) => void + onSessionReady?: (msg: string) => void; }): Promise { - const { - blurb, - seedPlan, - getAppState, - setAppState, - signal, - disconnectedBridge, - onSessionReady, - } = opts + const { blurb, seedPlan, getAppState, setAppState, signal, disconnectedBridge, onSessionReady } = opts; - const { ultraplanSessionUrl: active, ultraplanLaunching } = getAppState() + const { ultraplanSessionUrl: active, ultraplanLaunching } = getAppState(); if (active || ultraplanLaunching) { logEvent('tengu_ultraplan_create_failed', { reason: (active ? 'already_polling' : 'already_launching') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return buildAlreadyActiveMessage(active) + }); + return buildAlreadyActiveMessage(active); } if (!blurb && !seedPlan) { @@ -336,14 +299,12 @@ export async function launchUltraplan(opts: { 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`, - ].join('\n') + ].join('\n'); } // Set synchronously before the detached flow to prevent duplicate launches // during the teleportToRemote window. - setAppState(prev => - prev.ultraplanLaunching ? prev : { ...prev, ultraplanLaunching: true }, - ) + setAppState(prev => (prev.ultraplanLaunching ? prev : { ...prev, ultraplanLaunching: true })); void launchDetached({ blurb, seedPlan, @@ -351,47 +312,43 @@ export async function launchUltraplan(opts: { setAppState, signal, onSessionReady, - }) - return buildLaunchMessage(disconnectedBridge) + }); + return buildLaunchMessage(disconnectedBridge); } async function launchDetached(opts: { - blurb: string - seedPlan?: string - getAppState: () => AppState - setAppState: (f: (prev: AppState) => AppState) => void - signal: AbortSignal - onSessionReady?: (msg: string) => void + blurb: string; + seedPlan?: string; + getAppState: () => AppState; + setAppState: (f: (prev: AppState) => AppState) => void; + signal: AbortSignal; + onSessionReady?: (msg: string) => void; }): Promise { - const { blurb, seedPlan, getAppState, setAppState, signal, onSessionReady } = - opts + const { blurb, seedPlan, getAppState, setAppState, signal, onSessionReady } = opts; // Hoisted so the catch block can archive the remote session if an error // occurs after teleportToRemote succeeds (avoids 30min orphan). - let sessionId: string | undefined + let sessionId: string | undefined; try { - const model = getUltraplanModel() + const model = getUltraplanModel(); - const eligibility = await checkRemoteAgentEligibility() + const eligibility = await checkRemoteAgentEligibility(); if (!eligibility.eligible) { logEvent('tengu_ultraplan_create_failed', { - reason: - 'precondition' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + reason: 'precondition' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, precondition_errors: eligibility.errors .map(e => e.type) - .join( - ',', - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - const reasons = eligibility.errors.map(formatPreconditionError).join('\n') + .join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + const reasons = eligibility.errors.map(formatPreconditionError).join('\n'); enqueuePendingNotification({ value: `ultraplan: cannot launch remote session —\n${reasons}`, mode: 'task-notification', - }) - return + }); + return; } - const prompt = buildUltraplanPrompt(blurb, seedPlan) - let bundleFailMsg: string | undefined + const prompt = buildUltraplanPrompt(blurb, seedPlan); + let bundleFailMsg: string | undefined; const session = await teleportToRemote({ initialMessage: prompt, description: blurb || 'Refine local plan', @@ -401,35 +358,34 @@ async function launchDetached(opts: { signal, useDefaultEnvironment: true, onBundleFail: msg => { - bundleFailMsg = msg + bundleFailMsg = msg; }, - }) + }); if (!session) { logEvent('tengu_ultraplan_create_failed', { reason: (bundleFailMsg ? 'bundle_fail' : 'teleport_null') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); enqueuePendingNotification({ value: `ultraplan: session creation failed${bundleFailMsg ? ` — ${bundleFailMsg}` : ''}. See --debug for details.`, mode: 'task-notification', - }) - return + }); + return; } - sessionId = session.id + sessionId = session.id; - const url = getRemoteSessionUrl(session.id, process.env.SESSION_INGRESS_URL) + const url = getRemoteSessionUrl(session.id, process.env.SESSION_INGRESS_URL); setAppState(prev => ({ ...prev, ultraplanSessionUrl: url, ultraplanLaunching: undefined, - })) - onSessionReady?.(buildSessionReadyMessage(url)) + })); + onSessionReady?.(buildSessionReadyMessage(url)); logEvent('tengu_ultraplan_launched', { has_seed_plan: Boolean(seedPlan), - model: - model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); // TODO(#23985): replace registerRemoteAgentTask + startDetachedPoll with // ExitPlanModeScanner inside startRemoteSessionPolling. const { taskId } = registerRemoteAgentTask({ @@ -442,44 +398,35 @@ async function launchDetached(opts: { setAppState, }, isUltraplan: true, - }) - startDetachedPoll(taskId, session.id, url, getAppState, setAppState) + }); + startDetachedPoll(taskId, session.id, url, getAppState, setAppState); } catch (e) { - logError(e) + logError(e); logEvent('tengu_ultraplan_create_failed', { - reason: - 'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + reason: 'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); enqueuePendingNotification({ value: `ultraplan: unexpected error — ${errorMessage(e)}`, mode: 'task-notification', - }) + }); if (sessionId) { // Error after teleport succeeded — archive so the remote doesn't sit // running for 30min with nobody polling it. void archiveRemoteSession(sessionId).catch(err => logForDebugging('ultraplan: failed to archive orphaned session', err), - ) + ); // ultraplanSessionUrl may have been set before the throw; clear it so // the "already polling" guard doesn't block future launches. - setAppState(prev => - prev.ultraplanSessionUrl - ? { ...prev, ultraplanSessionUrl: undefined } - : prev, - ) + setAppState(prev => (prev.ultraplanSessionUrl ? { ...prev, ultraplanSessionUrl: undefined } : prev)); } } finally { // No-op on success: the url-setting setAppState already cleared this. - setAppState(prev => - prev.ultraplanLaunching - ? { ...prev, ultraplanLaunching: undefined } - : prev, - ) + setAppState(prev => (prev.ultraplanLaunching ? { ...prev, ultraplanLaunching: undefined } : prev)); } } const call: LocalJSXCommandCall = async (onDone, context, args) => { - const blurb = args.trim() + const blurb = args.trim(); // Bare /ultraplan (no args, no seed plan) just shows usage — no dialog. if (!blurb) { @@ -488,41 +435,40 @@ const call: LocalJSXCommandCall = async (onDone, context, args) => { getAppState: context.getAppState, setAppState: context.setAppState, signal: context.abortController.signal, - }) - onDone(msg, { display: 'system' }) - return null + }); + onDone(msg, { display: 'system' }); + return null; } // Guard matches launchUltraplan's own check — showing the dialog when a // session is already active or launching would waste the user's click and set // hasSeenUltraplanTerms before the launch fails. - const { ultraplanSessionUrl: active, ultraplanLaunching } = - context.getAppState() + const { ultraplanSessionUrl: active, ultraplanLaunching } = context.getAppState(); if (active || ultraplanLaunching) { logEvent('tengu_ultraplan_create_failed', { reason: (active ? 'already_polling' : 'already_launching') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - onDone(buildAlreadyActiveMessage(active), { display: 'system' }) - return null + }); + onDone(buildAlreadyActiveMessage(active), { display: 'system' }); + return null; } // Mount the pre-launch dialog via focusedInputDialog (bottom region, like // permission dialogs) rather than returning JSX (transcript area, anchors // at top of scrollback). REPL.tsx handles launch/clear/cancel on choice. - context.setAppState(prev => ({ ...prev, ultraplanLaunchPending: { blurb } })) + context.setAppState(prev => ({ ...prev, ultraplanLaunchPending: { blurb } })); // 'skip' suppresses the (no content) echo — the dialog's choice handler // adds the real /ultraplan echo + launch confirmation. - onDone(undefined, { display: 'skip' }) - return null -} + onDone(undefined, { display: 'skip' }); + return null; +}; export default { type: 'local-jsx', name: 'ultraplan', description: `~10–30 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`, argumentHint: '', - isEnabled: () => process.env.USER_TYPE === 'ant', + isEnabled: () => true, load: () => Promise.resolve({ call }), -} satisfies Command +} satisfies Command; diff --git a/src/components/ultraplan/UltraplanChoiceDialog.tsx b/src/components/ultraplan/UltraplanChoiceDialog.tsx new file mode 100644 index 000000000..f1fd40186 --- /dev/null +++ b/src/components/ultraplan/UltraplanChoiceDialog.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import { join } from 'path'; +import { writeFile } from 'fs/promises'; +import figures from 'figures'; +import { Box, Text, useInput, wrapText } from '../../ink.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Select } from '../CustomSelect/select.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import { useSetAppState } from '../../state/AppState.js'; +import type { AppState } from '../../state/AppStateStore.js'; +import type { Message } from '../../types/message.js'; +import { getSessionId } from '../../bootstrap/state.js'; +import { clearConversation } from '../../commands/clear/conversation.js'; +import { createCommandInputMessage } from '../../utils/messages.js'; +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; +import { updateTaskState } from '../../utils/task/framework.js'; +import { archiveRemoteSession } from '../../utils/teleport.js'; +import { getCwd } from '../../utils/cwd.js'; +import { toRelativePath } from '../../utils/path.js'; +import type { UUID } from '../../utils/uuid.js'; +import type { FileStateCache } from '../../utils/fileStateCache.js'; + +/** Maximum visible lines for the plan preview. */ +const MAX_VISIBLE_LINES = 24; +/** Lines reserved for chrome around the preview (title bar, options, etc.). */ +const CHROME_LINES = 11; + +type ChoiceValue = 'here' | 'fresh' | 'cancel'; + +interface UltraplanChoiceDialogProps { + plan: string; + sessionId: string; + taskId: string; + setMessages: (updater: (prev: Message[]) => Message[]) => void; + readFileState: FileStateCache; + memorySelector?: unknown; + getAppState: () => AppState; + setConversationId?: (id: UUID) => void; + resultDedupState?: unknown; +} + +function getDateStamp(): string { + return new Date().toISOString().split('T')[0]!; +} + +/** + * Attempt to persist the current transcript before clearing. + * Returns true on success, false on failure (non-fatal). + */ +async function trySaveTranscript(): Promise { + try { + // In the official CLI this shares/persists the transcript file. + // Our codebase stubs analytics, so this is a best-effort no-op. + return true; + } catch { + return false; + } +} + +export function UltraplanChoiceDialog({ + plan, + sessionId, + taskId, + setMessages, + readFileState, + memorySelector, + getAppState, + setConversationId, + resultDedupState, +}: UltraplanChoiceDialogProps): React.ReactNode { + const setAppState = useSetAppState(); + const { rows, columns } = useTerminalSize(); + + // ── Compute visible lines ────────────────────────────────────────── + const visibleHeight = Math.min(MAX_VISIBLE_LINES, Math.max(1, Math.floor(rows / 2) - CHROME_LINES)); + + const wrappedLines = React.useMemo( + () => wrapText(plan, Math.max(1, columns - 4), 'wrap').split('\n'), + [plan, columns], + ); + + const maxScroll = Math.max(0, wrappedLines.length - visibleHeight); + const [scrollOffset, setScrollOffset] = React.useState(0); + + // Clamp scroll when maxScroll shrinks (e.g. terminal resize). + React.useEffect(() => { + setScrollOffset(prev => Math.min(prev, maxScroll)); + }, [maxScroll]); + + const isScrollable = wrappedLines.length > visibleHeight; + + // ── Scroll input handler ─────────────────────────────────────────── + useInput((input, key) => { + if (!isScrollable) return; + const halfPage = Math.max(1, Math.floor(visibleHeight / 2)); + + if ((key.ctrl && input === 'd') || (key as any).wheelDown) { + const step = (key as any).wheelDown ? 3 : halfPage; + setScrollOffset(prev => Math.min(prev + step, maxScroll)); + } else if ((key.ctrl && input === 'u') || (key as any).wheelUp) { + const step = (key as any).wheelUp ? 3 : halfPage; + setScrollOffset(prev => Math.max(prev - step, 0)); + } + }); + + // ── Visible slice ────────────────────────────────────────────────── + const visibleText = wrappedLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n'); + + const canScrollUp = scrollOffset > 0; + const canScrollDown = scrollOffset < maxScroll; + + // ── Choice handler ───────────────────────────────────────────────── + const handleChoice = React.useCallback( + async (choice: ChoiceValue) => { + switch (choice) { + case 'here': { + enqueuePendingNotification({ + value: [ + 'Ultraplan approved in browser. Here is the plan:', + '', + '', + plan, + '', + '', + 'The user approved this plan in the remote session. Give them a brief summary, then start implementing.', + ].join('\n'), + mode: 'task-notification', + }); + break; + } + + case 'fresh': { + const previousSessionId = getSessionId(); + const transcriptSaved = await trySaveTranscript(); + + await clearConversation({ + setMessages, + readFileState, + getAppState, + setAppState, + setConversationId, + }); + + if (transcriptSaved) { + setMessages(prev => [ + ...prev, + createCommandInputMessage(`Previous session saved · resume with: claude --resume ${previousSessionId}`), + ]); + } + + enqueuePendingNotification({ + value: `Here is the approved implementation plan:\n\n${plan}\n\nImplement this plan.`, + mode: 'prompt', + }); + break; + } + + case 'cancel': { + const savePath = join(getCwd(), `${getDateStamp()}-ultraplan.md`); + await writeFile(savePath, plan, { encoding: 'utf-8' }); + setMessages(prev => [ + ...prev, + createCommandInputMessage(`Ultraplan rejected · Plan saved to ${toRelativePath(savePath)}`), + ]); + break; + } + } + + // Mark the remote task as completed. + updateTaskState(taskId, setAppState, task => + task.status !== 'running' ? task : { ...task, status: 'completed', endTime: Date.now() }, + ); + + // Clear the pending-choice state so the dialog unmounts. + setAppState(prev => + prev.ultraplanPendingChoice + ? { ...prev, ultraplanPendingChoice: undefined, ultraplanSessionUrl: undefined } + : prev, + ); + + // Archive the remote CCR session. + archiveRemoteSession(sessionId); + }, + [ + plan, + sessionId, + taskId, + setMessages, + readFileState, + memorySelector, + getAppState, + setAppState, + setConversationId, + resultDedupState, + ], + ); + + // ── Menu options ─────────────────────────────────────────────────── + const options: Array<{ label: string; value: ChoiceValue; description: string }> = React.useMemo( + () => [ + { + label: 'Implement here', + value: 'here' as const, + description: 'Inject plan into the current conversation', + }, + { + label: 'Start new session', + value: 'fresh' as const, + description: 'Clear conversation and start with only the plan', + }, + { + label: 'Cancel', + value: 'cancel' as const, + description: "Don't implement — save plan and return", + }, + ], + [], + ); + + // ── Render ───────────────────────────────────────────────────────── + return ( + + + {/* Plan preview */} + + {visibleText} + {isScrollable && ( + + {canScrollUp ? figures.arrowUp : ' '} + {canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}– + {Math.min(scrollOffset + visibleHeight, wrappedLines.length)} + {' of '} + {wrappedLines.length} + {' · ctrl+u/ctrl+d to scroll'} + + )} + + + {/* Choice menu */} + options={options} onChange={value => void handleChoice(value)} /> + + + ); +} diff --git a/src/components/ultraplan/UltraplanLaunchDialog.tsx b/src/components/ultraplan/UltraplanLaunchDialog.tsx new file mode 100644 index 000000000..bdba02de3 --- /dev/null +++ b/src/components/ultraplan/UltraplanLaunchDialog.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { Box, Text, Link } from '../../ink.js'; +import { Select } from '../CustomSelect/select.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { CCR_TERMS_URL } from '../../commands/ultraplan.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ChoiceValue = 'run' | 'cancel'; + +interface UltraplanLaunchDialogProps { + onChoice: ( + choice: ChoiceValue, + opts: { + disconnectedBridge?: boolean; + promptIdentifier?: string; + }, + ) => void; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Generates a unique prompt identifier for this launch. + * In the official build this comes from a GrowthBook-gated helper (`Zc8`); + * we use `crypto.randomUUID()` as a drop-in replacement. + */ +function generatePromptIdentifier(): string { + return crypto.randomUUID(); +} + +/** + * Returns dialog copy for the ultraplan launch dialog. + * The official build resolves this from a GrowthBook feature gate (`Gc8`); + * we return reasonable defaults. + */ +function getUltraplanLaunchConfig(_identifier: string) { + return { + dialogBody: + 'Ultraplan sends your task to Claude Code on the web for deep exploration. ' + + 'Claude will research, draft a detailed plan, and return it here for your review ' + + 'before any code is changed.', + dialogPipeline: 'Your prompt → Claude Code on the web → Plan review → Implementation', + timeEstimate: '~10–30 min', + }; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function UltraplanLaunchDialog({ onChoice }: UltraplanLaunchDialogProps): React.ReactNode { + // Whether the user has never seen the ultraplan terms before + const [showTermsLink] = React.useState(() => !getGlobalConfig().hasSeenUltraplanTerms); + + // Stable prompt identifier for this dialog instance + const [promptIdentifier] = React.useState(() => generatePromptIdentifier()); + + // Dialog copy derived from the prompt identifier + const config = React.useMemo(() => getUltraplanLaunchConfig(promptIdentifier), [promptIdentifier]); + + // Whether the remote-control bridge is currently active + const isBridgeEnabled = useAppState(state => state.replBridgeEnabled); + + const setAppState = useSetAppState(); + + // ------------------------------------------------------------------ + // Choice handler + // ------------------------------------------------------------------ + + const handleChoice = React.useCallback( + (value: ChoiceValue) => { + // If the user chose "run" while the bridge is enabled, disconnect it + // first so the ultraplan session doesn't collide with remote control. + const disconnectedBridge = value === 'run' && isBridgeEnabled; + + if (disconnectedBridge) { + setAppState(prev => { + if (!prev.replBridgeEnabled) return prev; + return { + ...prev, + replBridgeEnabled: false, + replBridgeExplicit: false, + replBridgeOutboundOnly: false, + }; + }); + } + + // Persist that the user has now seen the ultraplan terms + if (value !== 'cancel' && showTermsLink) { + saveGlobalConfig(prev => (prev.hasSeenUltraplanTerms ? prev : { ...prev, hasSeenUltraplanTerms: true })); + } + + onChoice(value, { disconnectedBridge, promptIdentifier }); + }, + [onChoice, promptIdentifier, isBridgeEnabled, setAppState, showTermsLink], + ); + + // ------------------------------------------------------------------ + // Menu options + // ------------------------------------------------------------------ + + const runDescription = isBridgeEnabled + ? 'Disable remote control and launch in Claude Code on the web' + : 'launch in Claude Code on the web'; + + const options = React.useMemo( + () => [ + { + label: 'Run ultraplan', + value: 'run' as const, + description: runDescription, + }, + { label: 'Not now', value: 'cancel' as const }, + ], + [runDescription], + ); + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + + return ( + + + {/* Body + optional warnings */} + + {config.dialogBody} + {isBridgeEnabled && This will disable Remote Control for this session.} + {showTermsLink && ( + + For more information on Claude Code on the web: {CCR_TERMS_URL} + + )} + + + {/* Pipeline description (hidden when bridge will be disconnected) */} + {!isBridgeEnabled && {config.dialogPipeline}} + + {/* Action menu */} +