Skip to content

feat(desktop): TODO Agent に Crush CLI 対応を追加#389

Merged
MocA-Love merged 5 commits intomainfrom
feat/crush-todo-agent
Apr 23, 2026
Merged

feat(desktop): TODO Agent に Crush CLI 対応を追加#389
MocA-Love merged 5 commits intomainfrom
feat/crush-todo-agent

Conversation

@MocA-Love
Copy link
Copy Markdown
Owner

@MocA-Love MocA-Love commented Apr 23, 2026

Summary

  • Crush CLI (charmbracelet/crush) を Todo Agent のエージェント種別として追加
  • SQLite ポーリングベースの crush-turn-runner を実装(crush run --yolo を spawn → .crush/crush.db を 250ms 間隔でポーリングして構造化イベントに変換)
  • PresetsDialog / TodoManager / TodoModal に Crush モデル選択 UI を追加(動的モデルリスト、プロバイダー別グルーピング)
  • crushModel カラムを todo_sessions / todo_schedules に追加(migration 0069

Supersedes #386 (squash merge を revert して通常 merge で再作成)。

Test plan

  • デスクトップアプリで Todo Agent を開き、エージェント種別で「Crush」を選択できること
  • Crush モデルドロップダウンに crush models の結果がプロバイダー別に表示されること
  • 設定タブで Crush デフォルトモデルを保存・復元できること
  • bun run typecheck / bun run lint が通ること

Summary by CodeRabbit

リリースノート

  • 新機能
    • 新しいエージェント「Crush」を追加しました。セッション作成時にCrushモデルを選択できます。
    • Crushモデルはプロバイダーごとにグループ分けされて表示されます。
    • 設定でデフォルトのCrushモデルを設定できるようになりました。これはセッション作成時のデフォルト値として機能します。

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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

このPRは、Crush CLIエージェントの新しいサポートを追加します。Crushモデル選択、セッション永続化、ターンランナー実装、DBスキーマ更新、およびUIコンポーネント統合を含む包括的な統合です。

Changes

コホート / ファイル(s) 概要
スキーマ・型定義
apps/desktop/src/main/todo-agent/types.ts, packages/local-db/src/schema/todo-sessions.ts, packages/local-db/src/schema/todo-schedules.ts
"crush"をエージェント種別として追加、crushModelフィールド("provider/model"形式、最大200文字)をスキーマに追加
データベース移行
packages/local-db/drizzle/0069_add_crush_model.sql, packages/local-db/drizzle/meta/0069_snapshot.json, packages/local-db/drizzle/meta/_journal.json
todo_sessionsおよびtodo_schedulesテーブルにcrush_modelテキストカラムを追加
設定・セッションストア
apps/desktop/src/main/todo-agent/settings.ts, apps/desktop/src/main/todo-agent/session-store.ts
DEFAULT_SETTINGSdefaultCrushModelフィールドを追加、insertQueuedFromTemplatecrushModelを永続化
tRPCルータ・API
apps/desktop/src/main/todo-agent/trpc-router.ts
crushModelsクエリエンドポイント(5分TTLでキャッシュ)、セッション作成・再実行時にcrushModelを解決・永続化
Crusターンランナー
apps/desktop/src/main/todo-daemon/crush-turn-runner.ts
Crush CLI起動、セッションID取得、.crush/crush.dbをポーリングしてストリームイベント(テキスト、ツール使用、結果、エラー)を解析・発行
スーパーバイザーエンジン
apps/desktop/src/main/todo-daemon/supervisor-engine.ts
runAgentTurncrushModelのサポート追加、"crush"種別用にrunCrushTurnを呼び出すルーティング実装
レンダラUIコンポーネント
apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/AgentRuntimePicker.tsx, apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/claudeRuntimeOptions.ts, apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/index.ts
CrushModelPick型、永続化ヘルパー関数(toPersistedCrushModelfromPersistedCrushModel)、Crush専用モデルセレクタUI追加
TodoManagerコンポーネント
apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx, apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx, apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx
Crushモデル状態管理、todoAgent.crushModelsクエリ、AgentRuntimePickerへプロップス渡し、セッション作成ペイロードに含める

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰✨
モデルを選びて、ターンは流れ、
Crushエージェント、駆け行く道を、
DB に刻み、UIは映す。
新しき種族、仲間入りだ!
🌟🔨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR説明はサマリー、テストプラン、関連情報を含んでいますが、公式テンプレートの「Description」「Related Issues」「Type of Change」などのセクションに完全には従っていません。 テンプレートに従い、Description、Related Issues、Type of Changeを明確なセクションヘッダーで整理し、チェックボックスで変更種別を明示することを推奨します。
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PRタイトルは主な変更内容(Crush CLIのTodo Agentへの対応追加)を明確かつ簡潔に表現しており、変更セット全体をよく要約しています。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/crush-todo-agent

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +251 to +252
const sessionId = getSessionId();
if (!sessionId) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +243 to +245
let lastSeenCreatedAt = 0;
let lastAssistantText: string | null = null;
let numTurns = 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 テーブルには agentKindcodexModelcodexEffortcrushModel 列が存在し、データベーススキーマも完全に対応済みですが、todoScheduleCreateInputSchematodoScheduleBaseSchema がこれらのフィールドを受け取っていないため、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 が crushcodex の環境で 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 | 🟠 Major

Crush セッションではランタイム表示/編集を 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 | 🟡 Minor

Crush 実行時のセットアップ表示が 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0aa85b2 and 4b80f48.

📒 Files selected for processing (17)
  • apps/desktop/src/main/todo-agent/session-store.ts
  • apps/desktop/src/main/todo-agent/settings.ts
  • apps/desktop/src/main/todo-agent/trpc-router.ts
  • apps/desktop/src/main/todo-agent/types.ts
  • apps/desktop/src/main/todo-daemon/crush-turn-runner.ts
  • apps/desktop/src/main/todo-daemon/supervisor-engine.ts
  • apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/AgentRuntimePicker.tsx
  • apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/claudeRuntimeOptions.ts
  • apps/desktop/src/renderer/features/todo-agent/ClaudeRuntimePicker/index.ts
  • apps/desktop/src/renderer/features/todo-agent/TodoManager/PresetsDialog/PresetsDialog.tsx
  • apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx
  • apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx
  • packages/local-db/drizzle/0069_add_crush_model.sql
  • packages/local-db/drizzle/meta/0069_snapshot.json
  • packages/local-db/drizzle/meta/_journal.json
  • packages/local-db/src/schema/todo-schedules.ts
  • packages/local-db/src/schema/todo-sessions.ts

Comment on lines +1071 to +1076
const values = stdout
.trim()
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
crushModelsCache.values = values;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +164 to +169
// 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(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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-agent

Repository: 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 tsx

Repository: 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 -A5

Repository: 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 -A3

Repository: 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 tsx

Repository: 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.ts

Repository: 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 -60

Repository: 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.tsx

Repository: 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 -A2

Repository: 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 -B1

Repository: 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 -A2

Repository: 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 -80

Repository: 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 -80

Repository: 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.ts

Repository: MocA-Love/superset

Length of output: 451


crushModeldefaultCrushModel の空文字を null に正規化してください。

.trim() 後の空文字列は .nullish() では正規化されず、スキーマレベルで DB に保存されます。CLI は空文字の判定で if (params.crushModel) を使用しているため実行時には影響しませんが、DB の値が null でなく "" として格納される点が問題です。同じファイルの goalverifyCommandcustomSystemPrompt フィールドで既に実装されているパターン (.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.

Comment on lines +248 to +255
while (!settled && !params.signal.aborted) {
await sleep(POLL_INTERVAL_MS);

const sessionId = getSessionId();
if (!sessionId) continue;

const dbPath = findCrushDb(params.cwd);
if (!dbPath) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

子プロセス終了後もポーリングが無限継続します。

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;
+		}

Comment on lines +257 to +308
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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

DBロック以外の処理エラーまで握りつぶしています。

この catchparams.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.

Comment on lines +276 to +282
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

最終結果のフォールバックがUI用の切り詰め済みテキストになります。

lastAssistantTextclassifyPart()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.

@MocA-Love MocA-Love merged commit 3dffc08 into main Apr 23, 2026
27 of 28 checks passed
@MocA-Love MocA-Love deleted the feat/crush-todo-agent branch April 23, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant