Skip to content
Closed
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
474 changes: 474 additions & 0 deletions Dayflow/Dayflow/Core/AI/ChatCLIModelCatalog.swift

Large diffs are not rendered by default.

163 changes: 140 additions & 23 deletions Dayflow/Dayflow/Core/AI/ChatCLIProcessRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@ import AppKit
import Darwin
import Foundation

enum ClaudeCLIWrapperMode: Sendable, Equatable {
case safeMode

fileprivate var commandArgument: String {
"--safe-mode"
}
}

enum ClaudeCLISessionMode: Sendable, Equatable {
/// Run a single turn without writing a resumable Claude session to disk.
case ephemeral
Expand All @@ -27,7 +19,6 @@ struct ClaudeCLIExecutionProfile: Sendable {
}

fileprivate let kind: Kind
let wrapperMode: ClaudeCLIWrapperMode
fileprivate let allowedReadPath: String?

private static let optimizedTranscriptionSystemPrompt =
Expand All @@ -38,26 +29,22 @@ struct ClaudeCLIExecutionProfile: Sendable {

static let optimizedTranscription = ClaudeCLIExecutionProfile(
kind: .optimizedTranscription,
wrapperMode: .safeMode,
allowedReadPath: nil
)

static let optimizedCardGeneration = ClaudeCLIExecutionProfile(
kind: .optimizedCardGeneration,
wrapperMode: .safeMode,
allowedReadPath: nil
)

static let optimizedTranscriptionCorrection = ClaudeCLIExecutionProfile(
kind: .optimizedTranscriptionCorrection,
wrapperMode: .safeMode,
allowedReadPath: nil
)

func allowingRead(at path: String) -> ClaudeCLIExecutionProfile {
ClaudeCLIExecutionProfile(
kind: kind,
wrapperMode: wrapperMode,
allowedReadPath: path
)
}
Expand All @@ -70,33 +57,28 @@ struct ClaudeCLIExecutionProfile: Sendable {

case .optimizedCardGeneration:
return [
wrapperMode.commandArgument,
"--tools", LoginShellRunner.shellEscape(""),
"--disable-slash-commands",
"--system-prompt",
LoginShellRunner.shellEscape(
"Return only the JSON requested by the user. Do not use tools."
),
"--prompt-suggestions", "false",
"--name", "dayflow-card-generation",
] + persistenceArguments
+ ClaudeCapabilityProbe.safeModeArguments
}
}

private func transcriptionCommandArguments(persistenceArguments: [String]) -> [String] {
var arguments = [
wrapperMode.commandArgument,
"--disable-slash-commands",
"--prompt-suggestions", "false",
"--settings",
LoginShellRunner.shellEscape(
#"{"alwaysThinkingEnabled":false,"effortLevel":"low"}"#
),
"--system-prompt",
LoginShellRunner.shellEscape(Self.optimizedTranscriptionSystemPrompt),
"--tools", "Read",
"--name", "dayflow-transcription",
]
] + ClaudeCapabilityProbe.safeModeArguments
arguments.append(contentsOf: persistenceArguments)
if let allowedReadPath {
arguments.append(contentsOf: [
Expand Down Expand Up @@ -891,8 +873,18 @@ struct ChatCLIProcessRunner {
if let model = model {
cmdParts.append(contentsOf: ["--model", model])
}
if let reasoningEffort {
cmdParts.append(contentsOf: ["--effort", reasoningEffort])
// Claude Code 2.1.22 dropped the `--effort` CLI flag. Effort is now set exclusively
// through the `--settings` JSON (`effortLevel` key). For the optimized profile path the
// profile already injects its own `--settings` (transcription sets
// `alwaysThinkingEnabled:false,effortLevel:low`), so we leave it alone. For the legacy
// no-profile path we inject a single-key settings JSON that sets just the effort.
if profile == nil, let reasoningEffort {
cmdParts.append(contentsOf: [
"--settings",
LoginShellRunner.shellEscape(
#"{"effortLevel":""# + reasoningEffort + #""}"#
),
])
}
if profile == nil && !disableTools {
cmdParts.append("--dangerously-skip-permissions")
Expand Down Expand Up @@ -972,8 +964,16 @@ struct ChatCLIProcessRunner {
if let model {
cmdParts.append(contentsOf: ["--model", model])
}
// Claude Code 2.1.22 dropped the `--effort` CLI flag. Effort is set via `--settings`
// JSON's `effortLevel` key. Streaming calls don't use a profile, so we inject the
// settings here directly.
if let reasoningEffort {
cmdParts.append(contentsOf: ["--effort", reasoningEffort])
cmdParts.append(contentsOf: [
"--settings",
LoginShellRunner.shellEscape(
#"{"effortLevel":""# + reasoningEffort + #""}"#
),
])
}
cmdParts.append("--dangerously-skip-permissions")
cmdParts.append("--strict-mcp-config")
Expand Down Expand Up @@ -1424,3 +1424,120 @@ private struct ClaudeNonStreamingEvent: Decodable {
}
}
}

// MARK: - Capability probe

/// Detects which Claude Code CLI features are available on the user's machine, so the runner
/// only ever passes flags the installed binary actually understands. Without this, users on
/// older Claude Code releases (e.g. 2.1.22, January 2026) would see every recording batch fail
/// with `error: unknown option '--safe-mode'` (and the same for `--effort` / `--prompt-suggestions`,
/// which landed in 2.1.191+ / 2.1.195+).
///
/// Detection runs `claude --version` once and caches the parsed `major.minor.patch` triple for
/// the lifetime of the process. The probe falls back to "no extras" if the binary is missing,
/// the version string can't be parsed, or the call times out — so the runner never blocks the
/// pipeline waiting for a missing CLI.
enum ClaudeCapabilityProbe {
/// First Claude Code release that recognizes `--safe-mode` (June 8, 2026). Below this we
/// skip the flag entirely; the profile's own `--tools Read` + `--allowedTools Read(...)`
/// already restrict Claude to the screenshot path, which is the security property we care
/// about. `--safe-mode` is a defense-in-depth layer on top, not a substitute.
static let safeModeMinimumVersion = SemanticVersion(major: 2, minor: 1, patch: 169)

/// Cached version of the installed Claude CLI. `nil` means "we haven't probed yet" OR
/// "the probe failed" (missing binary, timeout, unparseable version). Both cases degrade
/// to the safe "no extra flags" path.
private static let cachedVersion: SemanticVersion? = {
probeInstalledVersion()
}()

/// `["--safe-mode"]` when the installed Claude Code is new enough; `[]` otherwise. Appended
/// to the profile's command parts so the per-profile permission flags (`--tools`,
/// `--allowedTools`, `--disable-slash-commands`) stay the primary isolation mechanism.
static var safeModeArguments: [String] {
guard let version = cachedVersion, version >= safeModeMinimumVersion else {
return []
}
return ["--safe-mode"]
}

/// Exposed for tests and for the "why is this batch failing?" diagnostic the failed screen
/// shows next to the retry button. Strings the version probe picked up so we can tell a
/// user "your Claude Code is 2.1.22 — upgrade to 2.1.169+ for the extra sandbox layer"
/// without making them run `--version` themselves.
static var installedVersionDescription: String {
guard let version = cachedVersion else {
return "unknown (probe failed or `claude` not on PATH)"
}
return "\(version.major).\(version.minor).\(version.patch)"
}

private static func probeInstalledVersion() -> SemanticVersion? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = [
"-l", "-i", "-c",
"command -v claude >/dev/null 2>&1 && claude --version 2>/dev/null | head -1",
]
let stdoutPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = Pipe()
// The probe runs at most once per process; a 3-second ceiling keeps a hung `claude` from
// blocking the recording pipeline's first call.
let timeoutSeconds: TimeInterval = 3

do {
try process.run()
} catch {
return nil
}

let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
process.waitUntilExit()
semaphore.signal()
}
if semaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
process.terminate()
return nil
}

let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
guard let raw = String(data: data, encoding: .utf8) else { return nil }
return SemanticVersion.parse(raw)
}
}

/// `major.minor.patch` triple, compared element-wise. Handles Anthropic's semver-with-date
/// noise (e.g. `2.1.0 (2026-01-07)`) by stopping the parse at the first non-numeric segment.
struct SemanticVersion: Comparable, Sendable {
let major: Int
let minor: Int
let patch: Int

static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}

/// Pulls the first `MAJOR.MINOR.PATCH` triple out of any string. Returns nil if the head
/// of the string isn't a recognizable version — that path is the version probe's "I have
/// no idea, fall back to safe defaults" branch.
static func parse(_ raw: String) -> SemanticVersion? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
// Strip a leading `v` if present (`v2.1.22`).
let candidate = trimmed.hasPrefix("v") ? String(trimmed.dropFirst()) : trimmed
let parts = candidate.split(separator: ".")
guard parts.count >= 3,
let major = Int(parts[0]),
let minor = Int(parts[1])
else {
return nil
}
// The patch segment may carry trailing junk (`2.1.0 (2026-01-07)`); take the leading int.
let patchHead = parts[2].prefix { $0.isNumber }
guard let patch = Int(patchHead) else { return nil }
return SemanticVersion(major: major, minor: minor, patch: patch)
}
}
88 changes: 88 additions & 0 deletions Dayflow/Dayflow/Core/AI/ClaudeModelPreference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// ClaudeModelPreference.swift
// Dayflow
//
// Persists the user's choice of Claude model. Mirrors
// `GeminiModelPreference` so the Settings → Providers tab can show a
// picker with the same UX as the Gemini one.
//
// The `rawValue` is the alias the Claude CLI accepts (e.g. `sonnet`),
// while `displayName` is the user-facing label that includes the
// resolved full model name (e.g. "Claude Sonnet 5"). The catalog
// (`ChatCLIModelCatalog`) is what populates `displayName` — at minimum
// we know the alias resolves to a real model on the user's account,
// because we probed the CLI to build the list in the first place.
//
// The stored preference is the *alias* (not the full name) because
// that's what the CLI's `--model` flag actually accepts and because
// aliases track the user's account to the latest release of that
// model family (e.g. `sonnet` will follow Sonnet 5 → Sonnet 5.5
// without us having to bump a stored version).

import Foundation

enum ClaudeModel: String, Codable, CaseIterable {
// Aliases the Claude CLI accepts as `--model` values. Each one
// resolves to the latest model in that family on the user's account.
// The list is the same set the CLI probe iterates over
// (`ChatCLIModelCatalog.knownClaudeAliases`); keeping them in sync
// matters because `ClaudeModel(rawValue:)` is how we read a probed
// result back into the picker.
case sonnet
case opus
case fable
case haiku

/// User-facing label for the picker. The `displayName` baked in
/// here is a *baseline* — the Settings UI prefers the live
/// `displayName` returned by `ChatCLIModelCatalog` so the user
/// always sees the resolved full model name (e.g. "Claude Sonnet 5")
/// rather than a hard-coded string.
var displayName: String {
switch self {
case .sonnet: return "Claude Sonnet"
case .opus: return "Claude Opus"
case .fable: return "Claude Fable"
case .haiku: return "Claude Haiku"
}
}

/// One-line hint shown under the picker — keeps users oriented
/// between quality/speed tradeoffs without overwhelming the UI.
var hint: String {
switch self {
case .sonnet: return "Balanced — best default for most users"
case .opus: return "Highest quality, slower and pricier"
case .fable: return "Experimental — newest reasoning family"
case .haiku: return "Fastest — lower quality summaries"
}
}
}

struct ClaudeModelPreference: Codable {
// Key bump intentionally hard-resets existing users to the new
// ordering, matching the `GeminiModelPreference` convention.
private static let storageKey = "claudeSelectedModel_v1"

let primary: ClaudeModel

static let `default` = ClaudeModelPreference(primary: .sonnet)

static func load(from defaults: UserDefaults = .standard) -> ClaudeModelPreference {
if let data = defaults.data(forKey: storageKey),
let preference = try? JSONDecoder().decode(ClaudeModelPreference.self, from: data)
{
return preference
}

let preference = ClaudeModelPreference.default
preference.save(to: defaults)
return preference
}

func save(to defaults: UserDefaults = .standard) {
if let data = try? JSONEncoder().encode(self) {
defaults.set(data, forKey: Self.storageKey)
}
}
}
7 changes: 6 additions & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider+ActivityCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ extension ClaudeProvider {
static func activityCardModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
(model: "claude-sonnet", reasoningEffort: "low")
// Mirrors `transcriptionModelConfiguration` — we always pass
// the user's selected alias to the CLI rather than a hard-coded
// model name. The Settings → Providers tab is what writes this
// preference; the catalog at `ChatCLIModelCatalog` powers the
// picker with the live display names.
(model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low")
}

func generateActivityCards(
Expand Down
11 changes: 10 additions & 1 deletion Dayflow/Dayflow/Core/AI/ClaudeProvider+Transcription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ extension ClaudeProvider {
static func transcriptionModelConfiguration() -> (
model: String, reasoningEffort: String?
) {
(model: "claude-sonnet", reasoningEffort: "low")
// The Claude CLI's `--model` flag only accepts an alias
// (`sonnet`, `opus`, `fable`, `haiku`, …) or a full model name
// like `claude-fable-5`. Anything else returns "It may not exist
// or you may not have access to it" → exit 1. We persist the
// *alias* in `ClaudeModelPreference` because aliases track the
// latest release of each family automatically — Sonnet today
// becomes Sonnet 5.5 tomorrow without us bumping a stored
// version. The user picks the alias from the Settings → Providers
// tab; we send that exact string to the CLI.
(model: ClaudeModelPreference.load().primary.rawValue, reasoningEffort: "low")
}

func transcribeScreenshots(
Expand Down
Loading