ultraplan.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { readFileSync } from 'fs';
  2. import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js';
  3. import type { Command } from '../commands.js';
  4. import { DIAMOND_OPEN } from '../constants/figures.js';
  5. import { getRemoteSessionUrl } from '../constants/product.js';
  6. import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js';
  7. import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js';
  8. import type { AppState } from '../state/AppStateStore.js';
  9. import { checkRemoteAgentEligibility, formatPreconditionError, RemoteAgentTask, type RemoteAgentTaskState, registerRemoteAgentTask } from '../tasks/RemoteAgentTask/RemoteAgentTask.js';
  10. import type { LocalJSXCommandCall } from '../types/command.js';
  11. import { logForDebugging } from '../utils/debug.js';
  12. import { errorMessage } from '../utils/errors.js';
  13. import { logError } from '../utils/log.js';
  14. import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
  15. import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js';
  16. import { updateTaskState } from '../utils/task/framework.js';
  17. import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js';
  18. import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js';
  19. // TODO(prod-hardening): OAuth token may go stale over the 30min poll;
  20. // consider refresh.
  21. // Multi-agent exploration is slow; 30min timeout.
  22. const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000;
  23. export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web';
  24. // CCR runs against the first-party API — use the canonical ID, not the
  25. // provider-specific string getModelStrings() would return (which may be a
  26. // Bedrock ARN or Vertex ID on the local CLI). Read at call time, not module
  27. // load: the GrowthBook cache is empty at import and `/config` Gates can flip
  28. // it between invocations.
  29. function getUltraplanModel(): string {
  30. return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus46.firstParty);
  31. }
  32. // prompt.txt is wrapped in <system-reminder> so the CCR browser hides
  33. // scaffolding (CLI_BLOCK_TAGS dropped by stripSystemNotifications)
  34. // while the model still sees full text.
  35. // Phrasing deliberately avoids the feature name because
  36. // the remote CCR CLI runs keyword detection on raw input before
  37. // any tag stripping, and a bare "ultraplan" in the prompt would self-trigger as
  38. // /ultraplan, which is filtered out of headless mode as "Unknown skill"
  39. //
  40. // Bundler inlines .txt as a string; the test runner wraps it as {default}.
  41. /* eslint-disable @typescript-eslint/no-require-imports */
  42. const _rawPrompt = require('../utils/ultraplan/prompt.txt');
  43. /* eslint-enable @typescript-eslint/no-require-imports */
  44. const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd();
  45. // Dev-only prompt override resolved eagerly at module load.
  46. // Gated to ant builds (USER_TYPE is a build-time define,
  47. // so the override path is DCE'd from external builds).
  48. // Shell-set env only, so top-level process.env read is fine
  49. // — settings.env never injects this.
  50. /* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
  51. const ULTRAPLAN_INSTRUCTIONS: string = ("external" as string) === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE ? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd() : DEFAULT_INSTRUCTIONS;
  52. /* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
  53. /**
  54. * Assemble the initial CCR user message. seedPlan and blurb stay outside the
  55. * system-reminder so the browser renders them; scaffolding is hidden.
  56. */
  57. export function buildUltraplanPrompt(blurb: string, seedPlan?: string): string {
  58. const parts: string[] = [];
  59. if (seedPlan) {
  60. parts.push('Here is a draft plan to refine:', '', seedPlan, '');
  61. }
  62. parts.push(ULTRAPLAN_INSTRUCTIONS);
  63. if (blurb) {
  64. parts.push('', blurb);
  65. }
  66. return parts.join('\n');
  67. }
  68. function startDetachedPoll(taskId: string, sessionId: string, url: string, getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void): void {
  69. const started = Date.now();
  70. let failed = false;
  71. void (async () => {
  72. try {
  73. const {
  74. plan,
  75. rejectCount,
  76. executionTarget
  77. } = await pollForApprovedExitPlanMode(sessionId, ULTRAPLAN_TIMEOUT_MS, phase => {
  78. if (phase === 'needs_input') logEvent('tengu_ultraplan_awaiting_input', {});
  79. updateTaskState<RemoteAgentTaskState>(taskId, setAppState, t => {
  80. if (t.status !== 'running') return t;
  81. const next = phase === 'running' ? undefined : phase;
  82. return t.ultraplanPhase === next ? t : {
  83. ...t,
  84. ultraplanPhase: next
  85. };
  86. });
  87. }, () => getAppState().tasks?.[taskId]?.status !== 'running');
  88. logEvent('tengu_ultraplan_approved', {
  89. duration_ms: Date.now() - started,
  90. plan_length: plan.length,
  91. reject_count: rejectCount,
  92. execution_target: executionTarget as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  93. });
  94. if (executionTarget === 'remote') {
  95. // User chose "execute in CCR" in the browser PlanModal — the remote
  96. // session is now coding. Skip archive (ARCHIVE has no running-check,
  97. // would kill mid-execution) and skip the choice dialog (already chose).
  98. // Guard on task status so a poll that resolves after stopUltraplan
  99. // doesn't notify for a killed session.
  100. const task = getAppState().tasks?.[taskId];
  101. if (task?.status !== 'running') return;
  102. updateTaskState<RemoteAgentTaskState>(taskId, setAppState, t => t.status !== 'running' ? t : {
  103. ...t,
  104. status: 'completed',
  105. endTime: Date.now()
  106. });
  107. setAppState(prev => prev.ultraplanSessionUrl === url ? {
  108. ...prev,
  109. ultraplanSessionUrl: undefined
  110. } : prev);
  111. enqueuePendingNotification({
  112. value: [`Ultraplan approved — executing in Claude Code on the web. Follow along at: ${url}`, '', 'Results will land as a pull request when the remote session finishes. There is nothing to do here.'].join('\n'),
  113. mode: 'task-notification'
  114. });
  115. } else {
  116. // Teleport: set pendingChoice so REPL mounts UltraplanChoiceDialog.
  117. // The dialog owns archive + URL clear on choice. Guard on task status
  118. // so a poll that resolves after stopUltraplan doesn't resurrect the
  119. // dialog for a killed session.
  120. setAppState(prev => {
  121. const task = prev.tasks?.[taskId];
  122. if (!task || task.status !== 'running') return prev;
  123. return {
  124. ...prev,
  125. ultraplanPendingChoice: {
  126. plan,
  127. sessionId,
  128. taskId
  129. }
  130. };
  131. });
  132. }
  133. } catch (e) {
  134. // If the task was stopped (stopUltraplan sets status=killed), the poll
  135. // erroring is expected — skip the failure notification and cleanup
  136. // (kill() already archived; stopUltraplan cleared the URL).
  137. const task = getAppState().tasks?.[taskId];
  138. if (task?.status !== 'running') return;
  139. failed = true;
  140. logEvent('tengu_ultraplan_failed', {
  141. duration_ms: Date.now() - started,
  142. reason: (e instanceof UltraplanPollError ? e.reason : 'network_or_unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  143. reject_count: e instanceof UltraplanPollError ? e.rejectCount : undefined
  144. });
  145. enqueuePendingNotification({
  146. value: `Ultraplan failed: ${errorMessage(e)}\n\nSession: ${url}`,
  147. mode: 'task-notification'
  148. });
  149. // Error path owns cleanup; teleport path defers to the dialog; remote
  150. // path handled its own cleanup above.
  151. void archiveRemoteSession(sessionId).catch(e => logForDebugging(`ultraplan archive failed: ${String(e)}`));
  152. setAppState(prev =>
  153. // Compare against this poll's URL so a newer relaunched session's
  154. // URL isn't cleared by a stale poll erroring out.
  155. prev.ultraplanSessionUrl === url ? {
  156. ...prev,
  157. ultraplanSessionUrl: undefined
  158. } : prev);
  159. } finally {
  160. // Remote path already set status=completed above; teleport path
  161. // leaves status=running so the pill shows the ultraplanPhase state
  162. // until UltraplanChoiceDialog completes the task after the user's
  163. // choice. Setting completed here would filter the task out of
  164. // isBackgroundTask before the pill can render the phase state.
  165. // Failure path has no dialog, so it owns the status transition here.
  166. if (failed) {
  167. updateTaskState<RemoteAgentTaskState>(taskId, setAppState, t => t.status !== 'running' ? t : {
  168. ...t,
  169. status: 'failed',
  170. endTime: Date.now()
  171. });
  172. }
  173. }
  174. })();
  175. }
  176. // Renders immediately so the terminal doesn't appear hung during the
  177. // multi-second teleportToRemote round-trip.
  178. function buildLaunchMessage(disconnectedBridge?: boolean): string {
  179. const prefix = disconnectedBridge ? `${REMOTE_CONTROL_DISCONNECTED_MSG} ` : '';
  180. return `${DIAMOND_OPEN} ultraplan\n${prefix}Starting Claude Code on the web…`;
  181. }
  182. function buildSessionReadyMessage(url: string): string {
  183. 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`;
  184. }
  185. function buildAlreadyActiveMessage(url: string | undefined): string {
  186. 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.';
  187. }
  188. /**
  189. * Stop a running ultraplan: archive the remote session (halts it but keeps the
  190. * URL viewable), kill the local task entry (clears the pill), and clear
  191. * ultraplanSessionUrl (re-arms the keyword trigger). startDetachedPoll's
  192. * shouldStop callback sees the killed status on its next tick and throws;
  193. * the catch block early-returns when status !== 'running'.
  194. */
  195. export async function stopUltraplan(taskId: string, sessionId: string, setAppState: (f: (prev: AppState) => AppState) => void): Promise<void> {
  196. // RemoteAgentTask.kill archives the session (with .catch) — no separate
  197. // archive call needed here.
  198. await RemoteAgentTask.kill(taskId, setAppState);
  199. setAppState(prev => prev.ultraplanSessionUrl || prev.ultraplanPendingChoice || prev.ultraplanLaunching ? {
  200. ...prev,
  201. ultraplanSessionUrl: undefined,
  202. ultraplanPendingChoice: undefined,
  203. ultraplanLaunching: undefined
  204. } : prev);
  205. const url = getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL);
  206. enqueuePendingNotification({
  207. value: `Ultraplan stopped.\n\nSession: ${url}`,
  208. mode: 'task-notification'
  209. });
  210. enqueuePendingNotification({
  211. value: 'The user stopped the ultraplan session above. Do not respond to the stop notification — wait for their next message.',
  212. mode: 'task-notification',
  213. isMeta: true
  214. });
  215. }
  216. /**
  217. * Shared entry for the slash command, keyword trigger, and the plan-approval
  218. * dialog's "Ultraplan" button. When seedPlan is present (dialog path), it is
  219. * prepended as a draft to refine; blurb may be empty in that case.
  220. *
  221. * Resolves immediately with the user-facing message. Eligibility check,
  222. * session creation, and task registration run detached and failures surface via
  223. * enqueuePendingNotification.
  224. */
  225. export async function launchUltraplan(opts: {
  226. blurb: string;
  227. seedPlan?: string;
  228. getAppState: () => AppState;
  229. setAppState: (f: (prev: AppState) => AppState) => void;
  230. signal: AbortSignal;
  231. /** True if the caller disconnected Remote Control before launching. */
  232. disconnectedBridge?: boolean;
  233. /**
  234. * Called once teleportToRemote resolves with a session URL. Callers that
  235. * have setMessages (REPL) append this as a second transcript message so the
  236. * URL is visible without opening the ↓ detail view. Callers without
  237. * transcript access (ExitPlanModePermissionRequest) omit this — the pill
  238. * still shows live status.
  239. */
  240. onSessionReady?: (msg: string) => void;
  241. }): Promise<string> {
  242. const {
  243. blurb,
  244. seedPlan,
  245. getAppState,
  246. setAppState,
  247. signal,
  248. disconnectedBridge,
  249. onSessionReady
  250. } = opts;
  251. const {
  252. ultraplanSessionUrl: active,
  253. ultraplanLaunching
  254. } = getAppState();
  255. if (active || ultraplanLaunching) {
  256. logEvent('tengu_ultraplan_create_failed', {
  257. reason: (active ? 'already_polling' : 'already_launching') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  258. });
  259. return buildAlreadyActiveMessage(active);
  260. }
  261. if (!blurb && !seedPlan) {
  262. // No event — bare /ultraplan is a usage query, not an attempt.
  263. return [
  264. // Rendered via <Markdown>; raw <message> is tokenized as HTML
  265. // and dropped. Backslash-escape the brackets.
  266. 'Usage: /ultraplan \\<prompt\\>, or include "ultraplan" anywhere', 'in your prompt', '', 'Advanced multi-agent plan mode with our most powerful model', '(Opus). Runs in Claude Code on the web. When the plan is ready,', 'you can execute it in the web session or send it back here.', 'Terminal stays free while the remote plans.', 'Requires /login.', '', `Terms: ${CCR_TERMS_URL}`].join('\n');
  267. }
  268. // Set synchronously before the detached flow to prevent duplicate launches
  269. // during the teleportToRemote window.
  270. setAppState(prev => prev.ultraplanLaunching ? prev : {
  271. ...prev,
  272. ultraplanLaunching: true
  273. });
  274. void launchDetached({
  275. blurb,
  276. seedPlan,
  277. getAppState,
  278. setAppState,
  279. signal,
  280. onSessionReady
  281. });
  282. return buildLaunchMessage(disconnectedBridge);
  283. }
  284. async function launchDetached(opts: {
  285. blurb: string;
  286. seedPlan?: string;
  287. getAppState: () => AppState;
  288. setAppState: (f: (prev: AppState) => AppState) => void;
  289. signal: AbortSignal;
  290. onSessionReady?: (msg: string) => void;
  291. }): Promise<void> {
  292. const {
  293. blurb,
  294. seedPlan,
  295. getAppState,
  296. setAppState,
  297. signal,
  298. onSessionReady
  299. } = opts;
  300. // Hoisted so the catch block can archive the remote session if an error
  301. // occurs after teleportToRemote succeeds (avoids 30min orphan).
  302. let sessionId: string | undefined;
  303. try {
  304. const model = getUltraplanModel();
  305. const eligibility = await checkRemoteAgentEligibility();
  306. if (!eligibility.eligible) {
  307. const eligErrors = (eligibility as { eligible: false; errors: Array<{ type: string }> }).errors;
  308. logEvent('tengu_ultraplan_create_failed', {
  309. reason: 'precondition' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  310. precondition_errors: eligErrors.map(e => e.type).join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  311. });
  312. const reasons = eligErrors.map(formatPreconditionError).join('\n');
  313. enqueuePendingNotification({
  314. value: `ultraplan: cannot launch remote session —\n${reasons}`,
  315. mode: 'task-notification'
  316. });
  317. return;
  318. }
  319. const prompt = buildUltraplanPrompt(blurb, seedPlan);
  320. let bundleFailMsg: string | undefined;
  321. const session = await teleportToRemote({
  322. initialMessage: prompt,
  323. description: blurb || 'Refine local plan',
  324. model,
  325. permissionMode: 'plan',
  326. ultraplan: true,
  327. signal,
  328. useDefaultEnvironment: true,
  329. onBundleFail: msg => {
  330. bundleFailMsg = msg;
  331. }
  332. });
  333. if (!session) {
  334. logEvent('tengu_ultraplan_create_failed', {
  335. reason: (bundleFailMsg ? 'bundle_fail' : 'teleport_null') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  336. });
  337. enqueuePendingNotification({
  338. value: `ultraplan: session creation failed${bundleFailMsg ? ` — ${bundleFailMsg}` : ''}. See --debug for details.`,
  339. mode: 'task-notification'
  340. });
  341. return;
  342. }
  343. sessionId = session.id;
  344. const url = getRemoteSessionUrl(session.id, process.env.SESSION_INGRESS_URL);
  345. setAppState(prev => ({
  346. ...prev,
  347. ultraplanSessionUrl: url,
  348. ultraplanLaunching: undefined
  349. }));
  350. onSessionReady?.(buildSessionReadyMessage(url));
  351. logEvent('tengu_ultraplan_launched', {
  352. has_seed_plan: Boolean(seedPlan),
  353. model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  354. });
  355. // TODO(#23985): replace registerRemoteAgentTask + startDetachedPoll with
  356. // ExitPlanModeScanner inside startRemoteSessionPolling.
  357. const {
  358. taskId
  359. } = registerRemoteAgentTask({
  360. remoteTaskType: 'ultraplan',
  361. session: {
  362. id: session.id,
  363. title: blurb || 'Ultraplan'
  364. },
  365. command: blurb,
  366. context: {
  367. abortController: new AbortController(),
  368. getAppState,
  369. setAppState
  370. },
  371. isUltraplan: true
  372. });
  373. startDetachedPoll(taskId, session.id, url, getAppState, setAppState);
  374. } catch (e) {
  375. logError(e);
  376. logEvent('tengu_ultraplan_create_failed', {
  377. reason: 'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  378. });
  379. enqueuePendingNotification({
  380. value: `ultraplan: unexpected error — ${errorMessage(e)}`,
  381. mode: 'task-notification'
  382. });
  383. if (sessionId) {
  384. // Error after teleport succeeded — archive so the remote doesn't sit
  385. // running for 30min with nobody polling it.
  386. void archiveRemoteSession(sessionId).catch(err => logForDebugging('ultraplan: failed to archive orphaned session', err));
  387. // ultraplanSessionUrl may have been set before the throw; clear it so
  388. // the "already polling" guard doesn't block future launches.
  389. setAppState(prev => prev.ultraplanSessionUrl ? {
  390. ...prev,
  391. ultraplanSessionUrl: undefined
  392. } : prev);
  393. }
  394. } finally {
  395. // No-op on success: the url-setting setAppState already cleared this.
  396. setAppState(prev => prev.ultraplanLaunching ? {
  397. ...prev,
  398. ultraplanLaunching: undefined
  399. } : prev);
  400. }
  401. }
  402. const call: LocalJSXCommandCall = async (onDone, context, args) => {
  403. const blurb = args.trim();
  404. // Bare /ultraplan (no args, no seed plan) just shows usage — no dialog.
  405. if (!blurb) {
  406. const msg = await launchUltraplan({
  407. blurb,
  408. getAppState: context.getAppState,
  409. setAppState: context.setAppState,
  410. signal: context.abortController.signal
  411. });
  412. onDone(msg, {
  413. display: 'system'
  414. });
  415. return null;
  416. }
  417. // Guard matches launchUltraplan's own check — showing the dialog when a
  418. // session is already active or launching would waste the user's click and set
  419. // hasSeenUltraplanTerms before the launch fails.
  420. const {
  421. ultraplanSessionUrl: active,
  422. ultraplanLaunching
  423. } = context.getAppState();
  424. if (active || ultraplanLaunching) {
  425. logEvent('tengu_ultraplan_create_failed', {
  426. reason: (active ? 'already_polling' : 'already_launching') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  427. });
  428. onDone(buildAlreadyActiveMessage(active), {
  429. display: 'system'
  430. });
  431. return null;
  432. }
  433. // Mount the pre-launch dialog via focusedInputDialog (bottom region, like
  434. // permission dialogs) rather than returning JSX (transcript area, anchors
  435. // at top of scrollback). REPL.tsx handles launch/clear/cancel on choice.
  436. context.setAppState(prev => ({
  437. ...prev,
  438. ultraplanLaunchPending: {
  439. blurb
  440. }
  441. }));
  442. // 'skip' suppresses the (no content) echo — the dialog's choice handler
  443. // adds the real /ultraplan echo + launch confirmation.
  444. onDone(undefined, {
  445. display: 'skip'
  446. });
  447. return null;
  448. };
  449. export default {
  450. type: 'local-jsx',
  451. name: 'ultraplan',
  452. description: `~10–30 min · Claude Code on the web drafts an advanced plan you can edit and approve. See ${CCR_TERMS_URL}`,
  453. argumentHint: '<prompt>',
  454. isEnabled: () => ("external" as string) === 'ant',
  455. load: () => Promise.resolve({
  456. call
  457. })
  458. } satisfies Command;