diff --git a/.gitignore b/.gitignore index 722c0505af7..32fcf9a1ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ test-conflict-repo/ # Claude Code session lock (runtime artifact) .claude/scheduled_tasks.lock +temp/ diff --git a/apps/desktop/plans/20260417-todo-agent-remote-control.md b/apps/desktop/plans/20260417-todo-agent-remote-control.md new file mode 100644 index 00000000000..a09f171e6cb --- /dev/null +++ b/apps/desktop/plans/20260417-todo-agent-remote-control.md @@ -0,0 +1,124 @@ +# TODO Agent Remote Control 統合 計画 + +## 背景 + +Claude Code CLI は v2.1.51 で `claude remote-control` / `claude --remote-control` / スラッシュコマンド `/remote-control` を提供し、ローカルで走っているセッションを claude.ai/code や Claude iOS/Android アプリから閲覧・操作できるようになった。 + +TODO Agent は現在 `claude -p --output-format stream-json` をサブプロセスで起動して stdout の NDJSON を parse するヘッドレス方式で動いている。これは Remote Control と互換性がない (`-p` は Ink TUI を持たず、interactive 端末 UI を要求する `/remote-control` を受けられない)。 + +本 PR は PTY + JSONL tail ベースの代替エンジンを feature flag 付きで追加し、Remote Control を opt-in で使えるようにする。 + +## 検証済み事実 (手元 POC 完了) + +- interactive `claude --permission-mode bypassPermissions --settings ''` で Stop / UserPromptSubmit / PreToolUse / PostToolUse / SessionStart hook を inline 注入可能 +- `~/.claude/projects//.jsonl` は interactive モードでも書き込まれる。spawn 後 3 秒以内に生成される +- interactive モードの `--session-id ` は JSONL ファイル名を制御**しない** (別 UUID が内部生成される)。`fs.watch` で project dir の新規ファイルを自セッションとして同定する必要がある +- JSONL event type: `system` / `user` / `user(tool_result)` / `assistant(thinking|text|tool_use)` / `attachment` / `permission-mode` / `file-history-snapshot` / `queue-operation` / `last-prompt` +- PTY への bracketed paste (`\x1b[200~...\x1b[201~\r`) で prompt 投入成功 +- `/remote-control\r` で stdout に `https://claude.ai/code/session_...` が表示される +- mid-session で追加プロンプトを送信可能 + +## アーキテクチャ + +### 選択肢比較 + +| 案 | Remote Control | Live stream | コスト | 採否 | +|----|----------------|-------------|--------|------| +| A. 現状 `-p` | 不可 | ○ | 0 | 部分採用 (既定・非 RC 系は当面これ) | +| B. Agent SDK | 不可 (API key 必須) | ○ | 大 | 却下 | +| C. PTY + JSONL tail | ○ | △ (per-token なし / whole message) | 中 | **本 PR で採用** | +| D. Dual process | △ (競合) | ○ | 小 | 却下 (会話競合リスク) | + +### 案 C の構成 + +``` +[daemon] + ├── supervisor-engine.ts (従来 -p エンジン / 既定) + │ └── runClaudeTurn() : stream-json stdout parse + │ + └── pty-turn-runner.ts (新規 PTY エンジン / opt-in) + └── runClaudeTurnPty() + ├── node-pty spawn + │ claude --permission-mode bypassPermissions + │ --settings '' + │ [--model ...] [--effort ...] + │ [--resume ] + │ + ├── fs.watch(~/.claude/projects//) + │ → 新規 .jsonl を自セッションとして同定 + │ + ├── chokidar 相当の poll + offset tracking + │ → assistant / user(tool_result) / assistant(tool_use) を + │ supervisor-engine と同じ TodoStreamEvent 形に変換 + │ + ├── Stop hook 発火 (Unix/tmp ファイル経由) で turn 終了検知 + │ + ├── Remote Control 有効時のみ PTY stdin に `/remote-control\r` + │ → PTY stdout を ANSI strip 後 `https://claude.ai/code/session_...` + │ を抽出してセッションに保存 + │ + └── bracketed paste で prompt 投入 / 次ターンも同じ PTY 再利用 ... + ではなく、既存 supervisor の iteration ループに合わせて + **1 ターン 1 プロセス** とし、次 iteration は + `--resume ` で再 spawn する +``` + +**重要な設計判断: 1 ターン 1 プロセス** +既存 `supervisor-engine.ts` は iteration ごとに `claude -p` を spawn → exit する。PTY 版もこのライフサイクルに合わせ、1 ターンごとに PTY プロセスを起こして Stop hook で終了させる。これで: + +- 既存 `runSession` ループを変更せず `runClaudeTurn` を差し替えるだけで済む +- ScheduleWakeup の既存処理 (waiting 状態 → 別プロセスで resume) がそのまま動く +- Intervention (追加メッセージ) も既存の queue → 次 iteration 投入フローで動く +- 長命プロセスのリソース管理問題を回避 + +### Feature flag + +- 環境変数 `TODO_ENGINE=pty` で PTY エンジンに切り替え (既定: headless) +- セッション単位の `remote_control_enabled` フラグは UI チェックボックスで opt-in + - PTY エンジン + `remote_control_enabled=true` の AND 条件で Remote Control 発動 + - チェックボックスは `TODO_ENGINE=pty` が無効なときは disabled + +## DB schema 変更 + +`todo_sessions` に 1 列追加: + +```sql +ALTER TABLE todo_sessions ADD COLUMN remote_control_enabled INTEGER DEFAULT 0; +``` + +- `remote_control_session_url` は **永続化しない**。daemon 再起動で RC セッションは切れるため、URL は in-memory + stream event のみで表現 +- Remote Control 状態は stream event で live-stream に流す + +## UI 変更 + +- `TodoModal`: 「Remote Control を有効化」チェックボックス追加 (PTY mode 時のみ有効) +- `ScheduleEditorDialog`: 同様のチェックボックス追加 +- `TodoManager` live stream: RC 接続中バッジ + URL リンクを stream events から読んで表示 + +## 実装順序 + +1. plan.md 追加 (本文書) +2. DB schema: `remote_control_enabled` 列追加 +3. PTY turn runner 本体 (`pty-turn-runner.ts`) +4. supervisor-engine 側の feature flag 分岐 +5. StartRequest / tRPC 入出力 に RC フィールド追加 +6. TodoModal / ScheduleEditorDialog UI +7. live stream バッジ表示 +8. lint / typecheck / 自己レビュー +9. commit / push / PR + +## フォローアップ (後続 PR) + +- dogfood 後 `-p` エンジン削除 +- per-token streaming (JSONL には text_delta が無いので別経路を検討) +- mid-session メッセージ送信 UI (`queueIntervention` 拡張) +- Remote Control URL の永続化 + セッション再接続導線 +- 並列起動時の race 対策強化 +- Electron パッケージでの node-pty ネイティブ rebuild 確認 (既に terminal-host で使用中) + +## 前提条件 + +- `claude auth login` 済 (claude.ai OAuth) +- Claude Code v2.1.51+ +- Pro/Max/Team/Enterprise プラン +- Team/Enterprise は admin が Remote Control トグルを有効化済 diff --git a/apps/desktop/src/main/todo-agent/session-store.ts b/apps/desktop/src/main/todo-agent/session-store.ts index d186b889d54..5af3b7e31b9 100644 --- a/apps/desktop/src/main/todo-agent/session-store.ts +++ b/apps/desktop/src/main/todo-agent/session-store.ts @@ -284,6 +284,7 @@ class TodoSessionStore { customSystemPrompt?: string | null; claudeModel?: string | null; claudeEffort?: string | null; + remoteControlEnabled?: boolean; artifactPath: string; }): SelectTodoSession { return this.insert({ @@ -310,6 +311,7 @@ class TodoSessionStore { customSystemPrompt: template.customSystemPrompt ?? null, claudeModel: template.claudeModel ?? null, claudeEffort: template.claudeEffort ?? null, + remoteControlEnabled: template.remoteControlEnabled ?? false, verdictPassed: null, verdictReason: null, verdictFailingTest: null, diff --git a/apps/desktop/src/main/todo-agent/trpc-router.ts b/apps/desktop/src/main/todo-agent/trpc-router.ts index f58b597551b..7f96ac00614 100644 --- a/apps/desktop/src/main/todo-agent/trpc-router.ts +++ b/apps/desktop/src/main/todo-agent/trpc-router.ts @@ -130,6 +130,7 @@ export const createTodoAgentRouter = () => { customSystemPrompt: input.customSystemPrompt, claudeModel: resolvedModel, claudeEffort: resolvedEffort, + remoteControlEnabled: input.remoteControlEnabled, artifactPath, }); @@ -414,6 +415,7 @@ export const createTodoAgentRouter = () => { customSystemPrompt: source.customSystemPrompt, claudeModel: source.claudeModel, claudeEffort: source.claudeEffort, + remoteControlEnabled: source.remoteControlEnabled ?? false, verdictPassed: null, verdictReason: null, verdictFailingTest: null, diff --git a/apps/desktop/src/main/todo-agent/types.ts b/apps/desktop/src/main/todo-agent/types.ts index dcadc1b8fc1..422bd90b1fd 100644 --- a/apps/desktop/src/main/todo-agent/types.ts +++ b/apps/desktop/src/main/todo-agent/types.ts @@ -105,6 +105,13 @@ export const todoCreateInputSchema = z.object({ // means "use the user's configured default" (see todoSettingsSchema). claudeModel: todoClaudeModelSchema.nullish(), claudeEffort: todoClaudeEffortSchema.nullish(), + // When true, the daemon starts the session under the PTY engine + // and sends `/remote-control` after spawn so it is reachable from + // claude.ai/code and the Claude mobile app. Requires the daemon to + // be running in PTY mode (`TODO_ENGINE=pty`) and a claude.ai + // subscription (Pro/Max). See + // apps/desktop/plans/20260417-todo-agent-remote-control.md. + remoteControlEnabled: z.boolean().optional().default(false), }); export const todoPresetKindSchema = z.enum(["system", "description", "goal"]); @@ -206,7 +213,14 @@ export type TodoStreamEventKind = | "tool_result" | "result" | "error" - | "raw"; + | "raw" + // PTY engine (`TODO_ENGINE=pty`) emits these when Remote Control is + // enabled on the session. `remote_control` carries the connection URL + // (`https://claude.ai/code/session_...`) the UI surfaces as a badge; + // `remote_control_error` is non-fatal — the turn continues without RC. + // See apps/desktop/plans/20260417-todo-agent-remote-control.md. + | "remote_control" + | "remote_control_error"; /** * One condensed event we store in the per-session in-memory buffer and send diff --git a/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts b/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts new file mode 100644 index 00000000000..de19f8191b1 --- /dev/null +++ b/apps/desktop/src/main/todo-daemon/pty-turn-runner.ts @@ -0,0 +1,1131 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getTodoSessionStore } from "main/todo-agent/session-store"; +import { + CLAUDE_EFFORT_OPTIONS, + CLAUDE_MODEL_OPTIONS, + type TodoStreamEvent, + type TodoStreamEventKind, +} from "main/todo-agent/types"; +import type { IPty } from "node-pty"; +import * as pty from "node-pty"; + +/** + * PTY-mode Claude Code turn runner. + * + * Runs a single Claude Code iteration as an interactive TUI behind a + * PTY (instead of the default `claude -p` headless stream-json path). + * Structured events are pulled from the session JSONL transcript at + * `~/.claude/projects//.jsonl`; turn-end is + * detected via a Stop hook injected through `--settings`. See + * `apps/desktop/plans/20260417-todo-agent-remote-control.md`. + * + * Enabled only when the daemon process starts with `TODO_ENGINE=pty` + * **and** the session row has `remote_control_enabled` set. The + * supervisor engine routes to this runner instead of the headless + * implementation in `supervisor-engine.ts` under that condition. + */ + +// Public shape must match `runClaudeTurn` in supervisor-engine.ts so +// callers can swap implementations transparently. +export interface PtyTurnParams { + sessionId: string; + iteration: number; + cwd: string; + prompt: string; + resumeSessionId: string | null; + customSystemPrompt: string | null; + claudeModel: string | null; + claudeEffort: string | null; + signal: AbortSignal; + onChild: (handle: { + pid: number | null; + kill: () => void; + /** + * Register a callback invoked when the spawned PTY process + * exits. The supervisor uses this to clear its SIGKILL + * fallback timer so it does not fire against a terminated or + * recycled PID (CodeRabbit review). + */ + onExit: (cb: () => void) => void; + }) => void; + /** Whether to send `/remote-control` after the PTY is ready. */ + remoteControlEnabled: boolean; +} + +export interface PtyTurnResult { + result: string | null; + sessionId: string | null; + costUsd: number | null; + numTurns: number | null; + error: string | null; + interrupted: boolean; + scheduledWakeup: { delayMs: number; reason: string | null } | null; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Path of the POSIX executable whose session JSONL we tail. Falls + * back to `claude` on PATH when unset (tests / dev shells). */ +const CLAUDE_BIN = + process.env.TODO_CLAUDE_BIN || process.env.CLAUDE_BIN || "claude"; + +/** Project transcript root used by Claude Code. */ +const CLAUDE_PROJECTS_ROOT = path.join(os.homedir(), ".claude", "projects"); + +/** How long we wait after spawn for the JSONL file to appear. */ +const JSONL_DISCOVERY_TIMEOUT_MS = 15_000; + +/** How often we poll the JSONL file for appended lines. */ +const JSONL_POLL_INTERVAL_MS = 250; + +/** Max wait for TUI to settle before we send the first prompt. */ +const TUI_READY_MAX_WAIT_MS = 25_000; + +/** Idle window after which we consider the TUI ready (no stdout). */ +const TUI_READY_IDLE_MS = 2_000; + +/** Max wait for the Stop hook to fire after a prompt is sent. */ +const STOP_HOOK_MAX_WAIT_MS = 30 * 60 * 1000; // 30 min + +/** Max wait after sending `/remote-control` for a session URL to + * appear in PTY stdout. */ +const REMOTE_CONTROL_URL_TIMEOUT_MS = 15_000; + +const REMOTE_CONTROL_URL_RE = + /https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/; + +const ATTACHMENT_PATH_RE = + /!\[[^\]]*\]\(([^()\s]*[/\\]todo-agent[/\\]attachments[/\\][^)\s]+)\)/g; + +// ============================================================================= +// Public entry point +// ============================================================================= + +export async function runClaudeTurnPty( + params: PtyTurnParams, +): Promise { + const encodedCwd = encodeCwdForClaude(params.cwd); + const projectDir = path.join(CLAUDE_PROJECTS_ROOT, encodedCwd); + ensureDir(projectDir); + + // Set up the Stop-hook sink before spawning so we never miss events. + const hookSink = createHookSink(params.sessionId); + const settings = buildSettingsJson(hookSink.hookCommand); + + const args = [ + "--permission-mode", + "bypassPermissions", + "--settings", + settings, + ]; + if (params.customSystemPrompt) { + args.push("--append-system-prompt", params.customSystemPrompt); + } + if ( + params.claudeModel && + (CLAUDE_MODEL_OPTIONS as readonly string[]).includes(params.claudeModel) + ) { + args.push("--model", params.claudeModel); + } else if (params.claudeModel) { + console.warn( + "[todo-daemon:pty] ignoring unknown claudeModel:", + params.claudeModel, + ); + } + if ( + params.claudeEffort && + (CLAUDE_EFFORT_OPTIONS as readonly string[]).includes(params.claudeEffort) + ) { + args.push("--effort", params.claudeEffort); + } else if (params.claudeEffort) { + console.warn( + "[todo-daemon:pty] ignoring unknown claudeEffort:", + params.claudeEffort, + ); + } + if (params.resumeSessionId) { + args.push("--resume", params.resumeSessionId); + } + + let ptyProcess: IPty; + try { + ptyProcess = pty.spawn(CLAUDE_BIN, args, { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: params.cwd, + env: { + ...process.env, + TERM: "xterm-256color", + }, + }); + } catch (error) { + hookSink.cleanup(); + return { + result: null, + sessionId: null, + costUsd: null, + numTurns: null, + error: + error instanceof Error + ? `claude を PTY 起動できませんでした: ${error.message}` + : "claude を PTY 起動できませんでした", + interrupted: false, + scheduledWakeup: null, + }; + } + + const state: TurnState = { + claudeSessionId: params.resumeSessionId, + lastAssistantText: null, + costUsd: null, + numTurns: 0, + scheduledWakeup: null, + processedEventCount: 0, + jsonlPath: null, + jsonlReadOffset: 0, + remoteControlUrl: null, + }; + + let ptyBuffer = ""; + // Mutable flags wrapped in an object so TypeScript's control-flow + // analysis doesn't narrow the closure-captured locals to `never` + // when we read them later in the same function (the assignments + // live inside `onExit`/`onData` callbacks and are opaque to the + // analyzer). + const ptyStatus: { + alive: boolean; + exit: { exitCode: number; signal?: number } | null; + } = { alive: true, exit: null }; + // Collect onExit subscribers from the supervisor shim before the + // PTY actually exits so none are dropped even if the callback is + // registered after `onExit` fires (defensive — the supervisor is + // expected to subscribe synchronously inside `params.onChild`). + const exitSubscribers = new Set<() => void>(); + ptyProcess.onData((data) => { + ptyBuffer += data; + // Keep the buffer bounded. We only parse the last page when we + // need it (ready detection, `/remote-control` URL capture). + if (ptyBuffer.length > 64 * 1024) { + ptyBuffer = ptyBuffer.slice(-32 * 1024); + } + }); + ptyProcess.onExit((ev) => { + ptyStatus.alive = false; + ptyStatus.exit = ev; + for (const cb of Array.from(exitSubscribers)) { + exitSubscribers.delete(cb); + try { + cb(); + } catch { + /* ignore */ + } + } + }); + + params.onChild({ + pid: ptyProcess.pid ?? null, + kill: () => safeKill(ptyProcess), + onExit: (cb) => { + if (!ptyStatus.alive) { + try { + cb(); + } catch { + /* ignore */ + } + return; + } + exitSubscribers.add(cb); + }, + }); + + const abortHandler = () => { + safeKill(ptyProcess); + }; + params.signal.addEventListener("abort", abortHandler); + + // Poll state: abort / intervention / jsonl tail / hook sink. + let interrupted = false; + const interventionStore = getTodoSessionStore(); + const pollState = () => { + if (!ptyStatus.alive) return false; + if (params.signal.aborted) { + safeKill(ptyProcess); + return false; + } + const live = interventionStore.get(params.sessionId); + if (live?.pendingIntervention?.trim()) { + interrupted = true; + appendRawEvent( + params.sessionId, + params.iteration, + "system_init", + "介入", + "ユーザ介入を検知。現在のターンを中断して介入内容で再開します…", + ); + // Forcibly end the current turn. We do not send SIGINT + // through the PTY because the TUI treats ctrl-c as + // "cancel prompt"; just kill the process — the next + // iteration will re-spawn with the intervention prepended. + safeKill(ptyProcess); + return false; + } + return true; + }; + + try { + // Wait for the JSONL file to appear. For non-resume runs we + // wait until the SessionStart hook has written the runtime + // session id to disk — that is the only way to bind *this* + // PTY to *this* JSONL when multiple sessions spawn in the + // same cwd. Falling back to "first new jsonl" would make + // concurrent sessions tail each other's transcripts + // (P1 review finding). + const jsonlStartTs = Date.now(); + while (Date.now() - jsonlStartTs < JSONL_DISCOVERY_TIMEOUT_MS) { + if (!pollState()) break; + const nowJsonls = listJsonl(projectDir); + let discovered: string | null = null; + if (params.resumeSessionId) { + const expected = `${params.resumeSessionId}.jsonl`; + if (nowJsonls.includes(expected)) { + discovered = expected; + } + } else { + const runtimeSessionId = hookSink.readRuntimeSessionId(); + if (runtimeSessionId) { + const expected = `${runtimeSessionId}.jsonl`; + if (nowJsonls.includes(expected)) { + discovered = expected; + state.claudeSessionId = runtimeSessionId; + } + } + } + if (discovered) { + state.jsonlPath = path.join(projectDir, discovered); + // When resuming, skip past the existing content so we + // only see events produced by this turn. + if (params.resumeSessionId) { + try { + state.jsonlReadOffset = fs.statSync(state.jsonlPath).size; + } catch { + state.jsonlReadOffset = 0; + } + } + if (!state.claudeSessionId) { + const base = path.basename(discovered, ".jsonl"); + if (/^[0-9a-f-]{36}$/.test(base)) { + state.claudeSessionId = base; + } + } + break; + } + await sleep(200); + } + + if (!state.jsonlPath) { + const runtimeSid = hookSink.readRuntimeSessionId(); + return { + result: null, + sessionId: state.claudeSessionId, + costUsd: null, + numTurns: null, + error: runtimeSid + ? `Claude Code のセッション JSONL (${runtimeSid}.jsonl) が発見できませんでした` + : "SessionStart hook が発火しなかったため JSONL を同定できませんでした (PTY 起動は成功)", + interrupted: false, + scheduledWakeup: null, + }; + } + + // Wait for the TUI to settle so the first prompt isn't dropped. + await waitForTuiReady( + () => ptyBuffer, + () => ptyStatus.alive, + TUI_READY_MAX_WAIT_MS, + ); + if (!ptyStatus.alive) { + return ptyExitError(state, ptyStatus.exit, ptyBuffer); + } + + // `/remote-control` must be sent BEFORE the first user prompt. + // Otherwise the TUI may be busy rendering the response when we + // issue it, and the slash command gets treated as plain input. + if (params.remoteControlEnabled) { + await activateRemoteControl( + ptyProcess, + () => ptyBuffer, + (url) => { + state.remoteControlUrl = url; + appendRawEvent( + params.sessionId, + params.iteration, + "remote_control", + "Remote Control", + `接続 URL: ${url}`, + ); + }, + (errorText) => { + appendRawEvent( + params.sessionId, + params.iteration, + "remote_control_error", + "Remote Control エラー", + errorText, + ); + }, + ); + // Give the TUI a moment to settle after the slash command. + await sleep(500); + } + + // Send the prompt via bracketed paste to preserve newlines and + // avoid the TUI re-interpreting content like `/` as slash + // commands when it starts a line. + ptyProcess.write(`\x1b[200~${params.prompt}\x1b[201~`); + await sleep(200); + ptyProcess.write("\r"); + + // Tail the JSONL and wait for Stop hook or PTY exit. + const turnStartTs = Date.now(); + while (ptyStatus.alive) { + if (!pollState()) break; + await tailJsonl(state, params); + if (hookSink.hasStopEvent()) break; + if (Date.now() - turnStartTs > STOP_HOOK_MAX_WAIT_MS) { + appendRawEvent( + params.sessionId, + params.iteration, + "error", + "timeout", + "Stop hook が発火しないまま PTY ターンがタイムアウトしました", + ); + break; + } + await sleep(JSONL_POLL_INTERVAL_MS); + } + + // Drain any lines written after the last poll. + await tailJsonl(state, params); + + if (interrupted) { + return { + result: state.lastAssistantText, + sessionId: state.claudeSessionId, + costUsd: state.costUsd, + numTurns: state.numTurns || null, + error: null, + interrupted: true, + scheduledWakeup: state.scheduledWakeup, + }; + } + + if ( + !ptyStatus.alive && + (ptyStatus.exit?.exitCode ?? 0) !== 0 && + !state.lastAssistantText + ) { + return ptyExitError(state, ptyStatus.exit, ptyBuffer); + } + + return { + result: state.lastAssistantText, + sessionId: state.claudeSessionId, + costUsd: state.costUsd, + numTurns: state.numTurns || null, + error: null, + interrupted: false, + scheduledWakeup: state.scheduledWakeup, + }; + } finally { + params.signal.removeEventListener("abort", abortHandler); + // End the interactive session cleanly. The PTY may already have + // exited (Stop hook path often corresponds to a long-lived TUI + // waiting for the next prompt); tell it to exit so the next + // iteration can start fresh with --resume. + if (ptyStatus.alive) { + try { + ptyProcess.write("/exit\r"); + } catch { + /* ignore */ + } + await sleep(300); + if (ptyStatus.alive) safeKill(ptyProcess); + } + hookSink.cleanup(); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +interface TurnState { + claudeSessionId: string | null; + lastAssistantText: string | null; + costUsd: number | null; + numTurns: number; + scheduledWakeup: { delayMs: number; reason: string | null } | null; + processedEventCount: number; + jsonlPath: string | null; + jsonlReadOffset: number; + remoteControlUrl: string | null; +} + +function encodeCwdForClaude(cwd: string): string { + // Claude Code replaces every non-alphanumeric character with `-`. + return cwd.replace(/[^a-zA-Z0-9]/g, "-"); +} + +function ensureDir(p: string): void { + try { + fs.mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } +} + +function listJsonl(dir: string): string[] { + try { + return fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + } catch { + return []; + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function safeKill(p: IPty): void { + try { + p.kill(); + } catch { + /* ignore */ + } +} + +function buildSettingsJson(hookCommand: (event: string) => string): string { + const settings = { + hooks: { + Stop: [ + { + matcher: "", + hooks: [{ type: "command", command: hookCommand("Stop") }], + }, + ], + // SessionStart captures Claude's runtime `session_id` so the + // daemon can unambiguously identify which JSONL file this + // spawn owns, even when multiple PTY sessions start + // concurrently in the same cwd (P1 review finding). + SessionStart: [ + { + matcher: "", + hooks: [{ type: "command", command: hookCommand("SessionStart") }], + }, + ], + }, + }; + return JSON.stringify(settings); +} + +// ----------------------------------------------------------------------------- +// Hook sink — a Node.js helper script the Stop / SessionStart hooks +// invoke. It appends hook payloads to a per-session event log and +// records the runtime `session_id` that SessionStart carries. The +// daemon reads the log to: +// 1. flip `hasStopEvent()` when Claude finishes a turn +// 2. look up the authoritative JSONL filename (`.jsonl`) +// so concurrent PTY sessions in the same cwd never tail each +// other's transcripts (was a P1 review finding). +// +// Using Node (instead of POSIX `sh`) gives us a single script that +// works on Windows as well — Claude Code itself is a Node CLI so +// `node` is always on PATH for any environment where the daemon can +// launch `claude`. +// ----------------------------------------------------------------------------- + +interface HookSink { + hookCommand: (event: string) => string; + hasStopEvent(): boolean; + readRuntimeSessionId(): string | null; + cleanup(): void; +} + +function createHookSink(sessionId: string): HookSink { + const tmpDir = path.join(os.tmpdir(), "superset-todo-pty"); + try { + fs.mkdirSync(tmpDir, { recursive: true }); + } catch { + /* ignore */ + } + const stamp = `${sessionId}-${Date.now()}`; + const eventsPath = path.join(tmpDir, `hook-${stamp}.log`); + const sessionIdPath = path.join(tmpDir, `hook-${stamp}.session-id`); + const scriptPath = path.join(tmpDir, `hook-${stamp}.js`); + try { + fs.writeFileSync(eventsPath, ""); + } catch { + /* ignore */ + } + + // Cross-platform Node hook runner. It reads stdin (the hook + // payload JSON), appends an NDJSON line to the events log, and — + // when Claude's runtime session_id is in the payload — records it + // to a sibling file the daemon polls during JSONL discovery. + const script = `#!/usr/bin/env node +const fs = require('fs'); +const EVENT = process.argv[2] || ''; +const EVENTS_LOG = ${JSON.stringify(eventsPath)}; +const SESSION_ID_FILE = ${JSON.stringify(sessionIdPath)}; +let chunks = []; +process.stdin.on('data', (c) => { chunks.push(c); }); +process.stdin.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + try { + fs.appendFileSync(EVENTS_LOG, JSON.stringify({ event: EVENT, input: raw }) + '\\n'); + } catch (_) { /* ignore */ } + try { + const payload = JSON.parse(raw); + const sid = payload && typeof payload === 'object' ? payload.session_id : null; + if (typeof sid === 'string' && sid.length > 0) { + fs.writeFileSync(SESSION_ID_FILE, sid); + } + } catch (_) { /* non-JSON payload, ignore */ } + process.exit(0); +}); +process.stdin.on('error', () => process.exit(0)); +`; + try { + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + } catch (err) { + console.warn("[todo-daemon:pty] failed to write hook script:", err); + } + + // Quote-safe command string accepted by Claude Code hook runner + // (spawned through a shell on all platforms). The script path may + // contain spaces on macOS ("/Users/x y/..."), so wrap it in + // double-quotes and escape embedded quotes. + const quotedScript = `"${scriptPath.replace(/"/g, '\\"')}"`; + const hookCommand = (event: string) => `node ${quotedScript} ${event}`; + + return { + hookCommand, + hasStopEvent: () => { + try { + return fs.readFileSync(eventsPath, "utf8").includes('"event":"Stop"'); + } catch { + return false; + } + }, + readRuntimeSessionId: () => { + try { + const raw = fs.readFileSync(sessionIdPath, "utf8").trim(); + return raw.length > 0 ? raw : null; + } catch { + return null; + } + }, + cleanup: () => { + for (const p of [scriptPath, eventsPath, sessionIdPath]) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + }, + }; +} + +// ----------------------------------------------------------------------------- +// TUI ready detection +// ----------------------------------------------------------------------------- + +async function waitForTuiReady( + getBuffer: () => string, + isAlive: () => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastLen = 0; + let stableAt = Date.now(); + while (Date.now() < deadline) { + await sleep(200); + if (!isAlive()) return false; + const buf = getBuffer(); + if (buf.length === lastLen) { + if (Date.now() - stableAt >= TUI_READY_IDLE_MS) return true; + } else { + lastLen = buf.length; + stableAt = Date.now(); + } + } + return false; +} + +// ----------------------------------------------------------------------------- +// /remote-control flow +// ----------------------------------------------------------------------------- + +async function activateRemoteControl( + ptyProc: IPty, + getBuffer: () => string, + onUrl: (url: string) => void, + onError: (msg: string) => void, +): Promise { + const bufferLenBefore = getBuffer().length; + try { + ptyProc.write("/remote-control\r"); + } catch (err) { + onError( + `/remote-control の送信に失敗: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return; + } + const deadline = Date.now() + REMOTE_CONTROL_URL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(250); + const snippet = getBuffer().slice(bufferLenBefore); + const cleaned = stripAnsi(snippet); + const m = cleaned.match(REMOTE_CONTROL_URL_RE); + if (m?.[0]) { + onUrl(m[0]); + return; + } + const errM = cleaned.match( + /Remote Control [^\n]*(?:requires|disabled|not enabled|not yet enabled|failed)[^\n]*/i, + ); + if (errM) { + onError(errM[0].trim()); + return; + } + } + onError("Remote Control の URL を取得できませんでした (タイムアウト)"); +} + +function stripAnsi(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping real ANSI escapes from PTY output is the whole point + const csi = /\x1b\[[0-9;?]*[A-Za-z]/g; + // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC terminator BEL (0x07) is the spec-defined end of an OSC sequence + const osc = /\x1b\][^\x07]*\x07/g; + return s.replace(csi, "").replace(osc, ""); +} + +// ----------------------------------------------------------------------------- +// JSONL tail +// ----------------------------------------------------------------------------- + +async function tailJsonl( + state: TurnState, + params: PtyTurnParams, +): Promise { + if (!state.jsonlPath) return; + let stat: fs.Stats; + try { + stat = fs.statSync(state.jsonlPath); + } catch { + return; + } + if (stat.size <= state.jsonlReadOffset) return; + const len = stat.size - state.jsonlReadOffset; + let buf: Buffer; + let fd: number | null = null; + try { + fd = fs.openSync(state.jsonlPath, "r"); + buf = Buffer.alloc(len); + fs.readSync(fd, buf, 0, len, state.jsonlReadOffset); + } catch { + // `finally` below handles cleanup — returning here without a + // separate `closeSync` avoids the double-close that would + // otherwise risk closing an unrelated fd if the descriptor + // was recycled between the two closes (CodeRabbit review). + return; + } finally { + if (fd != null) { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } + } + // Find the last newline at the raw-byte level so we never split + // on a UTF-8 multibyte boundary. `Buffer.toString("utf8")` would + // replace a truncated tail with U+FFFD and make + // `Buffer.byteLength(lastLine)` disagree with the underlying bytes — + // which breaks Japanese content in verdict reasons etc. + // (CodeRabbit review). + const lastNewlineIdx = buf.lastIndexOf(0x0a); + if (lastNewlineIdx < 0) { + // No newline in this chunk — defer everything until we see one. + return; + } + const consumedBytes = lastNewlineIdx + 1; + const text = buf.subarray(0, consumedBytes).toString("utf8"); + state.jsonlReadOffset += consumedBytes; + const lines = text.split("\n"); + const events: TodoStreamEvent[] = []; + for (const line of lines) { + if (!line.trim()) continue; + let payload: unknown; + try { + payload = JSON.parse(line); + } catch { + continue; + } + const classified = classifyJsonlRecord(payload); + if (classified.sessionId && !state.claudeSessionId) { + state.claudeSessionId = classified.sessionId; + } + if (classified.scheduledWakeup) { + state.scheduledWakeup = classified.scheduledWakeup; + } + if (classified.assistantText) { + state.lastAssistantText = classified.assistantText; + } + if (classified.usage) { + state.numTurns += 1; + } + for (const e of classified.events) { + events.push({ + id: randomUUID(), + ts: Date.now(), + iteration: params.iteration, + kind: e.kind, + label: e.label, + text: e.text, + toolUseId: e.toolUseId, + parentToolUseId: e.parentToolUseId, + }); + } + } + if (events.length > 0) { + getTodoSessionStore().appendStreamEvents(params.sessionId, events); + } +} + +// ----------------------------------------------------------------------------- +// JSONL record classifier +// ----------------------------------------------------------------------------- + +interface ClassifiedJsonlRecord { + sessionId: string | null; + assistantText: string | null; + usage: boolean; + scheduledWakeup: { delayMs: number; reason: string | null } | null; + events: Array<{ + kind: TodoStreamEventKind; + label: string; + text: string; + toolUseId?: string; + parentToolUseId?: string; + }>; +} + +function classifyJsonlRecord(payload: unknown): ClassifiedJsonlRecord { + const empty: ClassifiedJsonlRecord = { + sessionId: null, + assistantText: null, + usage: false, + scheduledWakeup: null, + events: [], + }; + if (typeof payload !== "object" || payload === null) return empty; + const rec = payload as Record; + const type = typeof rec.type === "string" ? (rec.type as string) : ""; + // Claude Code's transcript format has varied between camelCase + // (`sessionId`, `parentToolUseId`) and snake_case (`session_id`, + // `parent_tool_use_id`) across versions. Read both so the parser + // stays correct regardless of which shape the installed CLI + // emits — otherwise sessionId ends up null and the daemon has no + // way to bind to the right JSONL (CodeRabbit review finding). + const sessionId = + typeof rec.sessionId === "string" + ? (rec.sessionId as string) + : typeof rec.session_id === "string" + ? (rec.session_id as string) + : null; + const parentToolUseId = + typeof rec.parentToolUseId === "string" + ? (rec.parentToolUseId as string) + : typeof rec.parent_tool_use_id === "string" + ? (rec.parent_tool_use_id as string) + : undefined; + + if (type === "assistant") { + const msg = rec.message as { content?: unknown } | undefined; + const text = extractText(msg?.content); + const tool = extractToolUse(msg?.content); + const wakeup = extractScheduledWakeup(msg?.content); + const hasUsage = + typeof msg === "object" && + msg !== null && + typeof (msg as { usage?: unknown }).usage === "object"; + const events: ClassifiedJsonlRecord["events"] = []; + if (text) { + events.push({ + kind: "assistant_text", + label: "Claude", + text, + parentToolUseId, + }); + } + if (tool) { + events.push({ + kind: "tool_use", + label: tool.label, + text: tool.text, + toolUseId: tool.id, + parentToolUseId, + }); + } + return { + sessionId, + assistantText: text, + usage: hasUsage, + scheduledWakeup: wakeup, + events, + }; + } + + if (type === "user") { + const msg = rec.message as { content?: unknown } | undefined; + const result = extractToolResult(msg?.content); + if (result) { + return { + ...empty, + sessionId, + events: [ + { + kind: "tool_result", + label: "tool result", + text: truncate(result.text, 400), + toolUseId: result.toolUseId, + parentToolUseId, + }, + ], + }; + } + return empty; + } + + if (type === "system") { + const subtype = + typeof rec.subtype === "string" ? (rec.subtype as string) : ""; + if (subtype === "init") { + return { + ...empty, + sessionId, + events: [ + { + kind: "system_init", + label: "init", + text: `session ${sessionId ?? "?"} 準備完了`, + }, + ], + }; + } + return empty; + } + + return empty; +} + +function extractText(content: unknown): string | null { + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const rec = part as Record; + if (rec.type === "text" && typeof rec.text === "string") { + parts.push(rec.text as string); + } + } + const joined = parts.join("").trim(); + return joined.length > 0 ? joined : null; +} + +function extractToolUse( + content: unknown, +): { label: string; text: string; id: string | undefined } | null { + if (!Array.isArray(content)) return null; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const rec = part as Record; + if (rec.type !== "tool_use") continue; + const name = typeof rec.name === "string" ? (rec.name as string) : "tool"; + const id = typeof rec.id === "string" ? (rec.id as string) : undefined; + const input = rec.input; + return { label: name, text: summarizeToolInput(name, input), id }; + } + return null; +} + +function extractScheduledWakeup( + content: unknown, +): { delayMs: number; reason: string | null } | null { + if (!Array.isArray(content)) return null; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const rec = part as Record; + if (rec.type !== "tool_use") continue; + if (rec.name !== "ScheduleWakeup") continue; + const input = rec.input; + if (typeof input !== "object" || input === null) continue; + const inp = input as Record; + const delaySeconds = + typeof inp.delaySeconds === "number" + ? (inp.delaySeconds as number) + : null; + if (delaySeconds == null || !Number.isFinite(delaySeconds)) continue; + const seconds = Math.floor(delaySeconds); + if (seconds < 60 || seconds > 3600) continue; + const reason = + typeof inp.reason === "string" ? (inp.reason as string) : null; + return { delayMs: seconds * 1000, reason }; + } + return null; +} + +function extractToolResult( + content: unknown, +): { text: string; toolUseId: string | undefined } | null { + if (!Array.isArray(content)) return null; + const parts: string[] = []; + let toolUseId: string | undefined; + let saw = false; + let imageCount = 0; + let otherCount = 0; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const rec = part as Record; + if (rec.type !== "tool_result") continue; + saw = true; + if (!toolUseId && typeof rec.tool_use_id === "string") { + toolUseId = rec.tool_use_id as string; + } + const inner = rec.content; + if (typeof inner === "string") { + parts.push(inner); + } else if (Array.isArray(inner)) { + for (const p of inner) { + if (typeof p !== "object" || p === null) continue; + const pr = p as Record; + if (pr.type === "text" && typeof pr.text === "string") { + parts.push(pr.text as string); + } else if (pr.type === "image") { + imageCount += 1; + } else if (typeof pr.type === "string") { + otherCount += 1; + } + } + } + } + if (!saw) return null; + const joined = parts.join("\n").trim(); + if (joined.length > 0) return { text: joined, toolUseId }; + const summary: string[] = []; + if (imageCount > 0) { + summary.push(imageCount === 1 ? "[画像 1 件]" : `[画像 ${imageCount} 件]`); + } + if (otherCount > 0) { + summary.push(`[非テキストブロック ${otherCount} 件]`); + } + return { + text: summary.length > 0 ? summary.join(" ") : "(空の結果)", + toolUseId, + }; +} + +function summarizeToolInput(name: string, input: unknown): string { + if (typeof input !== "object" || input === null) return name; + const rec = input as Record; + const key = + typeof rec.command === "string" + ? (rec.command as string) + : typeof rec.file_path === "string" + ? (rec.file_path as string) + : typeof rec.path === "string" + ? (rec.path as string) + : typeof rec.pattern === "string" + ? (rec.pattern as string) + : typeof rec.description === "string" + ? (rec.description as string) + : null; + return key ? truncate(`${name}: ${key}`, 300) : name; +} + +function truncate(text: string, cap: number): string { + if (text.length <= cap) return text; + return `${text.slice(0, cap)}…`; +} + +// ----------------------------------------------------------------------------- +// Stream event append helpers +// ----------------------------------------------------------------------------- + +function appendRawEvent( + sessionId: string, + iteration: number, + kind: TodoStreamEventKind, + label: string, + text: string, +): void { + getTodoSessionStore().appendStreamEvents(sessionId, [ + { + id: randomUUID(), + ts: Date.now(), + iteration, + kind, + label, + text, + }, + ]); +} + +function ptyExitError( + state: TurnState, + exit: { exitCode: number; signal?: number } | null, + ptyBuffer: string, +): PtyTurnResult { + const tail = stripAnsi(ptyBuffer).split("\n").slice(-8).join("\n").trim(); + return { + result: state.lastAssistantText, + sessionId: state.claudeSessionId, + costUsd: state.costUsd, + numTurns: state.numTurns || null, + error: `claude (PTY) が exit code ${exit?.exitCode ?? "?"} で終了しました${ + tail ? `:\n${tail}` : "" + }`, + interrupted: false, + scheduledWakeup: state.scheduledWakeup, + }; +} + +// `extractAttachmentPaths` is exported to keep the same affordance +// supervisor-engine offers; callers can pre-inspect a prompt for +// attachment chips without duplicating the regex. +export function extractAttachmentPaths( + texts: (string | null | undefined)[], +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const text of texts) { + if (!text) continue; + for (const m of text.matchAll(ATTACHMENT_PATH_RE)) { + const p = m[1]; + if (!p || seen.has(p)) continue; + seen.add(p); + out.push(p); + } + } + return out; +} diff --git a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts index 9d590758491..98fb3851981 100644 --- a/apps/desktop/src/main/todo-daemon/supervisor-engine.ts +++ b/apps/desktop/src/main/todo-daemon/supervisor-engine.ts @@ -12,6 +12,19 @@ import { CLAUDE_MODEL_OPTIONS, type TodoStreamEventKind, } from "main/todo-agent/types"; +import { runClaudeTurnPty } from "./pty-turn-runner"; + +/** + * Feature flag for the interactive PTY engine. When the daemon process + * is launched with `TODO_ENGINE=pty`, `runClaudeTurn` dispatches to the + * PTY runner (apps/desktop/src/main/todo-daemon/pty-turn-runner.ts) + * which supports Remote Control. Otherwise, the legacy `-p` headless + * path is used. The flag is process-wide (not per-session) because it + * governs which spawn path the daemon knows how to manage; Remote + * Control itself is still opt-in per session via + * `todo_sessions.remote_control_enabled`. + */ +const PTY_ENGINE_ENABLED = process.env.TODO_ENGINE === "pty"; /** * Daemon-side supervisor engine. Spawns `claude -p` children for TODO @@ -253,11 +266,33 @@ export class TodoSupervisorEngine { parts.push(`effort: ${session0.claudeEffort}`); appendSetupEvent(sessionId, "Claude 設定", parts.join(" / ")); } + const willUsePty = + PTY_ENGINE_ENABLED || Boolean(session0.remoteControlEnabled); appendSetupEvent( sessionId, "Claude", - "claude -p --output-format stream-json を起動します", + willUsePty + ? "claude を PTY (interactive) モードで起動します" + : "claude -p --output-format stream-json を起動します", ); + if (session0.remoteControlEnabled) { + appendSetupEvent( + sessionId, + "Remote Control", + "有効 (PTY モード)。起動後に接続 URL を発行します。", + ); + } + if (willUsePty) { + // PTY 経路は Claude Code JSONL に cost_usd が載らない + // ため totalCostUsd の集計は当面行われません。ユーザー + // 可観測性のためセットアップバナーに明示します + // (CodeRabbit review #278)。 + appendSetupEvent( + sessionId, + "計測", + "PTY モードではコスト (USD) の集計が無効化されます。ターン数は計測されます。", + ); + } const preservedClaudeSessionId = isResumingPastRun ? (session0.claudeSessionId ?? null) @@ -383,6 +418,7 @@ export class TodoSupervisorEngine { claudeModel: currentSession.claudeModel ?? null, claudeEffort: currentSession.claudeEffort ?? null, signal: ac.signal, + remoteControlEnabled: Boolean(currentSession.remoteControlEnabled), onChild: (child) => { run.currentChild = child; }, @@ -572,7 +608,59 @@ export class TodoSupervisorEngine { } } - private runClaudeTurn(params: { + private async runClaudeTurn(params: { + sessionId: string; + iteration: number; + cwd: string; + prompt: string; + resumeSessionId: string | null; + customSystemPrompt: string | null; + claudeModel: string | null; + claudeEffort: string | null; + signal: AbortSignal; + onChild: (child: ChildProcess) => void; + remoteControlEnabled: boolean; + }): Promise<{ + result: string | null; + sessionId: string | null; + costUsd: number | null; + numTurns: number | null; + error: string | null; + interrupted: boolean; + scheduledWakeup: { delayMs: number; reason: string | null } | null; + }> { + // The PTY engine is the only path that can drive `/remote-control`. + // We therefore dispatch to it whenever the daemon is running in + // PTY mode OR the session asked for Remote Control — the latter + // is a defensive fallback for when a user checks the box before + // the env flag is set, so the feature does not silently no-op. + if (PTY_ENGINE_ENABLED || params.remoteControlEnabled) { + return runClaudeTurnPty({ + sessionId: params.sessionId, + iteration: params.iteration, + cwd: params.cwd, + prompt: params.prompt, + resumeSessionId: params.resumeSessionId, + customSystemPrompt: params.customSystemPrompt, + claudeModel: params.claudeModel, + claudeEffort: params.claudeEffort, + signal: params.signal, + remoteControlEnabled: params.remoteControlEnabled, + // The legacy caller only knows how to track a + // ChildProcess-shaped handle. The PTY runner hands + // back an opaque handle plus an `onExit` subscription; + // wrap both into a shim so `abort()` and its + // `once("close", ...)` SIGKILL-cancel path keep + // working. + onChild: (handle) => { + params.onChild(buildChildProcessShim(handle)); + }, + }); + } + return this.runClaudeTurnHeadless(params); + } + + private runClaudeTurnHeadless(params: { sessionId: string; iteration: number; cwd: string; @@ -828,6 +916,92 @@ export class TodoSupervisorEngine { // Helpers // ============================================================================ +/** + * Thin ChildProcess façade over the opaque `{ pid, kill }` handle the + * PTY runner hands back. The supervisor only ever touches `.pid`, + * `.kill`, and `.exitCode` / `.signalCode` on its recorded child — it + * never reads stdout/stderr from this reference (those are consumed + * inside the PTY runner itself). The shim stubs out the rest as a + * minimal EventEmitter-free façade so TypeScript accepts it in place + * of the real ChildProcess. + */ +function buildChildProcessShim(handle: { + pid: number | null; + kill: () => void; + /** + * Register a callback the PTY runner invokes on spawn exit. The + * supervisor's abort path records a `once("close", ...)` listener + * to clear its 1.5s SIGKILL fallback timer — without an exit + * notification the timer always fires even when the PTY died + * cleanly, which is a best-effort `kill(-pid, SIGKILL)` against a + * potentially recycled PID (CodeRabbit review). + */ + onExit: (cb: () => void) => void; +}): ChildProcess { + let killed = false; + const closeListeners = new Set<() => void>(); + const shim = { + pid: handle.pid ?? undefined, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + kill: (_signal?: NodeJS.Signals | number): boolean => { + if (killed) return true; + killed = true; + try { + handle.kill(); + shim.signalCode = "SIGTERM" as NodeJS.Signals; + } catch { + /* ignore */ + } + return true; + }, + once: (event: string, listener: (...args: unknown[]) => void) => { + if (event === "close" || event === "exit") { + const wrapped = () => { + closeListeners.delete(wrapped); + try { + listener(); + } catch { + /* ignore */ + } + }; + closeListeners.add(wrapped); + } + return shim; + }, + on: (event: string, listener: (...args: unknown[]) => void) => { + if (event === "close" || event === "exit") { + const wrapped = () => { + try { + listener(); + } catch { + /* ignore */ + } + }; + closeListeners.add(wrapped); + } + return shim; + }, + off: (_event: string, _listener: (...args: unknown[]) => void) => shim, + removeListener: (_event: string, _listener: (...args: unknown[]) => void) => + shim, + removeAllListeners: (_event?: string) => shim, + emit: (_event: string, ..._args: unknown[]) => false, + }; + handle.onExit(() => { + // Mark terminated so the supervisor's abort path's check + // `child.exitCode == null && child.signalCode == null` stops + // being universally true, and fire listeners in-order. + if (shim.exitCode == null) shim.exitCode = 0; + for (const cb of Array.from(closeListeners)) cb(); + }); + // The supervisor's abort path only reaches into `.pid` and `.kill()`. + // Cast through `unknown` to sidestep the structural mismatch; the + // shim's surface area is deliberately minimal and the daemon never + // inspects streams on this reference. + return shim as unknown as ChildProcess; +} + function killProcessTree(pid: number, signal: NodeJS.Signals): void { if (process.platform === "win32") { try { diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx index 3b3313b83ec..dcf745c9189 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx @@ -2253,6 +2253,19 @@ function MessageRow({ event }: { event: TodoStreamEvent }) { ); } + if (event.kind === "remote_control") { + return ; + } + if (event.kind === "remote_control_error") { + return ( +
+ + Remote Control エラー + + {event.text} +
+ ); + } if (event.kind === "system_init") { return (
@@ -2281,6 +2294,41 @@ function MessageRow({ event }: { event: TodoStreamEvent }) { ); } +function RemoteControlBadge({ event }: { event: TodoStreamEvent }) { + // `event.text` is `接続 URL: https://claude.ai/code/session_...` + const urlMatch = event.text.match( + /https:\/\/claude\.ai\/code\/session_[A-Za-z0-9_-]+/, + ); + const url = urlMatch?.[0]; + // Renderer cannot reach outside the Electron sandbox via + // `window.open` (it opens in an internal BrowserWindow or no-ops + // depending on the window-open handler). Go through the existing + // `external.openUrl` tRPC so the URL hits `shell.openExternal` in + // the main process. + const openUrl = electronTrpc.external.openUrl.useMutation(); + return ( +
+ + Remote Control 接続中 + + {url ? ( + + ) : ( + {event.text} + )} +
+ ); +} + function _formatClock(ms: number): string { const d = new Date(ms); const pad = (n: number) => n.toString().padStart(2, "0"); diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx index ef51ca59a5f..26e4316d8a7 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx @@ -46,6 +46,7 @@ const DEFAULT_VERIFY_COMMAND = ""; const DEFAULT_MAX_ITERATIONS = 10; const DEFAULT_MAX_MINUTES = 30; const DEFAULT_CREATE_WORKTREE = false; +const DEFAULT_REMOTE_CONTROL = false; /** * Creation form for a new TODO autonomous session. Collects the minimum @@ -72,6 +73,9 @@ export function TodoModal({ const [maxMinutes, setMaxMinutes] = useState(DEFAULT_MAX_MINUTES); const [submitting, setSubmitting] = useState(false); const [createWorktree, setCreateWorktree] = useState(DEFAULT_CREATE_WORKTREE); + const [remoteControlEnabled, setRemoteControlEnabled] = useState( + DEFAULT_REMOTE_CONTROL, + ); const [selectedPresetId, setSelectedPresetId] = useState(null); const [claudeModel, setClaudeModel] = useState(DEFAULT_SENTINEL); @@ -129,6 +133,7 @@ export function TodoModal({ ); setMaxMinutes(todoSettings?.defaultMaxWallClockMin ?? DEFAULT_MAX_MINUTES); setCreateWorktree(DEFAULT_CREATE_WORKTREE); + setRemoteControlEnabled(DEFAULT_REMOTE_CONTROL); setSelectedPresetId(null); setClaudeModel( fromPersistedModel(todoSettings?.defaultClaudeModel ?? null), @@ -198,6 +203,7 @@ export function TodoModal({ customSystemPrompt: selectedPreset?.content ?? undefined, claudeModel: toPersistedModel(claudeModel), claudeEffort: toPersistedEffort(claudeEffort), + remoteControlEnabled, }); if (createWorktree) { toast.success( @@ -229,6 +235,7 @@ export function TodoModal({ maxIterations, maxMinutes, projectId, + remoteControlEnabled, selectedPreset, title, verifyCommand, @@ -278,6 +285,33 @@ export function TodoModal({ + +
diff --git a/packages/local-db/drizzle/0059_todo_remote_control_enabled.sql b/packages/local-db/drizzle/0059_todo_remote_control_enabled.sql new file mode 100644 index 00000000000..c54c6487441 --- /dev/null +++ b/packages/local-db/drizzle/0059_todo_remote_control_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE `todo_sessions` ADD `remote_control_enabled` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0059_snapshot.json b/packages/local-db/drizzle/meta/0059_snapshot.json new file mode 100644 index 00000000000..9c1b5d5c027 --- /dev/null +++ b/packages/local-db/drizzle/meta/0059_snapshot.json @@ -0,0 +1,2209 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "17542ad1-f8b1-4ffa-af48-59dac8f708dd", + "prevId": "a8ed241a-41cd-4941-bc29-4e9ee916a858", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_site_permissions": { + "name": "browser_site_permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_site_permissions_origin_idx": { + "name": "browser_site_permissions_origin_idx", + "columns": [ + "origin" + ], + "isUnique": false + }, + "browser_site_permissions_origin_kind_unique": { + "name": "browser_site_permissions_origin_kind_unique", + "columns": [ + "origin", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prevent_agent_sleep": { + "name": "prevent_agent_sleep", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_sidebar_open_view_width": { + "name": "right_sidebar_open_view_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_enabled": { + "name": "indent_rainbow_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_colors": { + "name": "indent_rainbow_colors", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_enabled": { + "name": "trailing_spaces_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_color": { + "name": "trailing_spaces_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_graph_enabled": { + "name": "reference_graph_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expose_host_service_via_relay": { + "name": "expose_host_service_via_relay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_smart_commit": { + "name": "enable_smart_commit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smart_commit_changes": { + "name": "smart_commit_changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_stash": { + "name": "auto_stash", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_sort_order": { + "name": "branch_sort_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_default_branch": { + "name": "pin_default_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_commit_command": { + "name": "post_commit_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_superset": { + "name": "created_by_superset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_prompt_presets": { + "name": "todo_prompt_presets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "todo_prompt_presets_name_idx": { + "name": "todo_prompt_presets_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "todo_prompt_presets_updated_at_idx": { + "name": "todo_prompt_presets_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "todo_prompt_presets_kind_idx": { + "name": "todo_prompt_presets_kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + }, + "todo_prompt_presets_workspace_idx": { + "name": "todo_prompt_presets_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_schedules": { + "name": "todo_schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minute": { + "name": "minute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hour": { + "name": "hour", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthday": { + "name": "monthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_expr": { + "name": "cron_expr", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verify_command": { + "name": "verify_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_iterations": { + "name": "max_iterations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "max_wall_clock_sec": { + "name": "max_wall_clock_sec", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1800 + }, + "custom_system_prompt": { + "name": "custom_system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_model": { + "name": "claude_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_effort": { + "name": "claude_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overlap_mode": { + "name": "overlap_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'skip'" + }, + "auto_sync_before_fire": { + "name": "auto_sync_before_fire", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_session_id": { + "name": "last_run_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "todo_schedules_project_idx": { + "name": "todo_schedules_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "todo_schedules_workspace_idx": { + "name": "todo_schedules_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "todo_schedules_enabled_next_run_idx": { + "name": "todo_schedules_enabled_next_run_idx", + "columns": [ + "enabled", + "next_run_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "todo_schedules_project_id_projects_id_fk": { + "name": "todo_schedules_project_id_projects_id_fk", + "tableFrom": "todo_schedules", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "todo_schedules_workspace_id_workspaces_id_fk": { + "name": "todo_schedules_workspace_id_workspaces_id_fk", + "tableFrom": "todo_schedules", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_sessions": { + "name": "todo_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verify_command": { + "name": "verify_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_iterations": { + "name": "max_iterations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "max_wall_clock_sec": { + "name": "max_wall_clock_sec", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1800 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attached_pane_id": { + "name": "attached_pane_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attached_tab_id": { + "name": "attached_tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_session_id": { + "name": "claude_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_assistant_text": { + "name": "final_assistant_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_num_turns": { + "name": "total_num_turns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_intervention": { + "name": "pending_intervention", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_head_sha": { + "name": "start_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_system_prompt": { + "name": "custom_system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_model": { + "name": "claude_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_effort": { + "name": "claude_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_passed": { + "name": "verdict_passed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_reason": { + "name": "verdict_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_failing_test": { + "name": "verdict_failing_test", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_path": { + "name": "artifact_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_control_enabled": { + "name": "remote_control_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "waiting_until": { + "name": "waiting_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_reason": { + "name": "waiting_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "todo_sessions_workspace_idx": { + "name": "todo_sessions_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "todo_sessions_status_idx": { + "name": "todo_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "todo_sessions_created_at_idx": { + "name": "todo_sessions_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "todo_sessions_project_id_projects_id_fk": { + "name": "todo_sessions_project_id_projects_id_fk", + "tableFrom": "todo_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "todo_sessions_workspace_id_workspaces_id_fk": { + "name": "todo_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "todo_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index bb3bb895d1c..53b0596af95 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -414,6 +414,13 @@ "when": 1776378635928, "tag": "0058_todo_claude_model_effort", "breakpoints": true + }, + { + "idx": 59, + "version": "6", + "when": 1776435014980, + "tag": "0059_todo_remote_control_enabled", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/todo-sessions.ts b/packages/local-db/src/schema/todo-sessions.ts index 0127eb51029..b8ac2930e13 100644 --- a/packages/local-db/src/schema/todo-sessions.ts +++ b/packages/local-db/src/schema/todo-sessions.ts @@ -103,6 +103,17 @@ export const todoSessions = sqliteTable( artifactPath: text("artifact_path").notNull(), + // When true, the daemon starts the Claude Code worker in the PTY + // engine (apps/desktop/src/main/todo-daemon/pty-turn-runner.ts) and + // sends `/remote-control` after spawn so the session is reachable + // from claude.ai/code and the Claude mobile app. Only effective + // when the daemon is running with TODO_ENGINE=pty; the UI disables + // the toggle when the flag is off. See + // apps/desktop/plans/20260417-todo-agent-remote-control.md. + remoteControlEnabled: integer("remote_control_enabled", { mode: "boolean" }) + .notNull() + .default(false), + // Populated when the session is in the `waiting` state — i.e. the // underlying Claude Code worker called `ScheduleWakeup` (or another // self-pacing primitive) to pause itself until a specific wall-clock