From 18ee51d450fbe9b0cfefea30228229873e52b57e Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 16 Apr 2026 00:28:41 +0900 Subject: [PATCH 01/27] feat(fork): scaffold TODO autonomous agent backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the main-process scaffolding for a new fork-local "TODO" feature that drives Claude Code autonomously toward a user-defined goal until a decisive verify command passes. This commit establishes the backend surface — schema, supervisor, and tRPC router — without any renderer work or existing-UI integration, so it can be iterated on and reviewed in isolation. Why this shape -------------- - The supervisor is pure TypeScript in the main process, not a second Claude Code. All creativity stays in one worker; "management" is deterministic code. This avoids LLM-to-LLM communication, which the research survey flagged as the biggest reliability sink for long-horizon autonomous loops. - The worker runs as interactive Claude Code inside a real PTY pane (same infra the existing Run button uses), so users can watch it live and type into it to intervene. Completion per turn is detected by idle timing on the PTY data stream; decisive success is the exit code of the user's verify command (e.g. `bun test`). LLM self-report is never trusted. - Fork-conflict surface is kept to three 1-line edits in existing files (trpc routers index, local-db schema.ts re-export, local-db schema barrel). Everything else lives in new files under new directories. What lands here --------------- - apps/desktop/plans/todo-agent-plan.md — full design doc covering goals, non-goals, architecture, execution loop, intervention UX, UI surface, fork-conflict strategy, data model, tRPC surface, phased delivery, and unresolved questions. - packages/local-db/src/schema/todo-sessions.ts — new `todo_sessions` SQLite table (workspace-scoped, status machine, budget, verdict fields, artifact path). Re-exported from schema.ts so drizzle-kit picks it up without changing the drizzle.config.ts entry. - apps/desktop/src/main/todo-agent/ - types.ts zod input schemas + shared constants - session-store.ts localDb-backed CRUD + EventEmitter fan-out, plus a worktree-path resolver for main-process callers. - supervisor.ts Singleton loop driver: prepares artifacts (`.superset/todo//goal.md`), writes the iteration prompt into the worker PTY via the workspace terminal runtime, waits for idle, runs the verify command as a detached child process, applies futility (3x same failing test) and budget (iteration count, wall-clock) guards, and settles the session to done/failed/escalated/aborted. Also exposes abort() (sends double Ctrl-C to the pane) and sendInput() passthroughs. - trpc-router.ts `todoAgent.*` router: create / list / get / attachPane / abort / sendInput + an observable-based subscribeState subscription (per trpc-electron constraint documented in apps/desktop/AGENTS.md). - index.ts Barrel. - apps/desktop/src/lib/trpc/routers/index.ts — register the new router as `todoAgent` on the app router (import + one field, clearly fork- marked). Not yet in this commit ---------------------- - Renderer UI (TodoButton, TodoModal, TodoPanel) and the PresetsBar integration point next to WorkspaceRunButton. - Drizzle migration file. Per repo policy, migrations are generated by running `bunx drizzle-kit generate` locally and never hand-written; this will be generated when the feature is wired end-to-end. - Stop-hook integration via `--settings`. v1 uses idle-detection to stay decoupled from Claude Code CLI internals. Tracked as an Unresolved item in the plan doc for v2. Verified -------- - `bun run typecheck` in apps/desktop — clean. - `tsc --noEmit` in packages/local-db — clean. Refs: apps/desktop/plans/todo-agent-plan.md --- apps/desktop/plans/todo-agent-plan.md | 285 +++++++++++++ apps/desktop/src/lib/trpc/routers/index.ts | 3 + apps/desktop/src/main/todo-agent/index.ts | 5 + .../src/main/todo-agent/session-store.ts | 111 +++++ .../desktop/src/main/todo-agent/supervisor.ts | 380 ++++++++++++++++++ .../src/main/todo-agent/trpc-router.ts | 116 ++++++ apps/desktop/src/main/todo-agent/types.ts | 54 +++ packages/local-db/src/schema/index.ts | 1 + packages/local-db/src/schema/schema.ts | 4 + packages/local-db/src/schema/todo-sessions.ts | 78 ++++ 10 files changed, 1037 insertions(+) create mode 100644 apps/desktop/plans/todo-agent-plan.md create mode 100644 apps/desktop/src/main/todo-agent/index.ts create mode 100644 apps/desktop/src/main/todo-agent/session-store.ts create mode 100644 apps/desktop/src/main/todo-agent/supervisor.ts create mode 100644 apps/desktop/src/main/todo-agent/trpc-router.ts create mode 100644 apps/desktop/src/main/todo-agent/types.ts create mode 100644 packages/local-db/src/schema/todo-sessions.ts diff --git a/apps/desktop/plans/todo-agent-plan.md b/apps/desktop/plans/todo-agent-plan.md new file mode 100644 index 00000000000..6067ae99d98 --- /dev/null +++ b/apps/desktop/plans/todo-agent-plan.md @@ -0,0 +1,285 @@ +# TODO 自律エージェント 実装計画 + +フォーク内限定の機能。ワークスペースの `Run` ボタンの左側にボタンを追加し、 +ユーザーが定義した目標が検証可能な形で達成されるまで、無人で実行を続ける +自律的な Claude Code ループを起動できるようにする。実行中のワーカー端末は +常にライブで可視化され、ユーザーは必要に応じて介入できる。 + +## 目的 + +- ユーザーは (1) 何をしてほしいか と (2) 明確なゴール + (受け入れ判定コマンド)を入力するだけでよく、その後は追加の指示なしで + システムが Claude Code を完了まで動かす。 +- ライブ可視性: 実行中ワーカーは実際の PTY であり、既存の + `TerminalPane` コンポーネントで描画されるため、誰でも監視したり + 直接入力したりできる。 +- 信頼性: 完了判定は決定的な verify コマンドの終了コードで行い、 + LLM の自己申告には依存しない。 +- 逐次実行: 同時にアクティブなのは 1 タスクのみとし、それ以外はキューに入れる。 +- upstream とのマージ容易性: 新規コードはすべて新しいファイル / ディレクトリに + 置き、既存ファイルへの変更は追記のみ、かつ 1 行変更を 3 箇所に限定する。 + +## 非目的(v1) + +- タスクの並列実行。 +- Cloud / Modal 上のサンドボックス実行 + (ローカル worktree のみを対象とする)。 +- セッションをまたいだ LLM 判定。最終判定はシェルの verify コマンドとする。 +- PR の自動作成。(v2 で対応予定) + +## アーキテクチャ + +``` +Renderer Main process +──────── ──────────── +TodoButton (PresetsBar) TodoSupervisor (singleton) + └─ TodoModal ──► trpc todo.create ──────► createSession() + ├─ writes .superset/todo//goal.md + ├─ inserts DB row (queued) + └─ returns sessionId +TodoPanel enqueue / runQueue loop + ├─ trpc todo.subscribeState ◄─────────── state observable (per session) + ├─ embeds ◄──────── (paneId assigned by renderer) + ├─ Abort / Pause buttons ├─ spawnWorker(paneId) via + └─ Intervene input ──► trpc todo.sendKey ─┘ existing terminal.write + ├─ subscribe data:${paneId} + │ (idle timer + log capture) + ├─ runVerify() (child_process) + └─ update state / next iteration +``` + +Supervisor は **メインプロセス上で動く純粋な TypeScript** であり、 +2 つ目の Claude Code インスタンスではない。これが最も重要な単純化ポイントで、 +LLM 間通信は存在せず、「管理」役は決定論的な TS コードで担い、 +創造的な処理はすべてワーカー側に集約する。 + +## 実行ループ + +各セッションは状態遷移ごとに DB へ永続化する: + +``` +queued → preparing → running → verifying → done + │ │ + │ └──► running (fail, under budget) + │ │ + │ └──► escalated (futility) + └──► aborted +``` + +各イテレーションの流れ: + +1. Supervisor はワーカー用 PTY ペインの存在を確認する + (初回は renderer が `tabs.addTerminalPane` で作成し、 + `todo.attachPane` で `paneId` を登録する)。 +2. `goal.md`、現在の `state.json`、およびリトライ時は verify 失敗ログの末尾を + もとにプロンプトを組み立てる。 +3. Supervisor はそのプロンプトを `terminal.write` 経由で PTY に書き込む。 + ワーカー側では、対話モードの `claude` が既にペイン内で待機している。 +4. Supervisor は node-pty emitter の `data:${paneId}` イベントを購読する + (メインプロセスから + `getWorkspaceRuntimeRegistry().getDefault().terminal` で直接参照可能)。 + チャンクを受け取るたびに 5 秒のアイドルタイマーをリセットする。 +5. ストリームがしきい値時間だけアイドル状態になり、かつ + ターン完了ヒューリスティックを満たしたら、Supervisor は worktree 上で + `verifyCommand` を独立した child process として実行し、 + 終了コードとログ末尾を取得する。 +6. `exit 0` の場合は状態を `done` にし、判定結果を記録して通知を送る。 +7. 非 0 の場合は futile 判定 + (同じ failing test が N 回連続、または同じ diff が 2 回連続)を行い、 + 次イテレーションへ進むか、`escalated` にするかを決める。 +8. 状態が変わるたびに Supervisor は `sessionId` をキーにした + `EventEmitter` へ通知し、それを trpc subscription 側が購読する。 + +### Stop hook ではなく idle 検知を使う理由 + +Stop hook の方がきれいだが、ワーカー起動コマンドへ +`--settings ` を差し込む必要があり、これはインストール済みの +Claude Code バイナリがそのフラグをサポートしているかに依存する。v1 では、 +Claude Code CLI の内部仕様と結合しないように idle 検知を使う。 +Stop hook 連携は v2 の拡張項目として、後述の `Unresolved` に記載する。 + +### 予算と futile ガード + +- `maxIterations`(デフォルト 10) +- `maxWallClockSec`(デフォルト 1800) +- `maxTurnsPerIteration` は強制しない + (対話モードのため)。wall-clock と iteration 上限を優先する。 +- Futility: verify が同じテスト名で 3 イテレーション連続失敗する、 + あるいは worktree diff が前回イテレーションと完全一致する場合。 +- 予算超過または futility 検知時は `escalated` とし、セッションは永続化しつつ、 + ワーカーペインはそのまま残してユーザーが引き継げるようにする。 + +## 介入 UX + +- PTY は通常のターミナルなので、`TerminalPane` を開いているユーザーは + 直接入力できる。Supervisor が入力を専有することはない。 +- `TodoPanel` でもワンクリックの `Send` 入力欄を提供し、 + ユーザーがターミナルにフォーカスを移さなくても + `terminal.write({paneId, data})` を実行できるようにする。 +- `Pause` ボタンはイテレーションスケジューラを停止するだけで、 + ワーカーの現在のターン自体は継続する。kill はしない。 +- `Abort` は PTY に `Ctrl-C`(`\x03`)を 2 回送ったうえで、 + 状態を `aborted` にする。 + +## UI サーフェス + +- **`TodoButton`**: `PresetsBar.tsx:488` の `WorkspaceRunButton` 左に置く + コンパクトなボタン。キュー中 + 実行中セッション数の小さなカウンターを表示する。 + クリックで `New TODO`、`Open panel`、最近のセッションを含むドロップダウンを開く。 +- **`TodoModal`**: フォーム項目は以下。 + - タイトル(必須) + - 説明(必須、複数行) + - ゴール / 受け入れ条件(必須、複数行) + - Verify コマンド(デフォルト: `bun test`) + - 予算: 最大イテレーション数(デフォルト 10)、 + wall-clock 分数(デフォルト 30) +- **`TodoPanel`**: 右側ドロワー。左にセッション一覧、右に詳細。 + 詳細にはゴール、フェーズ、イテレーション、残り予算、最新の判定結果、 + ワーカー用に埋め込まれた ``、および + Pause / Abort / Send コントロールを表示する。 + +## フォーク衝突面 + +### 新規ファイル(衝突リスクなし) + +``` +apps/desktop/plans/todo-agent-plan.md (this file) +apps/desktop/src/main/todo-agent/ + index.ts barrel + types.ts shared types + zod schemas + supervisor.ts singleton loop driver + session-store.ts in-memory session map + EventEmitter fan-out + worker-pty.ts thin wrapper around terminal.write / onData + verify-runner.ts child_process exec of verifyCommand + futility-detector.ts repeat-failure / diff-stall detection + prompt-builder.ts composes the claude prompt per iteration + trpc-router.ts tRPC router factory (createTodoAgentRouter) +packages/db/src/schema/todo-sessions.ts (new table) +apps/desktop/src/renderer/features/todo-agent/ + TodoButton/TodoButton.tsx + TodoButton/index.ts + TodoModal/TodoModal.tsx + TodoModal/index.ts + TodoPanel/TodoPanel.tsx + TodoPanel/index.ts + hooks/useTodoSession.ts + hooks/useTodoQueue.ts +``` + +### 変更する既存ファイル(最小限、追記のみ) + +1. `packages/db/src/schema/index.ts` + 1 行追加: `export * from "./todo-sessions";` +2. `apps/desktop/src/lib/trpc/routers/index.ts` + import 1 行 + router object に 1 行追加: + `todoAgent: createTodoAgentRouter()`. +3. `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/PresetsBar.tsx` + 既存の `` 描画直前の 1 行 + (488 行目付近)に + `` + を追加。 + +この 3 つの変更はいずれも 1 行単位で孤立しているため、 +upstream 側で多少の変更があっても衝突しにくい。 + +## データモデル + +```ts +// packages/db/src/schema/todo-sessions.ts +export const todoSessions = pgTable("todo_sessions", { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id").notNull().references(() => organizations.id), + projectId: uuid("project_id").references(() => projects.id), + workspaceId: uuid("workspace_id").notNull().references(() => workspaces.id), + createdByUserId: uuid("created_by_user_id").references(() => users.id), + + title: text().notNull(), + description: text().notNull(), + goal: text().notNull(), + verifyCommand: text("verify_command").notNull(), + + // Budget + maxIterations: integer("max_iterations").notNull().default(10), + maxWallClockSec: integer("max_wall_clock_sec").notNull().default(1800), + + // State + status: text().notNull().default("queued"), // queued|preparing|running|verifying|done|failed|escalated|aborted + phase: text(), + iteration: integer().notNull().default(0), + attachedPaneId: text("attached_pane_id"), + + // Verdict + verdictPassed: boolean("verdict_passed"), + verdictReason: text("verdict_reason"), + verdictFailingTest: text("verdict_failing_test"), + + // Artifacts + artifactPath: text("artifact_path").notNull(), // .superset/todo// + + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + startedAt: timestamp("started_at"), + completedAt: timestamp("completed_at"), +}, (table) => [ + index("todo_sessions_workspace_idx").on(table.workspaceId), + index("todo_sessions_status_idx").on(table.status), +]); + +export type InsertTodoSession = typeof todoSessions.$inferInsert; +export type SelectTodoSession = typeof todoSessions.$inferSelect; +``` + +ユーザー側で `bunx drizzle-kit generate --name="add_todo_sessions"` を実行する。 +リポジトリポリシーに従い、こちらでは実行しない。 + +## tRPC サーフェス + +``` +todoAgent.create(input) → { sessionId } +todoAgent.list(workspaceId) → SelectTodoSession[] +todoAgent.get(sessionId) → SelectTodoSession +todoAgent.attachPane(sessionId, paneId) → void +todoAgent.pause(sessionId) → void +todoAgent.resume(sessionId) → void +todoAgent.abort(sessionId) → void +todoAgent.sendInput(sessionId, data) → void (passthrough to terminal.write) +todoAgent.subscribeState(sessionId) → observable +``` + +すべての subscription は `observable` ヘルパーを使い、 +`apps/desktop/AGENTS.md` に記載された trpc-electron の制約を満たす。 + +## 段階的な提供 + +**Phase 1(このブランチ)** +- DB テーブル + migration +- 単一タスク対応・キューなし・idle 検知ループ・child_process による verify を備えた + Supervisor の骨組み +- ライブペイン埋め込み付きの `TodoButton` + `TodoModal` + `TodoPanel` +- Pause / Abort / Send Input + +**Phase 2** +- キュー + (複数セッションの逐次実行) +- Futility 検知の強化 +- `--settings` を使った Stop hook 連携の任意対応 +- Issue URL の自動取り込み + (`gh issue view` → ゴールの事前入力) + +**Phase 3** +- `done` 時の PR draft 自動作成 +- 通知 +- 追加 worktree による並列実行 + +## 未解決事項 + +- インストール済みの Claude Code バイナリが、セッション単位の hook 注入用に + `--settings ` フラグをサポートしているかどうか。 + Phase 2 の確認項目とする。 +- `verifyCommand` をワーカー PTY 内で実行するべきか、 + 別 child process で実行するべきか。現行案では、 + verify 出力でユーザーに見えるターミナルを汚さないため、 + 別 child process を使う。verify 出力をインラインで見たい要望が強ければ再検討する。 +- クラウドワークスペース実行時に、artifact + (`.superset/todo//`)をどこへ永続化するか。 + v1 ではローカル限定のため対象外。 diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0911ffbb6e2..3370dd7be7a 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -17,6 +17,8 @@ import { createDockerRouter } from "./docker"; import { createExtensionsRouter } from "./extensions"; import { createExternalRouter } from "./external"; import { createFilesystemRouter } from "./filesystem"; +// Fork-local: TODO autonomous agent feature. +import { createTodoAgentRouter } from "main/todo-agent"; import { createGitHubMetricsRouter } from "./github-metrics"; import { createHostServiceCoordinatorRouter } from "./host-service-coordinator"; import { createLanguageServicesRouter } from "./language-services"; @@ -77,6 +79,7 @@ export const createAppRouter = ( tabTearoff: createTabTearoffRouter(wm), extensions: createExtensionsRouter(getWindow), vscodeExtensions: createVscodeExtensionsRouter(), + todoAgent: createTodoAgentRouter(), }); }; diff --git a/apps/desktop/src/main/todo-agent/index.ts b/apps/desktop/src/main/todo-agent/index.ts new file mode 100644 index 00000000000..2fec79add90 --- /dev/null +++ b/apps/desktop/src/main/todo-agent/index.ts @@ -0,0 +1,5 @@ +export { createTodoAgentRouter } from "./trpc-router"; +export type { TodoAgentRouter } from "./trpc-router"; +export { getTodoSupervisor } from "./supervisor"; +export { getTodoSessionStore } from "./session-store"; +export * from "./types"; diff --git a/apps/desktop/src/main/todo-agent/session-store.ts b/apps/desktop/src/main/todo-agent/session-store.ts new file mode 100644 index 00000000000..7f9c3e607c7 --- /dev/null +++ b/apps/desktop/src/main/todo-agent/session-store.ts @@ -0,0 +1,111 @@ +import { EventEmitter } from "node:events"; +import { + type SelectTodoSession, + todoSessions, + workspaces, + worktrees, +} from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import type { TodoSessionStateEvent } from "./types"; + +/** + * In-memory session bookkeeping + persistence helpers for the TODO agent. + * + * All state transitions go through `updateSession` so we have exactly one + * place that writes to the DB and emits the state event consumed by the + * tRPC subscription. + */ +class TodoSessionStore { + private readonly emitter = new EventEmitter(); + + constructor() { + this.emitter.setMaxListeners(0); + } + + insert(row: Omit & { + id?: string; + }): SelectTodoSession { + const inserted = localDb + .insert(todoSessions) + .values(row) + .returning() + .get(); + this.emit(inserted); + return inserted; + } + + get(sessionId: string): SelectTodoSession | undefined { + return localDb + .select() + .from(todoSessions) + .where(eq(todoSessions.id, sessionId)) + .get(); + } + + listForWorkspace(workspaceId: string): SelectTodoSession[] { + return localDb + .select() + .from(todoSessions) + .where(eq(todoSessions.workspaceId, workspaceId)) + .all(); + } + + update( + sessionId: string, + patch: Partial, + ): SelectTodoSession | undefined { + const next = { + ...patch, + updatedAt: Date.now(), + }; + const updated = localDb + .update(todoSessions) + .set(next) + .where(eq(todoSessions.id, sessionId)) + .returning() + .get(); + if (updated) this.emit(updated); + return updated; + } + + subscribe( + sessionId: string, + handler: (event: TodoSessionStateEvent) => void, + ): () => void { + const key = `session:${sessionId}`; + this.emitter.on(key, handler); + return () => { + this.emitter.off(key, handler); + }; + } + + private emit(session: SelectTodoSession): void { + const event: TodoSessionStateEvent = { + sessionId: session.id, + session, + }; + this.emitter.emit(`session:${session.id}`, event); + } +} + +let singleton: TodoSessionStore | undefined; + +export function getTodoSessionStore(): TodoSessionStore { + if (!singleton) singleton = new TodoSessionStore(); + return singleton; +} + +/** + * Resolve the absolute worktree path for a workspace. Returns undefined if + * the workspace is branch-typed (no worktree) or does not exist. + */ +export function resolveWorktreePath(workspaceId: string): string | undefined { + const row = localDb + .select({ path: worktrees.path }) + .from(workspaces) + .leftJoin(worktrees, eq(worktrees.id, workspaces.worktreeId)) + .where(eq(workspaces.id, workspaceId)) + .get(); + return row?.path ?? undefined; +} diff --git a/apps/desktop/src/main/todo-agent/supervisor.ts b/apps/desktop/src/main/todo-agent/supervisor.ts new file mode 100644 index 00000000000..de4cb13c530 --- /dev/null +++ b/apps/desktop/src/main/todo-agent/supervisor.ts @@ -0,0 +1,380 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { SelectTodoSession } from "@superset/local-db"; +import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; +import { + DEFAULT_IDLE_WINDOW_MS, + MIN_IDLE_BEFORE_VERIFY_MS, + TODO_ARTIFACT_SUBDIR, +} from "./types"; +import { getTodoSessionStore, resolveWorktreePath } from "./session-store"; + +interface ActiveRun { + sessionId: string; + abortController: AbortController; + lastFailingTest?: string; + consecutiveSameFailure: number; + lastDiffHash?: string; + consecutiveSameDiff: number; + startedAt: number; +} + +/** + * Singleton TODO Supervisor. + * + * Responsibilities + * - Accept a session and drive it to a terminal verdict (done/failed/escalated/aborted). + * - Compose per-iteration prompts and write them to the worker's PTY via + * the workspace terminal runtime. + * - Detect turn completion via PTY idle timing. + * - Run the user-defined verify command after each turn. + * - Apply budget + futility guards. + * + * The supervisor does NOT create the terminal pane itself; the renderer + * creates it and passes the paneId via `todo.attachPane`. This is because + * panes are a client-side (Zustand) concept in this codebase. + */ +class TodoSupervisor { + private active: ActiveRun | undefined; + private readonly queue: string[] = []; + + /** + * Ensure the session's artifact directory exists and `goal.md` is + * written. Called at session creation. + */ + prepareArtifacts(session: SelectTodoSession): string { + const worktreePath = resolveWorktreePath(session.workspaceId); + if (!worktreePath) { + throw new Error( + `todo-agent: no worktree path for workspace ${session.workspaceId}`, + ); + } + const dir = path.join( + worktreePath, + TODO_ARTIFACT_SUBDIR, + session.id, + ); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, "goal.md"), + renderGoalDoc(session), + "utf8", + ); + return dir; + } + + /** + * Called by trpc `todo.attachPane` after the renderer has created a + * terminal pane, launched an interactive `claude` inside it, and is + * ready for the supervisor to take over the input side. + */ + async attachAndStart(sessionId: string): Promise { + if (this.active) { + // Already running something else; queue it. + if (!this.queue.includes(sessionId)) this.queue.push(sessionId); + return; + } + await this.runSession(sessionId); + // After the run settles, drain the queue. + while (this.queue.length > 0) { + const next = this.queue.shift(); + if (next) await this.runSession(next); + } + } + + abort(sessionId: string): void { + if (this.active?.sessionId === sessionId) { + this.active.abortController.abort(); + } + const store = getTodoSessionStore(); + const session = store.get(sessionId); + if (!session) return; + // Try to interrupt the running claude in the pane. + if (session.attachedPaneId) { + this.writeToPane(session.attachedPaneId, "\x03\x03"); + } + store.update(sessionId, { + status: "aborted", + completedAt: Date.now(), + }); + } + + sendInput(sessionId: string, data: string): void { + const store = getTodoSessionStore(); + const session = store.get(sessionId); + if (!session?.attachedPaneId) return; + this.writeToPane(session.attachedPaneId, data); + } + + // ---- internals ---- + + private async runSession(sessionId: string): Promise { + const store = getTodoSessionStore(); + const session0 = store.get(sessionId); + if (!session0) return; + if (!session0.attachedPaneId) { + store.update(sessionId, { + status: "failed", + verdictReason: "No pane attached to session", + completedAt: Date.now(), + }); + return; + } + + const ac = new AbortController(); + const run: ActiveRun = { + sessionId, + abortController: ac, + consecutiveSameFailure: 0, + consecutiveSameDiff: 0, + startedAt: Date.now(), + }; + this.active = run; + + try { + store.update(sessionId, { + status: "running", + startedAt: Date.now(), + }); + + let iteration = session0.iteration; + while (iteration < session0.maxIterations) { + if (ac.signal.aborted) break; + if ( + Date.now() - run.startedAt > + session0.maxWallClockSec * 1000 + ) { + store.update(sessionId, { + status: "escalated", + verdictReason: "wall-clock budget exhausted", + completedAt: Date.now(), + }); + return; + } + + iteration += 1; + store.update(sessionId, { iteration, phase: "running" }); + + const promptSession = store.get(sessionId); + if (!promptSession) return; + const prompt = buildIterationPrompt(promptSession, iteration); + this.writeToPane( + promptSession.attachedPaneId as string, + `${prompt}\n`, + ); + + // Wait for the PTY to go idle, indicating the turn is done. + const idled = await this.waitForIdle( + promptSession.attachedPaneId as string, + DEFAULT_IDLE_WINDOW_MS, + session0.maxWallClockSec * 1000, + ac.signal, + ); + if (!idled) break; + + store.update(sessionId, { phase: "verifying" }); + const verdict = await runVerify( + promptSession.verifyCommand, + promptSession.workspaceId, + ac.signal, + ); + + if (verdict.passed) { + store.update(sessionId, { + status: "done", + phase: "done", + verdictPassed: true, + verdictReason: "verify command exited 0", + completedAt: Date.now(), + }); + return; + } + + // Futility: same failing test 3x in a row. + if ( + verdict.failingTest && + verdict.failingTest === run.lastFailingTest + ) { + run.consecutiveSameFailure += 1; + } else { + run.consecutiveSameFailure = 1; + run.lastFailingTest = verdict.failingTest; + } + if (run.consecutiveSameFailure >= 3) { + store.update(sessionId, { + status: "escalated", + verdictPassed: false, + verdictReason: `futility: ${verdict.failingTest ?? "same failure"} recurred ${run.consecutiveSameFailure} times`, + verdictFailingTest: verdict.failingTest, + completedAt: Date.now(), + }); + return; + } + + store.update(sessionId, { + verdictPassed: false, + verdictReason: tailForReason(verdict.log), + verdictFailingTest: verdict.failingTest, + }); + } + + store.update(sessionId, { + status: "escalated", + verdictReason: "iteration budget exhausted", + completedAt: Date.now(), + }); + } finally { + this.active = undefined; + } + } + + private writeToPane(paneId: string, data: string): void { + const terminal = getWorkspaceRuntimeRegistry().getDefault().terminal; + try { + terminal.write({ paneId, data }); + } catch (error) { + console.error("[todo-agent] write failed", error); + } + } + + private waitForIdle( + paneId: string, + idleWindowMs: number, + hardCapMs: number, + signal: AbortSignal, + ): Promise { + return new Promise((resolve) => { + const terminal = getWorkspaceRuntimeRegistry().getDefault().terminal; + let idleTimer: NodeJS.Timeout | undefined; + let hardTimer: NodeJS.Timeout | undefined; + const start = Date.now(); + + const cleanup = () => { + if (idleTimer) clearTimeout(idleTimer); + if (hardTimer) clearTimeout(hardTimer); + terminal.off(`data:${paneId}`, onData); + signal.removeEventListener("abort", onAbort); + }; + + const kickIdle = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (Date.now() - start < MIN_IDLE_BEFORE_VERIFY_MS) { + kickIdle(); + return; + } + cleanup(); + resolve(true); + }, idleWindowMs); + }; + + const onData = () => kickIdle(); + const onAbort = () => { + cleanup(); + resolve(false); + }; + + terminal.on(`data:${paneId}`, onData); + signal.addEventListener("abort", onAbort); + hardTimer = setTimeout(() => { + cleanup(); + resolve(true); + }, hardCapMs); + kickIdle(); + }); + } +} + +let supervisor: TodoSupervisor | undefined; +export function getTodoSupervisor(): TodoSupervisor { + if (!supervisor) supervisor = new TodoSupervisor(); + return supervisor; +} + +// ---- helpers ---- + +function renderGoalDoc(session: SelectTodoSession): string { + return [ + `# TODO: ${session.title}`, + "", + "## Description", + session.description, + "", + "## Goal (acceptance criteria)", + session.goal, + "", + "## Verify command", + "```sh", + session.verifyCommand, + "```", + "", + `Budget: ${session.maxIterations} iterations, ${session.maxWallClockSec}s wall clock.`, + "", + ].join("\n"); +} + +function buildIterationPrompt( + session: SelectTodoSession, + iteration: number, +): string { + const header = + iteration === 1 + ? `You are executing an autonomous TODO task. Goal file is at .superset/todo/${session.id}/goal.md. Read it, then work towards the goal. When you believe a turn is complete, stop and wait; an external verifier will run \`${session.verifyCommand}\` and tell you if you need another turn.` + : `Iteration ${iteration}. The verify command \`${session.verifyCommand}\` failed. Reason: ${session.verdictReason ?? "unknown"}. Continue working toward the goal in .superset/todo/${session.id}/goal.md.`; + return header; +} + +function tailForReason(log: string): string { + const tail = log.trim().split("\n").slice(-20).join("\n"); + return tail.length > 2000 ? `${tail.slice(-2000)}` : tail; +} + +interface VerifyResult { + passed: boolean; + log: string; + failingTest?: string; +} + +function runVerify( + verifyCommand: string, + workspaceId: string, + signal: AbortSignal, +): Promise { + return new Promise((resolve) => { + const cwd = resolveWorktreePath(workspaceId); + if (!cwd) { + resolve({ passed: false, log: "no worktree path for workspace" }); + return; + } + const child = spawn("sh", ["-c", verifyCommand], { + cwd, + env: process.env, + signal, + }); + let buf = ""; + child.stdout.on("data", (d) => { + buf += d.toString(); + }); + child.stderr.on("data", (d) => { + buf += d.toString(); + }); + child.on("error", (err) => { + resolve({ passed: false, log: `${err.message}\n${buf}` }); + }); + child.on("close", (code) => { + const passed = code === 0; + resolve({ + passed, + log: buf, + failingTest: passed ? undefined : guessFailingTest(buf), + }); + }); + }); +} + +function guessFailingTest(log: string): string | undefined { + // Very rough heuristic: first line that looks like a test failure marker. + const match = log.match(/(?:FAIL|✗|×)\s+([^\s][^\n]+)/); + return match?.[1]?.trim(); +} diff --git a/apps/desktop/src/main/todo-agent/trpc-router.ts b/apps/desktop/src/main/todo-agent/trpc-router.ts new file mode 100644 index 00000000000..21dd91396bf --- /dev/null +++ b/apps/desktop/src/main/todo-agent/trpc-router.ts @@ -0,0 +1,116 @@ +import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { publicProcedure, router } from "lib/trpc"; +import { getTodoSessionStore, resolveWorktreePath } from "./session-store"; +import { getTodoSupervisor } from "./supervisor"; +import { + todoAttachPaneInputSchema, + todoCreateInputSchema, + todoSendInputSchema, + type TodoSessionStateEvent, +} from "./types"; + +/** + * tRPC router for the fork-local TODO autonomous agent feature. + * + * Exposed as `todoAgent.*` on the app router. + */ +export const createTodoAgentRouter = () => { + return router({ + create: publicProcedure + .input(todoCreateInputSchema) + .mutation(async ({ input }) => { + const store = getTodoSessionStore(); + const worktreePath = resolveWorktreePath(input.workspaceId); + if (!worktreePath) { + throw new Error( + `todo-agent: workspace ${input.workspaceId} has no worktree`, + ); + } + + const session = store.insert({ + projectId: input.projectId ?? null, + workspaceId: input.workspaceId, + title: input.title, + description: input.description, + goal: input.goal, + verifyCommand: input.verifyCommand, + maxIterations: input.maxIterations, + maxWallClockSec: input.maxWallClockSec, + status: "queued", + phase: "queued", + iteration: 0, + attachedPaneId: null, + attachedTabId: null, + verdictPassed: null, + verdictReason: null, + verdictFailingTest: null, + artifactPath: `.superset/todo/PENDING`, + startedAt: null, + completedAt: null, + }); + + const artifactPath = getTodoSupervisor().prepareArtifacts(session); + store.update(session.id, { artifactPath }); + + return { sessionId: session.id }; + }), + + list: publicProcedure + .input(z.object({ workspaceId: z.string().min(1) })) + .query(({ input }) => + getTodoSessionStore().listForWorkspace(input.workspaceId), + ), + + get: publicProcedure + .input(z.object({ sessionId: z.string().min(1) })) + .query(({ input }) => getTodoSessionStore().get(input.sessionId)), + + attachPane: publicProcedure + .input(todoAttachPaneInputSchema) + .mutation(async ({ input }) => { + const store = getTodoSessionStore(); + store.update(input.sessionId, { + attachedPaneId: input.paneId, + attachedTabId: input.tabId, + status: "preparing", + }); + // Fire-and-forget: kick off the loop. + void getTodoSupervisor().attachAndStart(input.sessionId); + return { ok: true }; + }), + + abort: publicProcedure + .input(z.object({ sessionId: z.string().min(1) })) + .mutation(({ input }) => { + getTodoSupervisor().abort(input.sessionId); + return { ok: true }; + }), + + sendInput: publicProcedure + .input(todoSendInputSchema) + .mutation(({ input }) => { + getTodoSupervisor().sendInput(input.sessionId, input.data); + return { ok: true }; + }), + + subscribeState: publicProcedure + .input(z.object({ sessionId: z.string().min(1) })) + .subscription(({ input }) => { + return observable((emit) => { + const store = getTodoSessionStore(); + // Emit current state immediately on subscribe. + const current = store.get(input.sessionId); + if (current) { + emit.next({ sessionId: current.id, session: current }); + } + const unsubscribe = store.subscribe(input.sessionId, (event) => { + emit.next(event); + }); + return () => unsubscribe(); + }); + }), + }); +}; + +export type TodoAgentRouter = ReturnType; diff --git a/apps/desktop/src/main/todo-agent/types.ts b/apps/desktop/src/main/todo-agent/types.ts new file mode 100644 index 00000000000..fca43b55d4d --- /dev/null +++ b/apps/desktop/src/main/todo-agent/types.ts @@ -0,0 +1,54 @@ +import type { SelectTodoSession } from "@superset/local-db"; +import { z } from "zod"; + +export const todoCreateInputSchema = z.object({ + workspaceId: z.string().min(1), + projectId: z.string().optional(), + title: z.string().min(1).max(200), + description: z.string().min(1).max(10_000), + goal: z.string().min(1).max(10_000), + verifyCommand: z.string().min(1).default("bun test"), + maxIterations: z.number().int().min(1).max(100).default(10), + maxWallClockSec: z.number().int().min(60).max(60 * 60 * 4).default(1800), +}); + +export type TodoCreateInput = z.infer; + +export const todoAttachPaneInputSchema = z.object({ + sessionId: z.string().min(1), + tabId: z.string().min(1), + paneId: z.string().min(1), +}); + +export type TodoAttachPaneInput = z.infer; + +export const todoSendInputSchema = z.object({ + sessionId: z.string().min(1), + data: z.string().min(1), +}); + +export type TodoSendInput = z.infer; + +/** + * Event published on state changes so the tRPC subscription can fan out to + * the renderer. Kept small and serializable. + */ +export interface TodoSessionStateEvent { + sessionId: string; + session: SelectTodoSession; +} + +export type TodoSessionPhase = + | "queued" + | "preparing" + | "running" + | "verifying" + | "done" + | "failed" + | "escalated" + | "aborted" + | "paused"; + +export const TODO_ARTIFACT_SUBDIR = ".superset/todo"; +export const DEFAULT_IDLE_WINDOW_MS = 5_000; +export const MIN_IDLE_BEFORE_VERIFY_MS = 3_000; diff --git a/packages/local-db/src/schema/index.ts b/packages/local-db/src/schema/index.ts index b5d33072d34..06ed2bd9362 100644 --- a/packages/local-db/src/schema/index.ts +++ b/packages/local-db/src/schema/index.ts @@ -1,3 +1,4 @@ export * from "./relations"; export * from "./schema"; +export * from "./todo-sessions"; export * from "./zod"; diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 9782b5dfb55..9780d473ec5 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -455,3 +455,7 @@ export type InsertBrowserSitePermission = typeof browserSitePermissions.$inferInsert; export type SelectBrowserSitePermission = typeof browserSitePermissions.$inferSelect; + +// Fork-local: TODO autonomous agent sessions. Re-exported so drizzle-kit +// (configured with schema="./src/schema/schema.ts") picks up the table. +export * from "./todo-sessions"; diff --git a/packages/local-db/src/schema/todo-sessions.ts b/packages/local-db/src/schema/todo-sessions.ts new file mode 100644 index 00000000000..e01831d4090 --- /dev/null +++ b/packages/local-db/src/schema/todo-sessions.ts @@ -0,0 +1,78 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { v4 as uuidv4 } from "uuid"; + +import { projects, workspaces } from "./schema"; + +/** + * TODO autonomous agent sessions. + * + * Each row represents a user-defined autonomous Claude Code task that runs + * inside a workspace until a verify command passes or a budget/futility + * guard trips. Fork-local feature; see apps/desktop/plans/todo-agent-plan.md. + */ +export const todoSessions = sqliteTable( + "todo_sessions", + { + id: text("id") + .primaryKey() + .$defaultFn(() => uuidv4()), + projectId: text("project_id").references(() => projects.id, { + onDelete: "set null", + }), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + + title: text("title").notNull(), + description: text("description").notNull(), + goal: text("goal").notNull(), + verifyCommand: text("verify_command").notNull(), + + maxIterations: integer("max_iterations").notNull().default(10), + maxWallClockSec: integer("max_wall_clock_sec").notNull().default(1800), + + status: text("status").notNull().default("queued"), + phase: text("phase"), + iteration: integer("iteration").notNull().default(0), + + attachedPaneId: text("attached_pane_id"), + attachedTabId: text("attached_tab_id"), + + verdictPassed: integer("verdict_passed", { mode: "boolean" }), + verdictReason: text("verdict_reason"), + verdictFailingTest: text("verdict_failing_test"), + + artifactPath: text("artifact_path").notNull(), + + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at") + .notNull() + .$defaultFn(() => Date.now()), + startedAt: integer("started_at"), + completedAt: integer("completed_at"), + }, + (table) => [ + index("todo_sessions_workspace_idx").on(table.workspaceId), + index("todo_sessions_status_idx").on(table.status), + index("todo_sessions_created_at_idx").on(table.createdAt), + ], +); + +export type InsertTodoSession = typeof todoSessions.$inferInsert; +export type SelectTodoSession = typeof todoSessions.$inferSelect; + +export const todoSessionStatusValues = [ + "queued", + "preparing", + "running", + "verifying", + "done", + "failed", + "escalated", + "aborted", + "paused", +] as const; + +export type TodoSessionStatus = (typeof todoSessionStatusValues)[number]; From 99287e2675d3019d73382ba7fafa6d248880fd22 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 16 Apr 2026 00:31:21 +0900 Subject: [PATCH 02/27] feat(fork): add TODO button and session creation modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first user-facing surface of the autonomous TODO agent: a compact TODO button placed immediately left of WorkspaceRunButton in PresetsBar, plus the creation modal that collects the task details the supervisor needs to start a run. Scope of this commit -------------------- Deliberately limited to session *creation*. Clicking the button opens a modal, the user fills in the form, and submit creates a `todo_sessions` row via `todoAgent.create`. The supervisor does not start executing yet — pane attach + execution handoff lands in a follow-up commit along with TodoPanel. This keeps each commit independently reviewable and rollback-safe. TodoButton (TodoButton/TodoButton.tsx) -------------------------------------- - Small ghost-variant button with a list icon and "TODO" label, styled to sit naturally next to WorkspaceRunButton without visually competing with it. - Polls `todoAgent.list` every 3s for the current workspace and shows a badge with the count of queued/preparing/running/verifying sessions so users can see at a glance that work is in flight. - Opens the modal as local state; no global store needed. TodoModal (TodoModal/TodoModal.tsx) ----------------------------------- Form fields, each mapped 1:1 to the zod schema in `main/todo-agent/types.ts`: - Title (max 200) - What should be done? (multiline, max 10k) - Clear goal / acceptance criteria (multiline, required — this is the single most important input for making the loop terminate) - Verify command (default `bun test`, exit code is the ground truth) - Max iterations (default 10, capped at 100) - Wall-clock minutes (default 30, capped at 240) Submit calls `electronTrpc.todoAgent.create.useMutation` and invalidates `todoAgent.list` so the button badge updates immediately. Success and failure are surfaced via the existing sonner toast. Cancel and close both reset the form. Rendering changes ----------------- - `PresetsBar.tsx` now imports TodoButton and renders it inside the existing `ml-auto flex items-center gap-1 shrink-0` wrapper, immediately before WorkspaceRunButton. The wrapper already handles spacing so no layout tweaks are needed. - Both the TodoButton import line and the render line are isolated additions to keep upstream merge conflicts cheap. Co-location ----------- Component code follows the repo's folder-per-component convention under `src/renderer/features/todo-agent/` so all fork-local feature code stays in one directory and is easy to delete or rebase. Verified -------- - `bun run typecheck` in apps/desktop — clean. --- .../todo-agent/TodoButton/TodoButton.tsx | 68 ++++++ .../features/todo-agent/TodoButton/index.ts | 1 + .../todo-agent/TodoModal/TodoModal.tsx | 229 ++++++++++++++++++ .../features/todo-agent/TodoModal/index.ts | 1 + .../components/PresetsBar/PresetsBar.tsx | 6 + 5 files changed, 305 insertions(+) create mode 100644 apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx create mode 100644 apps/desktop/src/renderer/features/todo-agent/TodoButton/index.ts create mode 100644 apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx create mode 100644 apps/desktop/src/renderer/features/todo-agent/TodoModal/index.ts diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx new file mode 100644 index 00000000000..fe0d44f82d1 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx @@ -0,0 +1,68 @@ +import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; +import { memo, useState } from "react"; +import { HiMiniListBullet } from "react-icons/hi2"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { TodoModal } from "../TodoModal"; + +interface TodoButtonProps { + projectId?: string | null; + workspaceId: string; + worktreePath?: string | null; +} + +/** + * Fork-local TODO autonomous agent entry point. Sits immediately left of + * the WorkspaceRunButton in PresetsBar. Opens a modal where the user + * specifies a goal; the modal submits via trpc and the supervisor takes + * over from there. + */ +export const TodoButton = memo(function TodoButton({ + projectId, + workspaceId, +}: TodoButtonProps) { + const [modalOpen, setModalOpen] = useState(false); + + const { data: sessions } = electronTrpc.todoAgent.list.useQuery( + { workspaceId }, + { enabled: !!workspaceId, refetchInterval: 3000 }, + ); + + const activeCount = (sessions ?? []).filter( + (session) => + session.status === "queued" || + session.status === "preparing" || + session.status === "running" || + session.status === "verifying", + ).length; + + return ( + <> + + + + ); +}); diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoButton/index.ts b/apps/desktop/src/renderer/features/todo-agent/TodoButton/index.ts new file mode 100644 index 00000000000..8a8676c99f1 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoButton/index.ts @@ -0,0 +1 @@ +export { TodoButton } from "./TodoButton"; diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx new file mode 100644 index 00000000000..e362be836c8 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx @@ -0,0 +1,229 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { Textarea } from "@superset/ui/textarea"; +import { useCallback, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface TodoModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + projectId?: string; +} + +const DEFAULT_VERIFY_COMMAND = "bun test"; +const DEFAULT_MAX_ITERATIONS = 10; +const DEFAULT_MAX_MINUTES = 30; + +/** + * Creation form for a new TODO autonomous session. Collects the minimum + * needed for the supervisor to start a run: description, goal, verify + * command, and budget. On submit, creates a DB row via trpc and closes. + * The actual execution handoff (pane attach + start) is done separately + * from the TodoPanel once the user opens it. + */ +export function TodoModal({ + open, + onOpenChange, + workspaceId, + projectId, +}: TodoModalProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [goal, setGoal] = useState(""); + const [verifyCommand, setVerifyCommand] = useState(DEFAULT_VERIFY_COMMAND); + const [maxIterations, setMaxIterations] = useState(DEFAULT_MAX_ITERATIONS); + const [maxMinutes, setMaxMinutes] = useState(DEFAULT_MAX_MINUTES); + const [submitting, setSubmitting] = useState(false); + + const utils = electronTrpc.useUtils(); + const create = electronTrpc.todoAgent.create.useMutation({ + onSuccess: async () => { + await utils.todoAgent.list.invalidate({ workspaceId }); + }, + }); + + const reset = useCallback(() => { + setTitle(""); + setDescription(""); + setGoal(""); + setVerifyCommand(DEFAULT_VERIFY_COMMAND); + setMaxIterations(DEFAULT_MAX_ITERATIONS); + setMaxMinutes(DEFAULT_MAX_MINUTES); + setSubmitting(false); + }, []); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next) reset(); + onOpenChange(next); + }, + [onOpenChange, reset], + ); + + const canSubmit = + title.trim().length > 0 && + description.trim().length > 0 && + goal.trim().length > 0 && + verifyCommand.trim().length > 0 && + maxIterations >= 1 && + maxMinutes >= 1 && + !submitting; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) return; + setSubmitting(true); + try { + await create.mutateAsync({ + workspaceId, + projectId, + title: title.trim(), + description: description.trim(), + goal: goal.trim(), + verifyCommand: verifyCommand.trim(), + maxIterations, + maxWallClockSec: maxMinutes * 60, + }); + toast.success("TODO session created"); + handleOpenChange(false); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to create TODO"; + toast.error(message); + setSubmitting(false); + } + }, [ + canSubmit, + create, + description, + goal, + handleOpenChange, + maxIterations, + maxMinutes, + projectId, + title, + verifyCommand, + workspaceId, + ]); + + return ( + + + + New autonomous TODO + + An autonomous Claude Code session will run until the verify + command exits 0 or the budget is exhausted. You can watch and + intervene from the TODO panel while it runs. + + + +
+
+ + setTitle(e.target.value)} + placeholder="Fix issue #123: login redirect loop" + maxLength={200} + autoFocus + /> +
+ +
+ +