Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/main/todo-agent/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ class TodoSessionStore {
agentKind?: string | null;
codexModel?: string | null;
codexEffort?: string | null;
crushModel?: string | null;
remoteControlEnabled?: boolean;
artifactPath: string;
}): SelectTodoSession {
Expand Down Expand Up @@ -317,6 +318,7 @@ class TodoSessionStore {
agentKind: template.agentKind ?? "claude",
codexModel: template.codexModel ?? null,
codexEffort: template.codexEffort ?? null,
crushModel: template.crushModel ?? null,
remoteControlEnabled: template.remoteControlEnabled ?? false,
verdictPassed: null,
verdictReason: null,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/todo-agent/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const DEFAULT_SETTINGS: TodoSettings = {
defaultClaudeEffort: null,
defaultCodexModel: null,
defaultCodexEffort: null,
defaultCrushModel: null,
};

let cached: TodoSettings | null = null;
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/main/todo-agent/trpc-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ import {
*
* Exposed as `todoAgent.*` on the app router.
*/
interface CrushModelsCache {
values: string[];
expiresAt: number;
inflight: Promise<string[]> | null;
}

const CRUSH_MODELS_CACHE_TTL_MS = 5 * 60_000;
const crushModelsCache: CrushModelsCache = {
values: [],
expiresAt: 0,
inflight: null,
};

export const createTodoAgentRouter = () => {
return router({
create: publicProcedure
Expand Down Expand Up @@ -152,6 +165,10 @@ export const createTodoAgentRouter = () => {
: (settings.defaultCodexEffort ?? null);
const resolvedAgentKind =
input.agentKind ?? settings.defaultAgentKind ?? "claude";
const resolvedCrushModel =
input.crushModel !== undefined
? input.crushModel
: (settings.defaultCrushModel ?? null);

const session = store.insertQueuedFromTemplate({
id: sessionId,
Expand All @@ -172,6 +189,8 @@ export const createTodoAgentRouter = () => {
resolvedAgentKind === "codex" ? resolvedCodexModel : null,
codexEffort:
resolvedAgentKind === "codex" ? resolvedCodexEffort : null,
crushModel:
resolvedAgentKind === "crush" ? resolvedCrushModel : null,
remoteControlEnabled: input.remoteControlEnabled,
artifactPath,
});
Expand Down Expand Up @@ -542,6 +561,7 @@ export const createTodoAgentRouter = () => {
agentKind: source.agentKind ?? "claude",
codexModel: source.codexModel,
codexEffort: source.codexEffort,
crushModel: source.crushModel ?? null,
remoteControlEnabled: source.remoteControlEnabled ?? false,
verdictPassed: null,
verdictReason: null,
Expand Down Expand Up @@ -1029,6 +1049,43 @@ export const createTodoAgentRouter = () => {
}),
),
}),
crushModels: publicProcedure.query(async () => {
const now = Date.now();
if (crushModelsCache.expiresAt > now) {
return crushModelsCache.values;
}
if (crushModelsCache.inflight) {
return crushModelsCache.inflight;
}

crushModelsCache.inflight = (async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
const bin =
process.env.TODO_CRUSH_BIN || process.env.CRUSH_BIN || "crush";
try {
const { stdout } = await execFileAsync(bin, ["models"], {
timeout: 10_000,
});
const values = stdout
.trim()
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
crushModelsCache.values = values;
Comment on lines +1071 to +1076
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.

crushModelsCache.expiresAt = Date.now() + CRUSH_MODELS_CACHE_TTL_MS;
return values;
} catch {
return crushModelsCache.values.length > 0
? crushModelsCache.values
: [];
} finally {
crushModelsCache.inflight = null;
}
})();
return crushModelsCache.inflight;
}),
});
};

Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/main/todo-agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface TodoSessionListEntry extends SelectTodoSession {

// ---- Agent kind ----

export const AGENT_KIND_OPTIONS = ["claude", "codex"] as const;
export const AGENT_KIND_OPTIONS = ["claude", "codex", "crush"] as const;
export type AgentKind = (typeof AGENT_KIND_OPTIONS)[number];
export const agentKindSchema = z.enum(AGENT_KIND_OPTIONS);
export const DEFAULT_AGENT_KIND: AgentKind = "claude";
Expand Down Expand Up @@ -161,6 +161,12 @@ export const todoCreateInputSchema = z.object({
// "codex"; ignored for Claude sessions.
codexModel: todoCodexModelSchema.nullish(),
codexEffort: todoCodexEffortSchema.nullish(),
// 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(),
Comment on lines +164 to +169
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.

// Beta escape hatch: opt a single TODO into the interactive PTY
// engine without flipping the whole app over from headless `-p`.
// Persisted in the artifact runtime config, not the DB row.
Expand Down Expand Up @@ -218,6 +224,8 @@ export const todoSettingsSchema = z.object({
// Global defaults for Codex sessions.
defaultCodexModel: todoCodexModelSchema.nullish().default(null),
defaultCodexEffort: todoCodexEffortSchema.nullish().default(null),
// Global default for Crush sessions. Free-form string ("provider/model").
defaultCrushModel: z.string().trim().max(200).nullish().default(null),
});

export type TodoSettings = z.infer<typeof todoSettingsSchema>;
Expand Down
Loading
Loading