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.
This commit is contained in:
Mika Kuns
2026-07-27 15:02:51 +02:00
parent c93a20f20c
commit fde9615b34
27 changed files with 1318 additions and 40 deletions
@@ -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<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
public async Task<LaunchSpec> 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<string, string>
{
["MCP_TOOL_TIMEOUT"] = "200000",
};
return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty<string>(), env));
// No task and no list here — the global default model's preset decides the effort.
return new LaunchSpec(
directory, resolvedClaude,
WithEffort(Array.Empty<string>(), 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<string>
{
"--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 <level>`. 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<string> WithEffort(IReadOnlyList<string> args, string effort)
{
var result = new List<string>(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<string> BuildFreshPromptArgs(TaskEntity task)