mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge pull request #156 from amDosion/feat/ultraplan-enablement
feat: enable /ultraplan and harden GrowthBook fallback chain
This commit is contained in:
118
DEV-LOG.md
118
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 <job-id>` | 调用 `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 <prompt>
|
||||
→ 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`
|
||||
|
||||
1
build.ts
1
build.ts
@@ -27,6 +27,7 @@ const DEFAULT_BUILD_FEATURES = [
|
||||
'VERIFICATION_AGENT',
|
||||
'KAIROS_BRIEF',
|
||||
'AWAY_SUMMARY',
|
||||
'ULTRAPLAN',
|
||||
]
|
||||
|
||||
// Collect FEATURE_* env vars → Bun.build features
|
||||
|
||||
@@ -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_<NAME>=1 will also enable that feature.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <system-reminder> 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<RemoteAgentTaskState>(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<RemoteAgentTaskState>(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<RemoteAgentTaskState>(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<RemoteAgentTaskState>(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<void> {
|
||||
// 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<string> {
|
||||
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<void> {
|
||||
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: '<prompt>',
|
||||
isEnabled: () => process.env.USER_TYPE === 'ant',
|
||||
isEnabled: () => true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
} satisfies Command;
|
||||
|
||||
244
src/components/ultraplan/UltraplanChoiceDialog.tsx
Normal file
244
src/components/ultraplan/UltraplanChoiceDialog.tsx
Normal file
@@ -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<boolean> {
|
||||
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:',
|
||||
'',
|
||||
'<ultraplan>',
|
||||
plan,
|
||||
'</ultraplan>',
|
||||
'',
|
||||
'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 (
|
||||
<PermissionDialog title="Ultraplan approved" subtitle="How should the plan be implemented?">
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{/* Plan preview */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>{visibleText}</Text>
|
||||
{isScrollable && (
|
||||
<Text dimColor>
|
||||
{canScrollUp ? figures.arrowUp : ' '}
|
||||
{canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}–
|
||||
{Math.min(scrollOffset + visibleHeight, wrappedLines.length)}
|
||||
{' of '}
|
||||
{wrappedLines.length}
|
||||
{' · ctrl+u/ctrl+d to scroll'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Choice menu */}
|
||||
<Select<ChoiceValue> options={options} onChange={value => void handleChoice(value)} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
153
src/components/ultraplan/UltraplanLaunchDialog.tsx
Normal file
153
src/components/ultraplan/UltraplanLaunchDialog.tsx
Normal file
@@ -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 (
|
||||
<PermissionDialog title="Run ultraplan in the cloud?" subtitle={config.timeEstimate}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* Body + optional warnings */}
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>{config.dialogBody}</Text>
|
||||
{isBridgeEnabled && <Text dimColor>This will disable Remote Control for this session.</Text>}
|
||||
{showTermsLink && (
|
||||
<Text dimColor>
|
||||
For more information on Claude Code on the web: <Link url={CCR_TERMS_URL}>{CCR_TERMS_URL}</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Pipeline description (hidden when bridge will be disconnected) */}
|
||||
{!isBridgeEnabled && <Text dimColor>{config.dialogPipeline}</Text>}
|
||||
|
||||
{/* Action menu */}
|
||||
<Select options={options} onChange={handleChoice} />
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default UltraplanLaunchDialog;
|
||||
@@ -27,7 +27,9 @@ function hasSummarySinceLastUserTurn(messages: readonly Message[]): boolean {
|
||||
* blurred for 5 minutes. Fires only when (a) 5min since blur, (b) no turn in
|
||||
* progress, and (c) no existing away_summary since the last user message.
|
||||
*
|
||||
* Focus state 'unknown' (terminal doesn't support DECSET 1004) is a no-op.
|
||||
* For terminals that don't support DECSET 1004 focus events (CMD, PowerShell),
|
||||
* falls back to idle-based detection: starts an idle timer after each turn
|
||||
* ends, resets it when the user starts a new turn.
|
||||
*/
|
||||
export function useAwaySummary(
|
||||
messages: readonly Message[],
|
||||
@@ -91,7 +93,10 @@ export function useAwaySummary(
|
||||
|
||||
function onFocusChange(): void {
|
||||
const state = getTerminalFocusState()
|
||||
if (state === 'blurred') {
|
||||
if (state === 'blurred' || state === 'unknown') {
|
||||
// For 'unknown' terminals (CMD, PowerShell), treat mount as
|
||||
// potentially away — start idle timer. The isLoading effect
|
||||
// below resets the timer on each turn transition.
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(onBlurTimerFire, BLUR_DELAY_MS)
|
||||
} else if (state === 'focused') {
|
||||
@@ -99,7 +104,6 @@ export function useAwaySummary(
|
||||
abortInFlight()
|
||||
pendingRef.current = false
|
||||
}
|
||||
// 'unknown' → no-op
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeTerminalFocus(onFocusChange)
|
||||
@@ -115,11 +119,45 @@ export function useAwaySummary(
|
||||
}
|
||||
}, [gbEnabled, setMessages])
|
||||
|
||||
// Timer fired mid-turn → fire when turn ends (if still blurred)
|
||||
// Timer fired mid-turn → fire when turn ends (if still away)
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!pendingRef.current) return
|
||||
if (getTerminalFocusState() !== 'blurred') return
|
||||
const state = getTerminalFocusState()
|
||||
if (state !== 'blurred' && state !== 'unknown') return
|
||||
void generateRef.current?.()
|
||||
}, [isLoading])
|
||||
|
||||
// For 'unknown' terminals: use isLoading transitions as presence signal.
|
||||
// User starts a turn → they're present, cancel idle timer.
|
||||
// Turn ends → restart idle timer.
|
||||
useEffect(() => {
|
||||
if (getTerminalFocusState() !== 'unknown') return
|
||||
if (!feature('AWAY_SUMMARY')) return
|
||||
if (!gbEnabled) return
|
||||
|
||||
if (isLoading) {
|
||||
// User is actively using — cancel idle timer
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = null
|
||||
pendingRef.current = false
|
||||
} else {
|
||||
// Turn ended — restart idle timer
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
if (isLoadingRef.current) {
|
||||
pendingRef.current = true
|
||||
return
|
||||
}
|
||||
void generateRef.current?.()
|
||||
}, BLUR_DELAY_MS)
|
||||
}
|
||||
}, [isLoading, gbEnabled])
|
||||
}
|
||||
|
||||
4997
src/screens/REPL.tsx
4997
src/screens/REPL.tsx
File diff suppressed because it is too large
Load Diff
@@ -852,16 +852,15 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
|
||||
if (cached !== undefined) {
|
||||
return cached as T
|
||||
}
|
||||
// Disk cache miss — use local gate defaults before falling back to
|
||||
// the caller's defaultValue. This covers the common case where
|
||||
// GrowthBook is "enabled" (user has an API key and analytics are on)
|
||||
// but has never connected to the remote server, so both in-memory
|
||||
// and disk caches are empty.
|
||||
const localDefault = getLocalGateDefault(feature)
|
||||
return localDefault !== undefined ? (localDefault as T) : defaultValue
|
||||
} catch {
|
||||
return defaultValue
|
||||
// Config not yet initialized — fall through to local gate defaults
|
||||
}
|
||||
// Disk cache miss (or config not initialized) — use local gate defaults
|
||||
// before falling back to the caller's defaultValue. This covers:
|
||||
// 1. GrowthBook "enabled" but never connected (caches empty)
|
||||
// 2. Config not yet initialized (early in startup)
|
||||
const localDefault = getLocalGateDefault(feature)
|
||||
return localDefault !== undefined ? (localDefault as T) : defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -918,17 +917,21 @@ export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
||||
|
||||
// Return cached value immediately from disk
|
||||
// First check GrowthBook cache, then fall back to Statsig cache for migration
|
||||
const config = getGlobalConfig()
|
||||
const gbCached = config.cachedGrowthBookFeatures?.[gate]
|
||||
if (gbCached !== undefined) {
|
||||
return Boolean(gbCached)
|
||||
try {
|
||||
const config = getGlobalConfig()
|
||||
const gbCached = config.cachedGrowthBookFeatures?.[gate]
|
||||
if (gbCached !== undefined) {
|
||||
return Boolean(gbCached)
|
||||
}
|
||||
// Fallback to Statsig cache for migration period
|
||||
const statsigCached = config.cachedStatsigGates?.[gate]
|
||||
if (statsigCached !== undefined) {
|
||||
return statsigCached
|
||||
}
|
||||
} catch {
|
||||
// Config not yet initialized — fall through to local gate defaults
|
||||
}
|
||||
// Fallback to Statsig cache for migration period
|
||||
const statsigCached = config.cachedStatsigGates?.[gate]
|
||||
if (statsigCached !== undefined) {
|
||||
return statsigCached
|
||||
}
|
||||
// Neither cache has a value — use local gate defaults
|
||||
// Neither cache has a value (or config not initialized) — use local gate defaults
|
||||
const localDefault = getLocalGateDefault(gate)
|
||||
return localDefault !== undefined ? Boolean(localDefault) : false
|
||||
}
|
||||
|
||||
54
src/skills/bundled/cronManage.ts
Normal file
54
src/skills/bundled/cronManage.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
CRON_DELETE_TOOL_NAME,
|
||||
CRON_LIST_TOOL_NAME,
|
||||
isKairosCronEnabled,
|
||||
} from '../../tools/ScheduleCronTool/prompt.js'
|
||||
import { registerBundledSkill } from '../bundledSkills.js'
|
||||
|
||||
export function registerCronListSkill(): void {
|
||||
registerBundledSkill({
|
||||
name: 'cron-list',
|
||||
description: 'List all scheduled cron jobs in this session',
|
||||
whenToUse:
|
||||
'When the user wants to see their scheduled/recurring tasks, check active cron jobs, or review what is currently looping.',
|
||||
userInvocable: true,
|
||||
isEnabled: isKairosCronEnabled,
|
||||
async getPromptForCommand() {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Call ${CRON_LIST_TOOL_NAME} to list all scheduled cron jobs. Display the results in a table with columns: ID, Schedule, Prompt, Recurring, Durable. If no jobs exist, say "No scheduled tasks."`,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function registerCronDeleteSkill(): void {
|
||||
registerBundledSkill({
|
||||
name: 'cron-delete',
|
||||
description: 'Cancel a scheduled cron job by ID',
|
||||
whenToUse:
|
||||
'When the user wants to cancel, stop, or remove a scheduled/recurring task or cron job.',
|
||||
argumentHint: '<job-id>',
|
||||
userInvocable: true,
|
||||
isEnabled: isKairosCronEnabled,
|
||||
async getPromptForCommand(args) {
|
||||
const id = args.trim()
|
||||
if (!id) {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Usage: /cron-delete <job-id>\n\nProvide the job ID to cancel. Use /cron-list to see active jobs and their IDs.`,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Call ${CRON_DELETE_TOOL_NAME} with id "${id}" to cancel that scheduled job. Confirm the result to the user.`,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { registerRememberSkill } from './remember.js'
|
||||
import { registerSimplifySkill } from './simplify.js'
|
||||
import { registerSkillifySkill } from './skillify.js'
|
||||
import { registerStuckSkill } from './stuck.js'
|
||||
import { registerCronDeleteSkill, registerCronListSkill } from './cronManage.js'
|
||||
import { registerLoopSkill } from './loop.js'
|
||||
import { registerDreamSkill } from './dream.js'
|
||||
import { registerUpdateConfigSkill } from './updateConfig.js'
|
||||
@@ -35,6 +36,8 @@ export function initBundledSkills(): void {
|
||||
registerBatchSkill()
|
||||
registerStuckSkill()
|
||||
registerLoopSkill()
|
||||
registerCronListSkill()
|
||||
registerCronDeleteSkill()
|
||||
registerDreamSkill()
|
||||
if (feature('REVIEW_ARTIFACT')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
|
||||
Reference in New Issue
Block a user