feat(desktop): TODO Agent に Crush CLI 対応を追加#389
Conversation
TODO Agent の自律実行エンジンを拡張し、Charmbracelet Crush CLI (`crush run`) を バックエンドとして選択可能にした。Claude Code / Codex CLI に続く3つ目のエージェント対応。 Crush は stdout に構造化ストリーミングを出力しないため、プロジェクトローカルの SQLite データベース (`<project>/.crush/crush.db`) をポーリングしてメッセージを 取得し、既存の TodoStreamEvent 形式に変換する方式を採用。 Claude PTY runner の JSONL ポーリングと同じパターン。 モデル選択は `crush models` の出力(276+)を動的フェッチし、 provider ごとにグループ化して UI に表示。ハードコードなし。 ## 変更点 ### コアロジック - `types.ts` — `AgentKind` enum に `"crush"` を追加。`crushModel` フィールドを `todoCreateInputSchema` / `todoSettingsSchema` に追加 - `crush-turn-runner.ts` (新規) — `crush run --yolo` を spawn → `.crush/crush.db` を 250ms 間隔でポーリング → messages テーブルの parts JSON をパースして TodoStreamEvent に変換。system_init / assistant_text / tool_use / tool_result / result / error の全 kind をマッピング - `supervisor-engine.ts` — `runAgentTurn()` に `agentKind === "crush"` ルートを追加 ### データ層 - DB schema — `todo_sessions` / `todo_schedules` に `crush_model` カラムを追加 - マイグレーション — `0069_add_crush_model.sql` 自動生成 - session-store / tRPC router — 作成・再実行時に crushModel を永続化 - tRPC — `crushModels` クエリ追加(`crush models` の出力をキャッシュ付きで返す) ### UI - `AgentRuntimePicker.tsx` — Agent セレクタに "Crush" を追加。Crush 選択時は モデルのみのセクションを表示(Effort は Crush CLI に概念がないため省略)。 モデルリストは provider ごとにグループ化 - `claudeRuntimeOptions.ts` — `CrushModelPick` 型 + 永続化ヘルパー - `TodoModal.tsx` — `crushModel` state + `crushModels` フェッチ + 送信 ## 設計上の決定 - **後方互換性**: `agent_kind` のデフォルトは引き続き `"claude"`。 既存セッションへの影響なし - **SQLite ポーリング**: Crush は NDJSON 出力をサポートしないため、DB ポーリング を選択。Claude PTY runner の JSONL ポーリングと同じアプローチ - **動的モデルリスト**: 276+ のモデルをハードコードせず `crush models` から動的取得 - **Effort なし**: Crush CLI に effort/reasoning の概念がないため省略 💘 Generated with Crush Assisted-by: GLM-5 via Crush <crush@charm.land>
`pollDbForEvents` の `settled` が `const false` だったため、正常終了時に `await pollPromise` が永久にブロックしセッションが running でスタックしていた。 修正: - `settled` を `let` に変更し、DB で `finish` part (end_turn/stop) を 検知したら `settled = true` にする - `isChildExited` コールバックを追加し、子プロセス終了後もポーリングが 停止するようにした(DB に finish が書かれないエラー/シグナルケース対応) Codex review P1 指摘への対応。 💘 Generated with Crush Assisted-by: GLM-5 via Crush <crush@charm.land>
- crush-turn-runner: `safeParseJson` で `Array.isArray` チェックを追加し、 非 JSON 配列が渡った際の TypeError を防止 - crush-turn-runner: `killProcess` で `child.pid` の undefined ガードを追加 - crush-turn-runner: `cost` 取得時の NULL チェックを追加 (`typeof === "number" && Number.isFinite`) - supervisor-engine: `(currentSession as any).crushModel` の `as any` を削除 - trpc-router: `(source as any).crushModel` の `as any` を削除 - trpc-router: `crushModels` クエリに TTL キャッシュ (5分) と inflight 重複排除を追加 💘 Generated with Crush Assisted-by: GLM-5 via Crush <crush@charm.land>
PresetsDialog (設定タブ) と TodoManager (インラインコンポーザー) に Crush CLI 向けモデル選択 UI を追加。PR #387 で Codex に対応した 両コンポーネントで Crush の抜けがあったのを補完。 💘 Generated with Crush Assisted-by: GLM-5 via Crush <crush@charm.land>
📝 WalkthroughWalkthroughこのPRは、Crush CLIエージェントの新しいサポートを追加します。Crushモデル選択、セッション永続化、ターンランナー実装、DBスキーマ更新、およびUIコンポーネント統合を含む包括的な統合です。 Changes
Sequence Diagram(s)sequenceDiagram
participant UI as UI (TodoModal)
participant Router as tRPC Router
participant Store as Session Store
participant DB as Database
participant Daemon as Supervisor Engine
participant CrushRunner as Crush Turn Runner
participant CrushCLI as Crush CLI
participant CrushDB as .crush/crush.db
UI->>Router: todoAgent.crushModels.query()
Router->>CrushCLI: spawn crush CLI
CrushCLI-->>Router: models list (cached 5min)
Router-->>UI: available models
UI->>Router: todoAgent.create({crushModel, agentKind: "crush"})
Router->>Router: resolve crushModel from input/settings
Router->>Store: insertQueued(session)
Store->>DB: INSERT todo_sessions (crushModel persisted)
DB-->>Store: session inserted
Store-->>Router: session created
Router-->>UI: session response
Daemon->>Store: fetch queued session
Store-->>Daemon: session with crushModel
Daemon->>Daemon: runAgentTurn (agentKind="crush")
Daemon->>CrushRunner: runCrushTurn({crushModel, ...})
CrushRunner->>CrushCLI: spawn with crushModel
CrushCLI-->>CrushRunner: session_id (stderr)
CrushRunner->>CrushDB: poll messages by session_id
CrushDB-->>CrushRunner: message parts (incremental)
CrushRunner->>CrushRunner: emit stream events
CrushRunner-->>Daemon: CrushTurnResult
Daemon->>Store: record events + result
Store->>DB: UPDATE todo_sessions
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b80f48cde
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const sessionId = getSessionId(); | ||
| if (!sessionId) continue; |
There was a problem hiding this comment.
Exit polling loop when session ID is unavailable
If crushSessionId is never parsed from stderr (for example when crush run exits early or its log format differs), the loop hits continue before checking isChildExited(), so pollDbForEvents() never settles. runCrushTurn() then awaits pollPromise forever after the child has already exited, leaving the TODO session stuck in running state.
Useful? React with 👍 / 👎.
| let lastSeenCreatedAt = 0; | ||
| let lastAssistantText: string | null = null; | ||
| let numTurns = 0; |
There was a problem hiding this comment.
Initialize poll cursor for resumed Crush sessions
lastSeenCreatedAt is always reset to 0, so when a turn resumes with --session the poll query re-reads the entire prior message history for that session and re-emits old events as if they were new. In multi-iteration runs this duplicates timeline entries and inflates numTurns because each iteration recounts previous finish rows.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
apps/desktop/src/main/todo-agent/types.ts (1)
352-424:⚠️ Potential issue | 🟠 Majorスケジュール入力スキーマに Crush の実行設定フィールドが不足しています。
todo_schedulesテーブルにはagentKind、codexModel、codexEffort、crushModel列が存在し、データベーススキーマも完全に対応済みですが、todoScheduleCreateInputSchemaとtodoScheduleBaseSchemaがこれらのフィールドを受け取っていないため、UI から送信されたエージェント設定が検証層で破棄されます。その結果、スケジュールから生成される TODO セッションは常に既定の Claude にフォールバックしてしまいます。修正案
customSystemPrompt: z.string().trim().max(20_000).nullish(), claudeModel: todoClaudeModelSchema.nullish(), claudeEffort: todoClaudeEffortSchema.nullish(), + agentKind: agentKindSchema.default(DEFAULT_AGENT_KIND), + codexModel: todoCodexModelSchema.nullish(), + codexEffort: todoCodexEffortSchema.nullish(), + crushModel: crushModelSchema.nullish(), overlapMode: todoScheduleOverlapModeSchema.default("skip"), autoSyncBeforeFire: z.boolean().default(false), }) @@ customSystemPrompt: z.string().trim().max(20_000).nullish(), claudeModel: todoClaudeModelSchema.nullish(), claudeEffort: todoClaudeEffortSchema.nullish(), + agentKind: agentKindSchema, + codexModel: todoCodexModelSchema.nullish(), + codexEffort: todoCodexEffortSchema.nullish(), + crushModel: crushModelSchema.nullish(), overlapMode: todoScheduleOverlapModeSchema, autoSyncBeforeFire: z.boolean(), });router/store 側でもこれらのフィールドが正しく保存されることを確認してください。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/main/todo-agent/types.ts` around lines 352 - 424, Add the missing agent configuration fields to the input and base schemas so the UI-submitted agent settings are validated and persisted: update todoScheduleCreateInputSchema and todoScheduleBaseSchema to include agentKind (likely an enum/string), codexModel (nullable string/enum), codexEffort (nullable effort enum), and crushModel (nullable string/enum) with the same nullability/defaults/constraints as your DB columns (e.g., .nullish() or explicit defaults) and ensure any refine rules still apply (e.g., cronExpr). After changing the schemas, confirm the router/store handlers that consume TodoScheduleCreateInput preserve and write these new properties to the DB.apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx (1)
20-35:⚠️ Potential issue | 🟠 Major設定 hydration で Agent/Codex 既定値も復元してください。
dirtyと保存 payload ではdefaultAgentKind/ Codex 既定値を扱っていますが、初回 hydration で state に戻していないため、保存済みの既定 Agent がcrushやcodexの環境で UI がclaudeに戻り、保存時に設定を上書きします。修正案
import { AgentRuntimePicker, type ClaudeEffortPick, type ClaudeModelPick, type CodexEffortPick, type CodexModelPick, type CrushModelPick, DEFAULT_SENTINEL, + fromPersistedCodexEffort, + fromPersistedCodexModel, fromPersistedCrushModel, fromPersistedEffort, fromPersistedModel, toPersistedCodexEffort, toPersistedCodexModel, @@ setDefaultModel(fromPersistedModel(settings.defaultClaudeModel ?? null)); setDefaultEffort(fromPersistedEffort(settings.defaultClaudeEffort ?? null)); + setDefaultAgentKind(settings.defaultAgentKind ?? DEFAULT_AGENT_KIND); + setDefaultCodexModel( + fromPersistedCodexModel(settings.defaultCodexModel ?? null), + ); + setDefaultCodexEffort( + fromPersistedCodexEffort(settings.defaultCodexEffort ?? null), + ); setDefaultCrushModel( fromPersistedCrushModel(settings.defaultCrushModel ?? null), );Also applies to: 144-155
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx` around lines 20 - 35, Hydration is missing restoration of saved default agent/codex values, causing UI to revert to "claude" and overwrite saved defaults; update the initial hydration logic in PresetsDialog to read and apply persisted defaults (defaultAgentKind and Codex default fields) into component state using the existing deserializers (fromPersistedModel, fromPersistedCrushModel, fromPersistedEffort as appropriate) and map them into the same in-memory shape that AgentRuntimePicker/ClaudeModelPick/CodexModelPick expect, and ensure the `dirty`/save payload generation still references those restored `defaultAgentKind` and codex default state so save does not clobber them.apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx (1)
78-99:⚠️ Potential issue | 🟠 MajorCrush セッションではランタイム表示/編集を Claude 専用 UI から分岐してください。
agentKind === "crush"のセッションでもClaudeRuntimePickerと Claude の Model/Effort 表示が出るため、ユーザーは Crush モデルを編集できたように見えて実際にはcrushModelが更新されません。少なくとも Crush では現在のcrushModelを表示し、未対応なら編集ボタンを隠してください。最小修正案
getClaudeEffortLabel, getClaudeModelLabel, + getCrushModelLabel, toPersistedCodexEffort, toPersistedCodexModel, toPersistedCrushModel, @@ <DetailBlock - label="Model / Effort" + label={session.agentKind === "crush" ? "Model" : "Model / Effort"} action={ - canEditFields && editingField !== "runtime" ? ( + canEditFields && + editingField !== "runtime" && + session.agentKind !== "crush" ? ( <button type="button" className="text-[10px] text-muted-foreground hover:text-foreground transition" @@ - ) : ( + ) : session.agentKind === "crush" ? ( + <div> + <div className="text-[10px] text-muted-foreground mb-0.5"> + Crush Model + </div> + <div className="text-xs"> + {getCrushModelLabel(session.crushModel)} + </div> + </div> + ) : ( <div className="grid grid-cols-2 gap-4">Also applies to: 1624-1685
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx` around lines 78 - 99, The TodoManager currently always renders ClaudeRuntimePicker and Claude model/effort controls even when agentKind === "crush"; change the rendering logic so that when agentKind === "crush" you display the crush-specific view (show current crushModel via fromPersistedCrushModel/toPersistedCrushModel and related crush utilities) and hide or disable the edit controls if Crush editing isn't supported; specifically update the component branch that currently renders ClaudeRuntimePicker/ClaudeModel/Effort to instead branch on agentKind === "crush" and render either a Crush-specific read-only display using crushModel (and fromPersistedCrushModel/fromPersistedCrushModel helpers) or render AgentRuntimePicker/ClaudeRuntimePicker for non-crush agents, ensuring buttons that update crushModel are not shown or are disabled.apps/desktop/src/main/todo-daemon/supervisor-engine.ts (1)
278-308:⚠️ Potential issue | 🟡 MinorCrush 実行時のセットアップ表示が Claude になっています。
ここだけ
agentKindの型と分岐が Crush 未対応のため、Crush セッションでも stream 上はclaude -p ...を起動すると表示されます。修正案
const agentKind = - (session0.agentKind as "claude" | "codex" | null) ?? "claude"; + (session0.agentKind as "claude" | "codex" | "crush" | null) ?? + "claude"; @@ - if (agentKind === "codex") { + if (agentKind === "codex") { appendSetupEvent( sessionId, "Codex", "codex exec --json --full-auto を起動します", @@ "Codex モードではコスト (USD) の集計はトークン数ベースになります。", ); + } else if (agentKind === "crush") { + appendSetupEvent( + sessionId, + "Crush", + "crush run --yolo を起動します", + ); } else { appendSetupEvent( sessionId, "Claude",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/main/todo-daemon/supervisor-engine.ts` around lines 278 - 308, The setup message logic assumes agentKind only "claude" or "codex", so Crush sessions show the wrong Claude startup text; update the handling around session0.agentKind/agentKind and the branching that emits appendSetupEvent so "crush" is recognized and emits the correct messages. Concretely, extend the agentKind type/union to include "crush" (where session0.agentKind can be "crush"), and add a branch for agentKind === "crush" in the block that currently checks for "codex" vs else (using willUsePty/runtimeConfig as needed) to call appendSetupEvent with the appropriate "Crush" labels and startup text; keep existing codex and claude branches unchanged and ensure willUsePty/remoteControlEnabled logic (and references to readTodoSessionRuntimeConfig, session0.artifactPath, and sessionId) still apply.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/src/main/todo-agent/trpc-router.ts`:
- Around line 1071-1076: crushModelsCache.values is set directly from stdout
lines, which can contain duplicate model names and break the Picker's SelectItem
keys; deduplicate the parsed array before assigning it. After
splitting/trimming/filtering stdout (the existing chain that produces values),
create a deduplicated, order-preserving list (e.g., via Array.from(new Set(...))
or a reduce that keeps first occurrence) and assign that to
crushModelsCache.values so each model appears only once for the SelectItem
key/value pair.
In `@apps/desktop/src/main/todo-agent/types.ts`:
- Around line 164-169: Normalize empty-string values for crushModel and
defaultCrushModel by adding the same transform used for
goal/verifyCommand/customSystemPrompt: after .trim().max(200) apply
.transform((v) => (v && v.length > 0 ? v : undefined)) so that trimmed empty
strings become undefined/null at the schema level and are not stored as "" in
the DB; update the zod definitions for crushModel and defaultCrushModel
accordingly.
In `@apps/desktop/src/main/todo-daemon/crush-turn-runner.ts`:
- Around line 276-282: The loop currently updates lastAssistantText from
classifyPart's truncated evt.text (which uses truncate(text, 4000)), causing
saved resultText and next-turn context to be shortened; change the logic so
lastAssistantText (and the final resultText) come from the untruncated/raw
assistant output instead: either have classifyPart return both truncated
evt.text and the raw/full text (e.g., evt.rawText or evt.fullText) and set
lastAssistantText = evt.rawText when available, or read the original text from
the source part (e.g., part.rawText / part.stdoutBuffer) before truncation;
apply the same fix to the other occurrence block around the 332-342 area so the
DB-stored resultText is always the full, untruncated assistant output while UI
emits keep using evt.text for display.
- Around line 257-308: The catch block in crush-turn-runner.ts is swallowing all
errors (including from classifyPart and params.emit) because the try currently
wraps DB read and row processing; restrict the try/catch to only the Database
constructor/prepare/.all call (the DB read), then iterate over rows outside that
try so exceptions from classifyPart, params.emit, and related logic (including
lastSeenCreatedAt updates) propagate to the caller; keep db?.close() in the
finally, and move the lastSeenCreatedAt update so it only advances after a row
has been successfully processed/emitted to avoid dropping events if processing
fails.
---
Outside diff comments:
In `@apps/desktop/src/main/todo-agent/types.ts`:
- Around line 352-424: Add the missing agent configuration fields to the input
and base schemas so the UI-submitted agent settings are validated and persisted:
update todoScheduleCreateInputSchema and todoScheduleBaseSchema to include
agentKind (likely an enum/string), codexModel (nullable string/enum),
codexEffort (nullable effort enum), and crushModel (nullable string/enum) with
the same nullability/defaults/constraints as your DB columns (e.g., .nullish()
or explicit defaults) and ensure any refine rules still apply (e.g., cronExpr).
After changing the schemas, confirm the router/store handlers that consume
TodoScheduleCreateInput preserve and write these new properties to the DB.
In `@apps/desktop/src/main/todo-daemon/supervisor-engine.ts`:
- Around line 278-308: The setup message logic assumes agentKind only "claude"
or "codex", so Crush sessions show the wrong Claude startup text; update the
handling around session0.agentKind/agentKind and the branching that emits
appendSetupEvent so "crush" is recognized and emits the correct messages.
Concretely, extend the agentKind type/union to include "crush" (where
session0.agentKind can be "crush"), and add a branch for agentKind === "crush"
in the block that currently checks for "codex" vs else (using
willUsePty/runtimeConfig as needed) to call appendSetupEvent with the
appropriate "Crush" labels and startup text; keep existing codex and claude
branches unchanged and ensure willUsePty/remoteControlEnabled logic (and
references to readTodoSessionRuntimeConfig, session0.artifactPath, and
sessionId) still apply.
In
`@apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx`:
- Around line 20-35: Hydration is missing restoration of saved default
agent/codex values, causing UI to revert to "claude" and overwrite saved
defaults; update the initial hydration logic in PresetsDialog to read and apply
persisted defaults (defaultAgentKind and Codex default fields) into component
state using the existing deserializers (fromPersistedModel,
fromPersistedCrushModel, fromPersistedEffort as appropriate) and map them into
the same in-memory shape that AgentRuntimePicker/ClaudeModelPick/CodexModelPick
expect, and ensure the `dirty`/save payload generation still references those
restored `defaultAgentKind` and codex default state so save does not clobber
them.
In `@apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx`:
- Around line 78-99: The TodoManager currently always renders
ClaudeRuntimePicker and Claude model/effort controls even when agentKind ===
"crush"; change the rendering logic so that when agentKind === "crush" you
display the crush-specific view (show current crushModel via
fromPersistedCrushModel/toPersistedCrushModel and related crush utilities) and
hide or disable the edit controls if Crush editing isn't supported; specifically
update the component branch that currently renders
ClaudeRuntimePicker/ClaudeModel/Effort to instead branch on agentKind ===
"crush" and render either a Crush-specific read-only display using crushModel
(and fromPersistedCrushModel/fromPersistedCrushModel helpers) or render
AgentRuntimePicker/ClaudeRuntimePicker for non-crush agents, ensuring buttons
that update crushModel are not shown or are disabled.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c4859a22-0bd4-415d-b175-4ccc40add396
📒 Files selected for processing (17)
apps/desktop/src/main/todo-agent/session-store.tsapps/desktop/src/main/todo-agent/settings.tsapps/desktop/src/main/todo-agent/trpc-router.tsapps/desktop/src/main/todo-agent/types.tsapps/desktop/src/main/todo-daemon/crush-turn-runner.tsapps/desktop/src/main/todo-daemon/supervisor-engine.tsapps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/AgentRuntimePicker.tsxapps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/claudeRuntimeOptions.tsapps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/index.tsapps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsxapps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsxapps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsxpackages/local-db/drizzle/0069_add_crush_model.sqlpackages/local-db/drizzle/meta/0069_snapshot.jsonpackages/local-db/drizzle/meta/_journal.jsonpackages/local-db/src/schema/todo-schedules.tspackages/local-db/src/schema/todo-sessions.ts
| const values = stdout | ||
| .trim() | ||
| .split("\n") | ||
| .map((l) => l.trim()) | ||
| .filter(Boolean); | ||
| crushModelsCache.values = values; |
There was a problem hiding this comment.
Crush モデル一覧を cache に入れる前に重複排除してください。
このままだと同じモデル名が複数行に出た場合、Picker 側の SelectItem key={model} value={model} が重複して選択 UI が不安定になります。
修正案
- const values = stdout
- .trim()
- .split("\n")
- .map((l) => l.trim())
- .filter(Boolean);
+ const values = Array.from(
+ new Set(
+ stdout
+ .trim()
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean),
+ ),
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const values = stdout | |
| .trim() | |
| .split("\n") | |
| .map((l) => l.trim()) | |
| .filter(Boolean); | |
| crushModelsCache.values = values; | |
| const values = Array.from( | |
| new Set( | |
| stdout | |
| .trim() | |
| .split("\n") | |
| .map((l) => l.trim()) | |
| .filter(Boolean), | |
| ), | |
| ); | |
| crushModelsCache.values = values; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/todo-agent/trpc-router.ts` around lines 1071 - 1076,
crushModelsCache.values is set directly from stdout lines, which can contain
duplicate model names and break the Picker's SelectItem keys; deduplicate the
parsed array before assigning it. After splitting/trimming/filtering stdout (the
existing chain that produces values), create a deduplicated, order-preserving
list (e.g., via Array.from(new Set(...)) or a reduce that keeps first
occurrence) and assign that to crushModelsCache.values so each model appears
only once for the SelectItem key/value pair.
| // Optional per-session Crush CLI model override. Null / undefined means | ||
| // "use the user's configured default". Only read when agentKind is | ||
| // "crush"; ignored for Claude / Codex sessions. The value is a free-form | ||
| // string in the form "provider/model" (e.g. "openai/gpt-5.4") resolved | ||
| // dynamically from `crush models`. No effort option — Crush CLI lacks one. | ||
| crushModel: z.string().trim().max(200).nullish(), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Crush model の解決・実行経路で空文字が正規化されているか確認します。
rg -n -C3 '\b(resolvedCrushModel|crushModel|defaultCrushModel)\b|--model' apps/desktop/src/main/todo-agent apps/desktop/src/main/todo-daemon apps/desktop/src/renderer/features/todo-agentRepository: MocA-Love/superset
Length of output: 34115
🏁 Script executed:
#!/bin/bash
# Check where crushModel is set in the UI and if there's any validation preventing empty strings
rg -n -B3 -A3 'toPersistedCrushModel|fromPersistedCrushModel' apps/desktop/src/renderer/features/todo-agent --type ts --type tsxRepository: MocA-Love/superset
Length of output: 89
🏁 Script executed:
#!/bin/bash
# Check the implementation of toPersistedCrushModel and fromPersistedCrushModel functions
fd -type f '\.ts' '\.tsx' apps/desktop/src/renderer/features/todo-agent -x grep -l 'toPersistedCrushModel\|fromPersistedCrushModel'Repository: MocA-Love/superset
Length of output: 232
🏁 Script executed:
#!/bin/bash
# Find where these conversion functions are defined
rg -n 'export.*toPersistedCrushModel|export.*fromPersistedCrushModel|const.*toPersistedCrushModel|const.*fromPersistedCrushModel' apps/desktop/src/renderer --type ts --type tsx -A5Repository: MocA-Love/superset
Length of output: 89
🏁 Script executed:
#!/bin/bash
# Find where toPersistedCrushModel and fromPersistedCrushModel are defined
rg -n 'toPersistedCrushModel|fromPersistedCrushModel' apps/desktop/src/renderer/features/todo-agent -A3Repository: MocA-Love/superset
Length of output: 8098
🏁 Script executed:
#!/bin/bash
# Search for the conversion functions in the entire renderer directory
rg -n 'const toPersistedCrushModel|function toPersistedCrushModel|export.*toPersistedCrushModel' apps/desktop/src/renderer --type-add 'ts:*.ts' --type-add 'tsx:*.tsx' -t ts -t tsxRepository: MocA-Love/superset
Length of output: 232
🏁 Script executed:
#!/bin/bash
# Simpler approach - find files with these function names and read them
find apps/desktop/src/renderer/features/todo-agent -name "*.ts" -o -name "*.tsx" | xargs grep -l "toPersistedCrushModel\|fromPersistedCrushModel"Repository: MocA-Love/superset
Length of output: 460
🏁 Script executed:
#!/bin/bash
# Read the complete implementation of the crush model conversion functions
sed -n '378,390p' apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/claudeRuntimeOptions.tsRepository: MocA-Love/superset
Length of output: 436
🏁 Script executed:
#!/bin/bash
# Also check if there are type definitions for CrushModelPick
rg -n 'type CrushModelPick|interface CrushModelPick' apps/desktop/src/renderer --type-add 'ts:*.ts' --type-add 'tsx:*.tsx'Repository: MocA-Love/superset
Length of output: 741
🏁 Script executed:
#!/bin/bash
# Check if UI Select component validates or sanitizes the crush model input before submission
rg -n -B5 -A10 'onCrushModelChange' apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/AgentRuntimePicker.tsx | head -60Repository: MocA-Love/superset
Length of output: 1564
🏁 Script executed:
#!/bin/bash
# Check the crush models dropdown - where does the list come from and how is it used
sed -n '232,280p' apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/AgentRuntimePicker.tsxRepository: MocA-Love/superset
Length of output: 1293
🏁 Script executed:
#!/antml:bin/bash
# Check how crush models are fetched/populated in TodoModal
rg -n 'crushModels' apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx -B2 -A2Repository: MocA-Love/superset
Length of output: 359
🏁 Script executed:
#!/bin/bash
# Check codexModel and claudeModel schemas for comparison - do they have the same issue?
sed -n '164,230p' apps/desktop/src/main/todo-agent/types.ts | grep -E 'Model|Effort' -A1 -B1Repository: MocA-Love/superset
Length of output: 756
🏁 Script executed:
#!/bin/bash
# Check what todoCodexModelSchema and todoClaudeModelSchema are - do they have transforms?
rg -n 'todoCodexModelSchema|todoClaudeModelSchema|todoCodaxEffortSchema|todoClaudeEffortSchema' apps/desktop/src/main/todo-agent/types.ts -B2 -A2Repository: MocA-Love/superset
Length of output: 2341
🏁 Script executed:
#!/bin/bash
# Check if codex/claude model schemas use transform to normalize empty strings
sed -n '1,160p' apps/desktop/src/main/todo-agent/types.ts | tail -80Repository: MocA-Love/superset
Length of output: 2836
🏁 Script executed:
#!/bin/bash
# Check if there are any DB inserts/updates that might allow empty strings for crushModel
rg -n 'crushModel|defaultCrushModel' apps/desktop/src/main/todo-agent/session-store.ts -B3 -A3 | head -80Repository: MocA-Love/superset
Length of output: 616
🏁 Script executed:
#!/bin/bash
# Check if there's any data migration or seeding that might create empty strings
rg -n 'crushModel.*""' apps/desktop --type-add 'ts:*.ts' --type-add 'tsx:*.tsx'Repository: MocA-Love/superset
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Verify the actual runtime behavior - if empty string is passed, does the CLI handle it safely?
sed -n '207,220p' apps/desktop/src/main/todo-daemon/crush-turn-runner.tsRepository: MocA-Love/superset
Length of output: 451
crushModel と defaultCrushModel の空文字を null に正規化してください。
.trim() 後の空文字列は .nullish() では正規化されず、スキーマレベルで DB に保存されます。CLI は空文字の判定で if (params.crushModel) を使用しているため実行時には影響しませんが、DB の値が null でなく "" として格納される点が問題です。同じファイルの goal・verifyCommand・customSystemPrompt フィールドで既に実装されているパターン (.transform((v) => (v && v.length > 0 ? v : undefined))) を適用してください。
修正案
+const crushModelSchema = z
+ .string()
+ .trim()
+ .max(200)
+ .transform((value) => (value.length > 0 ? value : null))
+ .nullish();
+
export const todoCreateInputSchema = z.object({
@@
- crushModel: z.string().trim().max(200).nullish(),
+ crushModel: crushModelSchema,
@@
- defaultCrushModel: z.string().trim().max(200).nullish().default(null),
+ defaultCrushModel: crushModelSchema.default(null),
});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/todo-agent/types.ts` around lines 164 - 169, Normalize
empty-string values for crushModel and defaultCrushModel by adding the same
transform used for goal/verifyCommand/customSystemPrompt: after .trim().max(200)
apply .transform((v) => (v && v.length > 0 ? v : undefined)) so that trimmed
empty strings become undefined/null at the schema level and are not stored as ""
in the DB; update the zod definitions for crushModel and defaultCrushModel
accordingly.
| while (!settled && !params.signal.aborted) { | ||
| await sleep(POLL_INTERVAL_MS); | ||
|
|
||
| const sessionId = getSessionId(); | ||
| if (!sessionId) continue; | ||
|
|
||
| const dbPath = findCrushDb(params.cwd); | ||
| if (!dbPath) continue; |
There was a problem hiding this comment.
子プロセス終了後もポーリングが無限継続します。
sessionId 未取得、または .crush/crush.db 未作成のまま Crush が終了すると、continue により Line 314 の終了判定へ到達せず、runCrushTurn() が pollPromise 待ちでハングします。
修正案
while (!settled && !params.signal.aborted) {
await sleep(POLL_INTERVAL_MS);
const sessionId = getSessionId();
- if (!sessionId) continue;
+ if (!sessionId) {
+ if (isChildExited()) break;
+ continue;
+ }
const dbPath = findCrushDb(params.cwd);
- if (!dbPath) continue;
+ if (!dbPath) {
+ if (isChildExited()) break;
+ continue;
+ }| let db: Database.Database | null = null; | ||
| try { | ||
| db = new Database(dbPath, { readonly: true }); | ||
| const rows = db | ||
| .prepare( | ||
| "SELECT id, role, parts, created_at FROM messages WHERE session_id = ? AND created_at > ? ORDER BY created_at ASC", | ||
| ) | ||
| .all(sessionId, lastSeenCreatedAt) as Array<{ | ||
| id: string; | ||
| role: string; | ||
| parts: string; | ||
| created_at: number; | ||
| }>; | ||
|
|
||
| for (const row of rows) { | ||
| lastSeenCreatedAt = Math.max(lastSeenCreatedAt, row.created_at); | ||
| const parts = safeParseJson(row.parts); | ||
| if (!parts) continue; | ||
|
|
||
| for (const part of parts) { | ||
| const events = classifyPart(part, row.role, params.iteration); | ||
| for (const evt of events) { | ||
| params.emit(evt); | ||
| if (evt.kind === "assistant_text" && evt.text) { | ||
| lastAssistantText = evt.text; | ||
| } | ||
| if (evt.kind === "result") { | ||
| numTurns++; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Check if session has finished (finish reason in last assistant message) | ||
| if (rows.length > 0) { | ||
| const lastRow = rows[rows.length - 1]; | ||
| const lastParts = safeParseJson(lastRow.parts); | ||
| if (lastParts) { | ||
| for (const p of lastParts) { | ||
| if ( | ||
| p.type === "finish" && | ||
| (p.data?.reason === "end_turn" || p.data?.reason === "stop") | ||
| ) { | ||
| settled = true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } catch { | ||
| // DB may be locked or not yet created — retry | ||
| } finally { | ||
| db?.close(); |
There was a problem hiding this comment.
DBロック以外の処理エラーまで握りつぶしています。
この catch は params.emit() や分類処理の例外も「DB may be locked」として握りつぶします。さらに lastSeenCreatedAt 更新後に失敗すると、そのイベントは再処理されず欠落します。DB読み取りだけを try/catch に閉じ込め、行処理・emit の失敗は呼び出し元へ伝播させる方が安全です。
修正案
- let db: Database.Database | null = null;
+ let rows: Array<{
+ id: string;
+ role: string;
+ parts: string;
+ created_at: number;
+ }> = [];
+ let db: Database.Database | null = null;
try {
db = new Database(dbPath, { readonly: true });
- const rows = db
+ rows = db
.prepare(
"SELECT id, role, parts, created_at FROM messages WHERE session_id = ? AND created_at > ? ORDER BY created_at ASC",
)
- .all(sessionId, lastSeenCreatedAt) as Array<{
- id: string;
- role: string;
- parts: string;
- created_at: number;
- }>;
+ .all(sessionId, lastSeenCreatedAt) as typeof rows;
+ } catch {
+ // DB may be locked or not yet created — retry
+ continue;
+ } finally {
+ db?.close();
+ }
- for (const row of rows) {
- lastSeenCreatedAt = Math.max(lastSeenCreatedAt, row.created_at);
- const parts = safeParseJson(row.parts);
- if (!parts) continue;
+ for (const row of rows) {
+ lastSeenCreatedAt = Math.max(lastSeenCreatedAt, row.created_at);
+ const parts = safeParseJson(row.parts);
+ if (!parts) continue;
- for (const part of parts) {
- const events = classifyPart(part, row.role, params.iteration);
- for (const evt of events) {
- params.emit(evt);
- if (evt.kind === "assistant_text" && evt.text) {
- lastAssistantText = evt.text;
- }
- if (evt.kind === "result") {
- numTurns++;
- }
+ for (const part of parts) {
+ const events = classifyPart(part, row.role, params.iteration);
+ for (const evt of events) {
+ params.emit(evt);
+ if (evt.kind === "assistant_text" && evt.text) {
+ lastAssistantText = evt.text;
}
- }
- }
-
- // Check if session has finished (finish reason in last assistant message)
- if (rows.length > 0) {
- const lastRow = rows[rows.length - 1];
- const lastParts = safeParseJson(lastRow.parts);
- if (lastParts) {
- for (const p of lastParts) {
- if (
- p.type === "finish" &&
- (p.data?.reason === "end_turn" || p.data?.reason === "stop")
- ) {
- settled = true;
- }
+ if (evt.kind === "result") {
+ numTurns++;
}
}
}
- } catch {
- // DB may be locked or not yet created — retry
- } finally {
- db?.close();
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/todo-daemon/crush-turn-runner.ts` around lines 257 -
308, The catch block in crush-turn-runner.ts is swallowing all errors (including
from classifyPart and params.emit) because the try currently wraps DB read and
row processing; restrict the try/catch to only the Database
constructor/prepare/.all call (the DB read), then iterate over rows outside that
try so exceptions from classifyPart, params.emit, and related logic (including
lastSeenCreatedAt updates) propagate to the caller; keep db?.close() in the
finally, and move the lastSeenCreatedAt update so it only advances after a row
has been successfully processed/emitted to avoid dropping events if processing
fails.
| for (const part of parts) { | ||
| const events = classifyPart(part, row.role, params.iteration); | ||
| for (const evt of events) { | ||
| params.emit(evt); | ||
| if (evt.kind === "assistant_text" && evt.text) { | ||
| lastAssistantText = evt.text; | ||
| } |
There was a problem hiding this comment.
最終結果のフォールバックがUI用の切り詰め済みテキストになります。
lastAssistantText は classifyPart() が truncate(text, 4000) した evt.text から更新されています。stdoutBuffer が空のケースでは、保存される resultText が4000文字で欠落し、次ターンの文脈にも短縮版が使われます。結果用にはDBの生テキストを保持してください。
修正案
for (const part of parts) {
+ if (
+ row.role === "assistant" &&
+ part.type === "text" &&
+ typeof part.data?.text === "string" &&
+ part.data.text.length > 0
+ ) {
+ lastAssistantText = part.data.text;
+ }
+
const events = classifyPart(part, row.role, params.iteration);
for (const evt of events) {
params.emit(evt);
- if (evt.kind === "assistant_text" && evt.text) {
- lastAssistantText = evt.text;
- }
if (evt.kind === "result") {
numTurns++;
}Also applies to: 332-342
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/todo-daemon/crush-turn-runner.ts` around lines 276 -
282, The loop currently updates lastAssistantText from classifyPart's truncated
evt.text (which uses truncate(text, 4000)), causing saved resultText and
next-turn context to be shortened; change the logic so lastAssistantText (and
the final resultText) come from the untruncated/raw assistant output instead:
either have classifyPart return both truncated evt.text and the raw/full text
(e.g., evt.rawText or evt.fullText) and set lastAssistantText = evt.rawText when
available, or read the original text from the source part (e.g., part.rawText /
part.stdoutBuffer) before truncation; apply the same fix to the other occurrence
block around the 332-342 area so the DB-stored resultText is always the full,
untruncated assistant output while UI emits keep using evt.text for display.
Summary
crush-turn-runnerを実装(crush run --yoloを spawn →.crush/crush.dbを 250ms 間隔でポーリングして構造化イベントに変換)crushModelカラムをtodo_sessions/todo_schedulesに追加(migration0069)Supersedes #386 (squash merge を revert して通常 merge で再作成)。
Test plan
crush modelsの結果がプロバイダー別に表示されることbun run typecheck/bun run lintが通ることSummary by CodeRabbit
リリースノート