From fde9615b34df811415a642b84b859bf1556d3e48 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 27 Jul 2026 13:55:43 +0200 Subject: [PATCH] feat(settings): per-model effort and turn presets ClaudeDo never passed --effort, so every session inherited whatever effortLevel the user's Claude Code config happened to carry. Settings -> General now holds one row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25) supplying the global effort and turn defaults; list- and task-level max-turns overrides still win, and the agent editor's inherited badge follows the model. --effort is applied to autonomous runs and to every ConPTY spec (task session, planning start/resume, ad-hoc, list handler). The model itself is deliberately not forced on interactive sessions. The single global 'Max turns' field is replaced by the table, and 'fable' joins ModelRegistry.Aliases. The migration also adds the is_manual columns used by the next commit. --- .../AppSettingsEntityConfiguration.cs | 1 + .../Configuration/ListEntityConfiguration.cs | 1 + .../Configuration/TaskEntityConfiguration.cs | 1 + ...6_AddModelPresetsAndManualFlag.Designer.cs | 802 ++++++++++++++++++ ...0727114206_AddModelPresetsAndManualFlag.cs | 57 ++ .../ClaudeDoDbContextModelSnapshot.cs | 16 + src/ClaudeDo.Data/Models/AppSettingsEntity.cs | 4 + src/ClaudeDo.Data/Models/EffortRegistry.cs | 25 + src/ClaudeDo.Data/Models/ListEntity.cs | 3 + src/ClaudeDo.Data/Models/ModelPresets.cs | 91 ++ src/ClaudeDo.Data/Models/ModelRegistry.cs | 2 +- src/ClaudeDo.Data/Models/TaskEntity.cs | 4 + src/ClaudeDo.Localization/locales/de.json | 3 + src/ClaudeDo.Localization/locales/en.json | 3 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 6 +- .../Agent/AgentConfigEditorViewModel.cs | 25 +- .../Settings/GeneralSettingsTabViewModel.cs | 40 +- .../Modals/SettingsModalViewModel.cs | 4 +- .../Views/Modals/SettingsModalView.axaml | 33 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 15 +- .../Runner/ClaudeArgsBuilder.cs | 9 +- .../Runner/InteractiveLaunchSpecService.cs | 49 +- src/ClaudeDo.Worker/Runner/TaskRunner.cs | 12 +- .../ClaudeDo.Data.Tests/ModelPresetsTests.cs | 91 ++ .../AgentConfigEditorViewModelTests.cs | 6 +- .../Runner/ClaudeArgsBuilderTests.cs | 19 + .../InteractiveLaunchSpecServiceTests.cs | 36 +- 27 files changed, 1318 insertions(+), 40 deletions(-) create mode 100644 src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs create mode 100644 src/ClaudeDo.Data/Models/EffortRegistry.cs create mode 100644 src/ClaudeDo.Data/Models/ModelPresets.cs create mode 100644 tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs diff --git a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs index b023ae18..158ab1e1 100644 --- a/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs @@ -45,6 +45,7 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration s.SessionSkills).HasColumnName("session_skills"); + builder.Property(s => s.ModelPresets).HasColumnName("model_presets"); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); } diff --git a/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs index cd48bebc..29caa79e 100644 --- a/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs @@ -17,6 +17,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); + builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index 64777e5f..3b9e90c1 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -89,6 +89,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.MaxTurns).HasColumnName("max_turns"); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); + builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false); builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); diff --git a/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs new file mode 100644 index 00000000..836240fd --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.Designer.cs @@ -0,0 +1,802 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260727114206_AddModelPresetsAndManualFlag")] + partial class AddModelPresetsAndManualFlag + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 100, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + StandupWeekday = 3, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs new file mode 100644 index 00000000..7d13e4af --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260727114206_AddModelPresetsAndManualFlag.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddModelPresetsAndManualFlag : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_manual", + table: "tasks", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "is_manual", + table: "lists", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "model_presets", + table: "app_settings", + type: "TEXT", + nullable: true); + + migrationBuilder.UpdateData( + table: "app_settings", + keyColumn: "id", + keyValue: 1, + column: "model_presets", + value: null); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "is_manual", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "is_manual", + table: "lists"); + + migrationBuilder.DropColumn( + name: "model_presets", + table: "app_settings"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index ea34a728..a37fd3c3 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -66,6 +66,10 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue(1) .HasColumnName("max_parallel_executions"); + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + b.Property("RepoImportFolders") .HasColumnType("TEXT") .HasColumnName("repo_import_folders"); @@ -202,6 +206,12 @@ namespace ClaudeDo.Data.Migrations .HasDefaultValue("chore") .HasColumnName("default_commit_type"); + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + b.Property("Name") .IsRequired() .HasColumnType("TEXT") @@ -406,6 +416,12 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("finished_at"); + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + b.Property("IsMyDay") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") diff --git a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs index e3535d82..c898f1e9 100644 --- a/src/ClaudeDo.Data/Models/AppSettingsEntity.cs +++ b/src/ClaudeDo.Data/Models/AppSettingsEntity.cs @@ -30,4 +30,8 @@ public sealed class AppSettingsEntity // JSON array of session skill names applied by default to new tasks. public string? SessionSkills { get; set; } + + // JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and + // turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults. + public string? ModelPresets { get; set; } } diff --git a/src/ClaudeDo.Data/Models/EffortRegistry.cs b/src/ClaudeDo.Data/Models/EffortRegistry.cs new file mode 100644 index 00000000..096d231f --- /dev/null +++ b/src/ClaudeDo.Data/Models/EffortRegistry.cs @@ -0,0 +1,25 @@ +namespace ClaudeDo.Data.Models; + +/// +/// The reasoning-effort levels the claude CLI accepts for --effort. +/// +public static class EffortRegistry +{ + public static readonly IReadOnlyList Levels = new[] { "low", "medium", "high", "xhigh", "max" }; + + public const string DefaultLevel = "high"; + + /// + /// Validate an effort level from external input. Null/blank → null (don't pass --effort). + /// Returns the canonical lowercase level; throws on an unknown value. + /// + public static string? NormalizeLevel(string? effort) + { + var e = effort?.Trim(); + if (string.IsNullOrEmpty(e)) return null; + foreach (var level in Levels) + if (string.Equals(level, e, StringComparison.OrdinalIgnoreCase)) + return level; + throw new ArgumentException($"Unknown effort '{effort}'. Allowed: {string.Join(", ", Levels)}."); + } +} diff --git a/src/ClaudeDo.Data/Models/ListEntity.cs b/src/ClaudeDo.Data/Models/ListEntity.cs index b8ec9f1d..517e0e33 100644 --- a/src/ClaudeDo.Data/Models/ListEntity.cs +++ b/src/ClaudeDo.Data/Models/ListEntity.cs @@ -8,6 +8,9 @@ public sealed class ListEntity public string? WorkingDir { get; set; } public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType; public int SortOrder { get; set; } + // A manual list holds reminders rather than Claude work: new tasks created here start out + // manual (see TaskEntity.IsManual). + public bool IsManual { get; set; } // Navigation properties public ListConfigEntity? Config { get; set; } diff --git a/src/ClaudeDo.Data/Models/ModelPresets.cs b/src/ClaudeDo.Data/Models/ModelPresets.cs new file mode 100644 index 00000000..4d06b482 --- /dev/null +++ b/src/ClaudeDo.Data/Models/ModelPresets.cs @@ -0,0 +1,91 @@ +using System.Text.Json; + +namespace ClaudeDo.Data.Models; + +/// Per-model run defaults: which reasoning effort and turn budget a session gets when +/// it runs under and nothing more specific overrides it. +public sealed record ModelPreset(string Model, string Effort, int MaxTurns); + +/// +/// Serialization + lookup for (a JSON array on the +/// singleton settings row). One row per entry; the row supplies +/// the *global* effort and max-turns defaults, which list- and task-level overrides still beat. +/// +public static class ModelPresets +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + /// Shipping defaults — one row per known model alias. + public static IReadOnlyList Defaults { get; } = new[] + { + new ModelPreset("haiku", "medium", 20), + new ModelPreset("sonnet", "high", 30), + new ModelPreset("opus", "high", 40), + new ModelPreset("fable", "high", 25), + }; + + public static string SerializeDefaults() => Serialize(Defaults); + + public static string Serialize(IEnumerable presets) + => JsonSerializer.Serialize(Normalize(presets), Json); + + /// Parses the stored JSON, falling back to for anything absent + /// or malformed — a bad settings row must never stop a run from starting. + public static IReadOnlyList Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Defaults; + try + { + var parsed = JsonSerializer.Deserialize>(json, Json); + return parsed is { Count: > 0 } ? Normalize(parsed) : Defaults; + } + catch (JsonException) + { + return Defaults; + } + } + + /// The preset for , or the default row for it. Never null so + /// callers don't have to special-case an unconfigured or unknown model. + public static ModelPreset For(IReadOnlyList presets, string? model) + { + var alias = (model ?? ModelRegistry.DefaultAlias).Trim(); + foreach (var p in presets) + if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase)) + return p; + foreach (var p in Defaults) + if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase)) + return p; + return new ModelPreset(alias, EffortRegistry.DefaultLevel, 30); + } + + public static ModelPreset For(string? json, string? model) => For(Parse(json), model); + + // One row per known alias, canonical order, clamped values — so an edited or partial list + // still round-trips into something every consumer can rely on. + private static IReadOnlyList Normalize(IEnumerable presets) + { + var byModel = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in presets) + { + if (p is null || string.IsNullOrWhiteSpace(p.Model)) continue; + byModel[p.Model.Trim()] = p; + } + + var result = new List(); + foreach (var fallback in Defaults) + { + var source = byModel.TryGetValue(fallback.Model, out var found) ? found : fallback; + var effort = TryNormalizeEffort(source.Effort) ?? fallback.Effort; + var turns = source.MaxTurns is >= 1 and <= 200 ? source.MaxTurns : fallback.MaxTurns; + result.Add(new ModelPreset(fallback.Model, effort, turns)); + } + return result; + } + + private static string? TryNormalizeEffort(string? effort) + { + try { return EffortRegistry.NormalizeLevel(effort); } + catch (ArgumentException) { return null; } + } +} diff --git a/src/ClaudeDo.Data/Models/ModelRegistry.cs b/src/ClaudeDo.Data/Models/ModelRegistry.cs index 219db35b..57a45c7c 100644 --- a/src/ClaudeDo.Data/Models/ModelRegistry.cs +++ b/src/ClaudeDo.Data/Models/ModelRegistry.cs @@ -2,7 +2,7 @@ namespace ClaudeDo.Data.Models; public static class ModelRegistry { - public static readonly IReadOnlyList Aliases = new[] { "sonnet", "opus", "haiku" }; + public static readonly IReadOnlyList Aliases = new[] { "sonnet", "opus", "haiku", "fable" }; /// Model aliases ordered cheapest → most capable. Single source for prompt cost guidance. public static readonly IReadOnlyList ByCostAscending = new[] { "haiku", "sonnet", "opus" }; diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index d288b884..8d0944c1 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -43,6 +43,10 @@ public sealed class TaskEntity public int? MaxTurns { get; set; } public bool IsStarred { get; set; } public bool IsMyDay { get; set; } + // Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep, + // list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still + // allowed. New tasks in a manual list default to true. + public bool IsManual { get; set; } public string? Notes { get; set; } public int SortOrder { get; set; } public string? SessionSkills { get; set; } diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index e4e661f5..4261b3ac 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -15,6 +15,9 @@ "defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden", "model": "Modell", "maxTurns": "Max. Durchläufe", + "effort": "Effort", + "modelPresets": "Vorgaben pro Modell", + "modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.", "permission": "Berechtigung", "maxParallelExecutions": "Max. parallele Ausführungen", "maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 70f26da1..88fe7f88 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -15,6 +15,9 @@ "defaultInstructionsPlaceholder": "Baseline instructions applied to every task", "model": "Model", "maxTurns": "Max turns", + "effort": "Effort", + "modelPresets": "Per-model defaults", + "modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.", "permission": "Permission", "maxParallelExecutions": "Max parallel executions", "maxParallelExecutionsHint": "How many queued tasks the worker runs at once.", diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 7d565eda..bc6e18e4 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -607,7 +607,11 @@ public sealed record AppSettingsDto( string? ReportExcludedPaths, int StandupWeekday, int DailyPrepMaxTasks, - List? SessionSkills = null); + List? SessionSkills = null, + List? ModelPresets = null); + +// Per-model run defaults (effort + turn budget) edited in Settings → General. +public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns); public sealed record SessionSkillDto( string Name, diff --git a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs index b74edfe0..c51bcd1b 100644 --- a/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs @@ -47,7 +47,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa [ObservableProperty] private string _effectiveSystemPromptHint = ""; private string _globalModel = ModelRegistry.DefaultAlias; - private int _globalMaxTurns = 100; + // The global max-turns default is per-model (Settings -> General), so it moves with whichever + // model actually ends up in effect here. + private IReadOnlyList _presets = ModelPresets.Defaults; + private string EffectiveModel => Model ?? _listModel ?? _globalModel; + private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns; private string? _listModel; // Task scope only private int? _listMaxTurns; // Task scope only private string? _listAgentName; // Task scope only @@ -56,7 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa private CancellationTokenSource? _saveCts; public int EffectiveMaxTurns => - MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); + MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns); public ObservableCollection ModelOptions { get; } = new(ModelRegistry.Aliases); public ObservableCollection Agents { get; } = new(); @@ -97,7 +101,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged; } - partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); } + partial void OnModelChanged(string? value) + { + RecomputeModelBadge(); + // A different model means a different global turn default. + RecomputeTurnsBadge(); + OnPropertyChanged(nameof(EffectiveMaxTurns)); + QueueSave(); + } partial void OnMaxTurnsChanged(decimal? value) { @@ -130,8 +141,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa { var own = MaxTurns?.ToString(); var (value, source) = _scope == AgentConfigScope.Task - ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString()) - : InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString()); + ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString()) + : InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString()); TurnsInheritedHint = value; TurnsBadge = BadgeFor(source, MaxTurns is not null); } @@ -284,7 +295,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa { var app = await _worker.GetAppSettingsAsync(); _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; - _globalMaxTurns = app?.DefaultMaxTurns ?? 100; + _presets = app?.ModelPresets is { Count: > 0 } rows + ? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList() + : ModelPresets.Defaults; } private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs index d0c0ae1e..2965cada 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/Settings/GeneralSettingsTabViewModel.cs @@ -27,6 +27,23 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase public ObservableCollection SessionSkills { get; } = new(); + /// One editable row per model alias: the effort and turn budget a run gets under that + /// model. Supplies the global defaults; list- and task-level max-turns overrides still win. + public ObservableCollection ModelPresets { get; } = new(); + + public void LoadModelPresets(IReadOnlyCollection? presets) + { + ModelPresets.Clear(); + var source = presets is { Count: > 0 } + ? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList() + : Data.Models.ModelPresets.Defaults.ToList(); + foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source))) + ModelPresets.Add(new ModelPresetRowViewModel(p)); + } + + public List ModelPresetDtos() + => ModelPresets.Select(r => new ModelPresetDto(r.Model, r.Effort, (int)r.MaxTurns)).ToList(); + public GeneralSettingsTabViewModel() { } public GeneralSettingsTabViewModel(ILocalizer localizer, Action persist) @@ -50,10 +67,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase public string? Validate() { - if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) - return "Max turns must be between 1 and 200."; if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20) return "Max parallel executions must be between 1 and 20."; + foreach (var row in ModelPresets) + if (row.MaxTurns is < 1 or > 200) + return $"Max turns for {row.Model} must be between 1 and 200."; return null; } @@ -73,3 +91,21 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase return names.Count == 0 ? null : names; } } + +/// One row of the per-model preset table. Single consumer, so it lives here. +public sealed partial class ModelPresetRowViewModel : ViewModelBase +{ + public string Model { get; } + public IReadOnlyList EffortLevels { get; } = EffortRegistry.Levels; + + [ObservableProperty] private string _effort; + // decimal so it binds straight to a NumericUpDown, like the other numeric settings. + [ObservableProperty] private decimal _maxTurns; + + public ModelPresetRowViewModel(ModelPreset preset) + { + Model = preset.Model; + _effort = preset.Effort; + _maxTurns = preset.MaxTurns; + } +} diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs index 0842eed6..b28c1f77 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -73,6 +73,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase await OnlineInbox.LoadAsync(); await SessionSkills.LoadAsync(); await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills); + General.LoadModelPresets(dto?.ModelPresets); } finally { IsBusy = false; } } @@ -102,7 +103,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase .Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()), General.StandupWeekday, Prime.DailyPrepMaxTasks, - General.SelectedSessionSkillNames()); + General.SelectedSessionSkillNames(), + General.ModelPresetDtos()); await _worker.UpdateAppSettingsAsync(dto); await Prime.SaveAsync(); await OnlineInbox.SaveAsync(); diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml index cd99eef9..77fe0b62 100644 --- a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -67,7 +67,7 @@ PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}" Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/> - + - - - - + + + + + + + + + + + + + + + + + + + + + + ? SessionSkills = null); + List? SessionSkills = null, + List? ModelPresets = null); + +// Per-model run defaults (effort + turn budget) edited in Settings -> General. +public record ModelPresetDto(string Model, string Effort, int MaxTurns); public record SessionSkillDto( string Name, @@ -318,7 +322,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.ReportExcludedPaths, row.StandupWeekday, row.DailyPrepMaxTasks, - SkillsFromJson(row.SessionSkills)); + SkillsFromJson(row.SessionSkills), + Data.Models.ModelPresets.Parse(row.ModelPresets) + .Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList()); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -341,6 +347,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday, DailyPrepMaxTasks = dto.DailyPrepMaxTasks, SessionSkills = SkillsToJson(dto.SessionSkills), + // Normalized on the way in (unknown models dropped, effort validated, turns clamped). + ModelPresets = dto.ModelPresets is { Count: > 0 } + ? Data.Models.ModelPresets.Serialize( + dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns))) + : Data.Models.ModelPresets.SerializeDefaults(), }); } diff --git a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs index 137fde3a..56a783f6 100644 --- a/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs +++ b/src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs @@ -11,7 +11,8 @@ public sealed record ClaudeRunConfig( string? PermissionMode = null, string? McpConfigPath = null, string? AllowedTools = null, - IReadOnlyList? SkillNames = null + IReadOnlyList? SkillNames = null, + string? Effort = null ) { public IReadOnlyList SkillNames { get; init; } = SkillNames ?? Array.Empty(); @@ -53,6 +54,12 @@ public sealed class ClaudeArgsBuilder args.Add(config.Model); } + if (!string.IsNullOrWhiteSpace(config.Effort)) + { + args.Add("--effort"); + args.Add(config.Effort); + } + if (config.MaxTurns is int turns && turns > 0) { args.Add("--max-turns"); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index fb654f15..eff7e61e 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -96,6 +96,11 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService ? WindowsTerminalLauncher.BuildResumeArgs(sessionId) : BuildFreshPromptArgs(task); + // Start the hand-driven session at the effort configured for the model this task would run + // under, instead of inheriting whatever the user's global Claude Code config happens to be. + // The model itself is deliberately NOT forced here — the user can still switch it in the TUI. + args = WithEffort(args, EffortFor(globalSettings, task.Model ?? listConfig?.Model)); + // Same run environment variable ClaudeProcess sets for every headless run: the // AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for // every other tool. @@ -123,7 +128,10 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService }; return new LaunchSpec( - ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env); + ctx.WorkingDir, resolvedClaude, + WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), + EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), + env); } public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx) @@ -138,10 +146,13 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService }; return new LaunchSpec( - ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env); + ctx.WorkingDir, resolvedClaude, + WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), + EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)), + env); } - public Task BuildForDirectoryAsync(string directory, CancellationToken ct) + public async Task BuildForDirectoryAsync(string directory, CancellationToken ct) { if (!Directory.Exists(directory)) throw new InvalidOperationException($"Directory does not exist: {directory}"); @@ -149,12 +160,18 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var settings = await new AppSettingsRepository(ctx).GetAsync(ct); + var env = new Dictionary { ["MCP_TOOL_TIMEOUT"] = "200000", }; - return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty(), env)); + // No task and no list here — the global default model's preset decides the effort. + return new LaunchSpec( + directory, resolvedClaude, + WithEffort(Array.Empty(), EffortFor(settings, settings.DefaultModel)), env); } // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll, @@ -209,8 +226,12 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService // (--allowedTools, --add-dir) first, then a single-value flag, then the single-line // positional kickoff LAST — a multi-line positional prompt truncates at the first // newline, so the full multi-line brief travels via the file exposed through --add-dir. + var listConfig = await listRepo.GetConfigAsync(listId, ct); + var settings = await new AppSettingsRepository(ctx).GetAsync(ct); + var args = new List { + "--effort", EffortFor(settings, listConfig?.Model), "--permission-mode", "default", "--allowedTools", MergeHelperAllowedTools, "--add-dir", sessionDir, repoDir, @@ -227,6 +248,26 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(repoDir, resolvedClaude, args, env); } + // The reasoning effort configured for a model in Settings → General. Falls back to the shipped + // preset for that model, so a missing/malformed settings row can never block a launch. + private static string EffortFor(AppSettingsEntity settings, string? model) + => ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort; + + // Prepends `--effort `. It has to lead: a positional kickoff prompt must stay last, and + // it may only follow a single-value flag — a variadic flag would swallow it. + private static IReadOnlyList WithEffort(IReadOnlyList args, string effort) + { + var result = new List(args.Count + 2) { "--effort", effort }; + result.AddRange(args); + return result; + } + + private AppSettingsEntity ReadSettings() + { + using var ctx = _dbFactory.CreateDbContext(); + return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult(); + } + // The positional prompt claude opens the interactive session on. Empty (no positional arg) // if the task has neither a title nor a description. private static IReadOnlyList BuildFreshPromptArgs(TaskEntity task) diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 3d362bac..611e3ebc 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -516,14 +516,20 @@ public sealed class TaskRunner var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills); var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct); + // The model decides the global effort/turn defaults: one preset row per model alias + // (Settings → General). List- and task-level max-turns overrides still win. + var model = task.Model ?? listConfig?.Model ?? global.DefaultModel; + var preset = Data.Models.ModelPresets.For(global.ModelPresets, model); + return new ClaudeRunConfig( - Model: task.Model ?? listConfig?.Model ?? global.DefaultModel, + Model: model, SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions, AgentPath: task.AgentPath ?? listConfig?.AgentPath, ResumeSessionId: resumeSessionId, - MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns), + MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns), PermissionMode: global.DefaultPermissionMode, - SkillNames: skillNames); + SkillNames: skillNames, + Effort: preset.Effort); } private async Task> FilterToInstalledSkillsAsync( diff --git a/tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs b/tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs new file mode 100644 index 00000000..3aed384c --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/ModelPresetsTests.cs @@ -0,0 +1,91 @@ +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Data.Tests; + +public class ModelPresetsTests +{ + [Fact] + public void Defaults_cover_every_known_alias() + { + Assert.Equal( + ModelRegistry.Aliases.OrderBy(a => a), + ModelPresets.Defaults.Select(p => p.Model).OrderBy(m => m)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json at all")] + public void Parse_falls_back_to_defaults_when_unusable(string? json) + { + Assert.Equal(ModelPresets.Defaults, ModelPresets.Parse(json)); + } + + [Fact] + public void Serialize_roundtrips() + { + var edited = new[] { new ModelPreset("opus", "max", 55) }; + var parsed = ModelPresets.Parse(ModelPresets.Serialize(edited)); + + Assert.Equal("max", ModelPresets.For(parsed, "opus").Effort); + Assert.Equal(55, ModelPresets.For(parsed, "opus").MaxTurns); + } + + [Fact] + public void Serialize_fills_missing_aliases_from_defaults() + { + var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("opus", "max", 55) })); + + Assert.Equal(ModelRegistry.Aliases.Count, parsed.Count); + Assert.Equal(ModelPresets.For(ModelPresets.Defaults, "haiku"), ModelPresets.For(parsed, "haiku")); + } + + [Theory] + [InlineData("nonsense", 30)] // unknown effort → the default row's effort + [InlineData("high", 0)] // turns out of range → the default row's turns + [InlineData("high", 999)] + public void Serialize_rejects_out_of_range_values(string effort, int turns) + { + var fallback = ModelPresets.For(ModelPresets.Defaults, "sonnet"); + var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("sonnet", effort, turns) })); + var row = ModelPresets.For(parsed, "sonnet"); + + if (effort == "nonsense") Assert.Equal(fallback.Effort, row.Effort); + if (turns is 0 or 999) Assert.Equal(fallback.MaxTurns, row.MaxTurns); + } + + [Fact] + public void For_unknown_model_yields_a_usable_row() + { + var row = ModelPresets.For(ModelPresets.Defaults, "some-future-model"); + + Assert.Equal("some-future-model", row.Model); + Assert.Contains(row.Effort, EffortRegistry.Levels); + Assert.True(row.MaxTurns > 0); + } + + [Fact] + public void For_null_model_uses_the_default_alias() + { + Assert.Equal( + ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias), + ModelPresets.For(ModelPresets.Defaults, null)); + } + + [Theory] + [InlineData("low", "low")] + [InlineData("XHIGH", "xhigh")] + [InlineData(" max ", "max")] + public void NormalizeLevel_canonicalizes(string input, string expected) + { + Assert.Equal(expected, EffortRegistry.NormalizeLevel(input)); + } + + [Fact] + public void NormalizeLevel_blank_means_unset_and_unknown_throws() + { + Assert.Null(EffortRegistry.NormalizeLevel(null)); + Assert.Throws(() => EffortRegistry.NormalizeLevel("gigantic")); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs index 8ce806a0..02052daa 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs @@ -26,11 +26,15 @@ public class AgentConfigEditorViewModelTests public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; } } + // The global turn default is per-model now, so the preset row for `model` is what carries + // `turns` — DefaultMaxTurns is no longer consulted. private static AppSettingsDto AppWith(string model, int turns) => new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns, DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling", CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30, - ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5); + ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5, + SessionSkills: null, + ModelPresets: new List { new(model, "high", turns) }); private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) => new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow, diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs index 30e0ad6c..2dd827c0 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs @@ -22,6 +22,25 @@ public sealed class ClaudeArgsBuilderTests Assert.DoesNotContain("--append-system-prompt", args); Assert.DoesNotContain("--agents", args); Assert.DoesNotContain("--resume", args); + Assert.DoesNotContain("--effort", args); + } + + [Fact] + public void Effort_Adds_Effort_Flag() + { + var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: "xhigh")); + Assert.Contains("--effort", args); + Assert.Equal("xhigh", args[args.ToList().IndexOf("--effort") + 1]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Blank_Effort_Omits_The_Flag(string? effort) + { + var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: effort)); + Assert.DoesNotContain("--effort", args); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index aed5d6f6..12b7b298 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -184,7 +184,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.True(Directory.Exists(wtRow.Path)); Assert.Equal(wtRow.Path, spec.Cwd); - Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt + Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh session seeds the task title as the prompt Assert.Equal(_claudeStubPath, spec.Exe); } @@ -230,7 +230,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); - Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args); + Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec)); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); } @@ -246,7 +246,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args); + Assert.Equal(new[] { "--resume", "sess-kept" }, ArgsAfterEffort(spec)); } [Fact] @@ -261,7 +261,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt + Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt Assert.Equal(_worktreeDir, spec.Cwd); } @@ -277,7 +277,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt + Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt } [Fact] @@ -297,7 +297,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var svc = BuildService(); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); - Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args); + Assert.Equal(new[] { "T\n\nDo the thing" }, ArgsAfterEffort(spec)); } [Fact] @@ -333,7 +333,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_claudeStubPath, spec.Exe); - Assert.Empty(spec.Args); + Assert.Empty(ArgsAfterEffort(spec)); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Empty(_seeder.Calls); } @@ -348,6 +348,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable // ── Merge helper ── + /// Every ConPTY spec now leads with `--effort ` from the per-model preset (the seeded + /// settings row has no overrides, so the shipped default for the default model applies). + /// Asserts that pair and returns the rest of the args for the test's own comparison. + private static string[] ArgsAfterEffort(LaunchSpec spec) + { + var args = spec.Args.ToList(); + Assert.Equal("--effort", args[0]); + Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]); + return args.Skip(2).ToArray(); + } + private readonly List _mergeHelperSessionDirs = new(); /// The session dir is the value right after --add-dir; register it for cleanup @@ -498,11 +509,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var spec = BuildService().BuildPlanningResume(ctx); - Assert.Equal("--permission-mode", spec.Args[0]); - Assert.Equal("default", spec.Args[1]); - Assert.Equal("--allowedTools", spec.Args[2]); - Assert.Contains("mcp__claudedo__", spec.Args[3]); - Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray()); + var args = ArgsAfterEffort(spec); + Assert.Equal("--permission-mode", args[0]); + Assert.Equal("default", args[1]); + Assert.Equal("--allowedTools", args[2]); + Assert.Contains("mcp__claudedo__", args[3]); + Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray()); Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); Assert.Equal(_worktreeDir, spec.Cwd); }