Files
nucleic/Sources/NucleicCore/AppStore.swift
T
abkslmandClaude Opus 4.8 28282471d9 Detect edit conflicts automatically instead of relying on the agent
The agent-driven check_conflict tool was unreliable — models simply don't
call it (confirmed: zero calls across all test sessions). Replace the
dependency on model cooperation with automatic interception at the point
Nucleic already controls: the permission server.

When conflict coordination is active, claude always runs in `--permission-mode
default` so every tool call routes through our permission server (Claude's
`auto` mode auto-accepts edits before we can see them — confirmed empirically),
and Nucleic reproduces auto-approve itself (allow all but destructive). Every
Edit/Write/NotebookEdit is conflict-checked against other active sessions'
worktree footprints before it lands; on overlap the Defer/Cancel/Override sheet
blocks the edit. Path normalization handles sandboxed (canonicalized) worktrees.

The check_conflict MCP tool is kept as a secondary explicit path, but the
system-prompt instruction is dropped (no longer needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-12 23:58:46 -07:00

1240 lines
59 KiB
Swift

import Foundation
import Observation
/// Rollup numbers for the home dashboard.
public struct DashboardStats: Sendable, Equatable {
public var projects: Int
public var chats: Int
public var activeChats: Int
public var messages: Int
public var activeDays: Int
public init(projects: Int = 0, chats: Int = 0, activeChats: Int = 0, messages: Int = 0, activeDays: Int = 0) {
self.projects = projects
self.chats = chats
self.activeChats = activeChats
self.messages = messages
self.activeDays = activeDays
}
public static let empty = DashboardStats()
}
/// A compact, `Sendable` view of a session for the sidebar / dashboard list.
public struct SessionSummary: Sendable, Identifiable, Equatable {
public let id: SessionID
public let projectID: ProjectID
public var title: String
public var status: SessionStatus
/// Refines `.awaitingInput`: whether the last turn ended asking the user
/// something vs. finishing the work. `nil` until classified.
public var disposition: TurnDisposition?
public var diffStat: DiffStat?
public var pendingApprovalCount: Int
public var auto: Bool
public var favorite: Bool
public var archived: Bool
public var updatedAt: Date
public init(_ session: Session, pendingApprovalCount: Int) {
self.id = session.id
self.projectID = session.projectID
self.title = session.title
self.status = session.status
self.disposition = session.lastTurnDisposition
self.diffStat = session.diffStat
self.pendingApprovalCount = pendingApprovalCount
self.auto = session.auto
self.favorite = session.favorite
self.archived = session.archived
self.updatedAt = session.updatedAt
}
}
/// Open to-dos for one project (or the unassigned bucket), with a glanceable
/// model/heuristic summary of the cluster — the home to-do inbox, grouped.
public struct TodoGroup: Identifiable, Sendable, Equatable {
/// The project's id, or "" for the unassigned bucket.
public let id: String
public let projectID: ProjectID?
public let projectName: String
public var todos: [Todo]
/// One-line gist of the group's ideas ("" until generated).
public var summary: String
public init(id: String, projectID: ProjectID?, projectName: String, todos: [Todo], summary: String) {
self.id = id
self.projectID = projectID
self.projectName = projectName
self.todos = todos
self.summary = summary
}
}
/// The top-level UI state and object-graph root (RUNTIME §1). `@MainActor` +
/// `@Observable`: it owns the projects/session list and spawns/owns a
/// `SessionController` per live session, observing each one's canonical event
/// stream to keep the observable summaries + open transcript current. The UI binds
/// to this and sends intents; it never touches a backend or git directly.
@MainActor
@Observable
public final class AppStore: ConflictArbiter {
/// Builds the backend for a new session. The app injects a `ClaudeCodeBackend`
/// factory; tests inject a scripted one.
public typealias BackendFactory = @Sendable (Session) -> any AgentBackend
public private(set) var projects: [Project] = []
public private(set) var summaries: [SessionSummary] = []
/// Captured ideas (the home "to-do" inbox), most-recently-updated first.
public private(set) var todos: [Todo] = []
/// Drives the global quick-add to-do sheet (toggled by the Cmd-T command).
public var quickTodoPresented: Bool = false
/// Project to pre-select in the quick-add sheet (set when capturing from a
/// project page; nil for a global Cmd-T capture).
public var quickTodoProjectID: ProjectID?
/// When set, the edit-to-do sheet is shown for this idea (home or project page).
public var editingTodoID: TodoID?
/// Per-group to-do summaries, keyed by group id (project id, or "" unassigned).
/// Filled asynchronously; `todoGroups` reads it so the UI updates as they land.
private var todoSummaries: [String: String] = [:]
/// Content fingerprint per group so a group is only re-summarized when its ideas
/// actually change (avoids a model call on every unrelated mutation).
private var todoSummarySignatures: [String: Int] = [:]
/// Home-dashboard rollup across all projects/sessions (recomputed on demand).
public private(set) var dashboard: DashboardStats = .empty
/// User-message counts per calendar day, for the activity grid.
public private(set) var activityByDay: [Date: Int] = [:]
public var openSessionID: SessionID? {
didSet {
guard oldValue != openSessionID else { return }
// Reset synchronously so the (possibly shorter) newly-selected session's
// transcript can replace the previous one; the async reload then fills it.
openTranscript = []
openApprovals = []
openSession = nil
openSummary = ""
summaryToken += 1 // invalidate any in-flight summary for the previous session
intelligenceWorkSeq += 1
reloadTask = Task { await reloadOpen() }
}
}
/// When set (and no session is open), the detail pane shows the project
/// overview page instead of the home dashboard. Cleared by `goHome()`.
public var openProjectID: ProjectID?
public private(set) var openTranscript: [AgentEvent] = []
public private(set) var openApprovals: [ApprovalRequest] = []
/// Full state of the open session (title, model, effort, status…) for the detail view.
public private(set) var openSession: Session?
/// Model-generated "what just happened" blurb for the open session's summary card.
public private(set) var openSummary: String = ""
public private(set) var summarizing: Bool = false
/// Surfaced transient error for the UI; settable so views can post their own.
public var lastError: String?
/// The conflict currently shown to the user (head of `conflictQueue`), or nil. The
/// asking agent is blocked until `resolveConflict` answers it.
public private(set) var pendingConflict: ConflictPrompt?
/// Soft-AI provider for summaries + auto-naming (the app swaps in Apple
/// Foundation Models; defaults to a local heuristic for tests / when off).
public var intelligence: any IntelligenceProviding = HeuristicIntelligence()
private var summaryToken = 0
private var namedSessions: Set<SessionID> = []
/// Verdicts from the last AI triage pass, keyed by idea. Drives the inbox's ranked
/// order (when `triageSortEnabled`) and each row's level badge. In-memory only —
/// recomputed on demand, never persisted.
public private(set) var triageItems: [TodoID: TriageItem] = [:]
/// When true, `openTodos` is ordered by the triage ranking instead of FIFO.
public private(set) var triageSortEnabled = false
/// A triage pass is in flight (the Triage button shows a spinner).
public private(set) var triaging = false
/// In-flight intelligence work, tracked so tests can deterministically await it
/// (see `awaitOpenSessionSettled`) instead of sleeping. These chain: reopening a
/// session can spawn a summary, and a turn's classification spawns a summary too.
private var reloadTask: Task<Void, Never>?
private var classifyTask: Task<Void, Never>?
private var summaryTask: Task<Void, Never>?
/// Bumped whenever any of the tasks above is (re)issued; lets the settle helper
/// detect work spawned while it was awaiting and loop until none remains.
private var intelligenceWorkSeq = 0
/// Highest canonical event seq the observer has finished ingesting, per session.
/// Lets the settle helper confirm the observer has caught up to a run's terminal
/// event (and thus spawned its classification) before draining tasks.
private var lastIngestedSeq: [SessionID: UInt64] = [:]
/// Defaults applied to newly-created chats (set by the app from Settings).
public var defaultModel: String?
public var defaultEffort: String?
public var defaultAuto: Bool = false
/// The home chat bar's in-progress message. Held here (not in the transient
/// HomeView) so it survives bouncing between the dashboard and open sessions.
public var homeDraft: String = ""
private let database: any SessionMetadataStore
private let worktrees: any WorktreeManaging
private let backendFactory: BackendFactory
private let transcriptsDir: URL
private let now: @Sendable () -> Date
/// Shared sandbox orchestrator (nil in tests / non-sandbox builds). Same instance the
/// backend factory hands to each `ClaudeCodeBackend`, so lifecycle stays consistent.
private let containerManager: ContainerManager?
/// Shared conflict coordinator (nil in tests). The same instance the backend factory hands
/// to each `ClaudeCodeBackend`; `activateConflictArbitration()` registers self as its arbiter.
private let conflictCoordinator: ConflictCoordinator?
private var controllers: [SessionID: SessionController] = [:]
private var projectsByID: [ProjectID: Project] = [:]
private var observers: [SessionID: Task<Void, Never>] = [:]
/// FIFO of unresolved conflict prompts, each paired with the suspended `arbitrate`
/// continuation it must resume. The head is mirrored to `pendingConflict` for the UI.
private var conflictQueue: [(prompt: ConflictPrompt, continuation: CheckedContinuation<ConflictResolution, Never>)] = []
public init(
database: any SessionMetadataStore,
worktrees: any WorktreeManaging,
transcriptsDir: URL,
containerManager: ContainerManager? = nil,
conflictCoordinator: ConflictCoordinator? = nil,
now: @escaping @Sendable () -> Date = { Date() },
backendFactory: @escaping BackendFactory
) {
self.database = database
self.worktrees = worktrees
self.transcriptsDir = transcriptsDir
self.containerManager = containerManager
self.conflictCoordinator = conflictCoordinator
self.now = now
self.backendFactory = backendFactory
}
// MARK: - Projects
public func loadProjects() async {
let loaded = (try? await database.loadProjects()) ?? []
projects = loaded.sorted { $0.name < $1.name }
projectsByID = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
}
/// Reload persisted sessions into the sidebar on launch and rebuild a resumable
/// (idle) controller for each — without this, chats vanish from the sidebar after
/// a relaunch even though they're still in the database.
public func loadSessions() async {
for project in projects {
let stored = (try? await database.loadSessions(projectID: project.id)) ?? []
for var session in stored where controllers[session.id] == nil {
// A session that was mid-run when the app last quit has no live
// process now; make it resumable rather than stuck "running". The
// interrupted turn's outcome is unknown, so drop any stale disposition
// — but a session already idle at awaitingInput keeps its classification.
if !session.status.isTerminal && session.status != .awaitingInput {
session.status = .awaitingInput
session.lastTurnDisposition = nil
}
// Backfill: a session idle at awaitingInput with no disposition (e.g. it
// finished before classification existed) gets a cheap heuristic verdict
// from its last reply now, so the sidebar shows "Done" vs "Awaiting input"
// immediately rather than waiting for a fresh turn. Live turns still use
// the full Intelligence provider via classifyDisposition.
if session.status == .awaitingInput, session.lastTurnDisposition == nil,
let reply = lastReply(ofTranscriptAt: session.transcriptPath) {
session.lastTurnDisposition = HeuristicTurnClassifier.classify(reply)
}
guard let controller = reconstructController(for: session, in: project) else { continue }
controllers[session.id] = controller
namedSessions.insert(session.id) // already named; don't rename on resume
try? await database.saveSession(session) // persist the normalized status
observe(controller, session.id)
upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
}
}
// Clean up any sandbox containers orphaned by a previous run/crash.
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
}
/// The last complete assistant reply in a transcript, or nil if unreadable/empty —
/// the input the turn classifier reasons over.
private func lastReply(ofTranscriptAt path: String) -> String? {
let events = (try? TranscriptReader(url: URL(fileURLWithPath: path)).read().events) ?? []
guard let reply = HeuristicSummary.lastTurn(events).reply,
!reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return nil }
return reply
}
private func reconstructController(for session: Session, in project: Project) -> SessionController? {
let url = URL(fileURLWithPath: session.transcriptPath)
let events = (try? TranscriptReader(url: url).read().events) ?? []
guard let writer = try? TranscriptWriter(
appendingTo: url, lastSeq: events.last?.seq ?? session.lastSeq)
else { return nil } // transcript file gone — skip (reconcile would clean it up)
var worktree: Worktree?
if let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA {
worktree = Worktree(
sessionID: session.id, path: path, branch: branch,
baseSHA: baseSHA, createdAt: session.createdAt)
}
return SessionController(
session: session, backend: backendFactory(session), transcript: writer,
worktreeManager: worktrees, project: project, worktree: worktree,
metadataStore: database, conversational: true, initialHistory: events, now: now)
}
@discardableResult
public func addProject(name: String, rootPath: String, defaultBranch: GitRef) async -> Project? {
// Reject a repo that's already registered (same path, symlinks resolved).
let canonical = GitWorktreeManager.canonical(rootPath)
if let existing = projects.first(where: { GitWorktreeManager.canonical($0.rootPath) == canonical }) {
lastError = "“\(existing.name)” already uses that repository."
return nil
}
let project = Project(name: name, rootPath: rootPath, defaultBranch: defaultBranch, createdAt: now())
do {
try await database.saveProject(project)
await loadProjects()
return project
} catch {
lastError = "Add project failed: \(error)"
return nil
}
}
/// Persist edits to a project's configuration (e.g. sandbox settings) and refresh the
/// in-memory list. Sandbox changes take effect on the next turn started in the project.
public func updateProject(_ project: Project) async {
let wasSandboxed = projectsByID[project.id]?.sandbox?.enabled == true
do {
try await database.saveProject(project)
await loadProjects()
} catch {
lastError = "Update project failed: \(error)"
return
}
// Sandboxing turned off → tear down every container in the project now. The save
// above means new turns are already host-spawned, so we only need to drain and
// remove the existing ones. Done in the background (it waits for in-flight turns to
// finish) so the settings UI isn't blocked; failures still surface via lastError.
if wasSandboxed, project.sandbox?.enabled != true {
Task { await cleanupProjectContainers(project.id, waitForActive: true) }
}
}
/// Tear down all of a project's session containers, collecting any that couldn't be
/// removed into a single user-facing warning. `waitForActive` drains in-flight turns
/// first (used when disabling sandboxing); pass false to remove immediately.
private func cleanupProjectContainers(_ id: ProjectID, waitForActive: Bool) async {
guard let containerManager else { return }
let sessions = (try? await database.loadSessions(projectID: id)) ?? []
var failed: [String] = []
for session in sessions {
if let name = await containerManager.teardown(session.id, waitForActive: waitForActive) {
failed.append(name)
}
}
reportSandboxCleanupFailures(failed)
}
/// Surface a clear, actionable warning when one or more sandbox containers survived a
/// cleanup attempt, so the user can remove them by hand right away.
private func reportSandboxCleanupFailures(_ names: [String]) {
guard !names.isEmpty else { return }
let list = names.joined(separator: " ")
lastError = "Couldn't remove sandbox container(s): \(list). "
+ "Remove manually with: container delete -f \(list)"
}
public func renameProject(_ id: ProjectID, to name: String) async {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, var project = projectsByID[id] else { return }
project.name = trimmed
do {
try await database.saveProject(project)
await loadProjects()
} catch {
lastError = "Rename failed: \(error)"
}
}
/// Delete a project and everything under it: stop any live sessions, remove
/// their worktrees + branches, and drop the records. The user's actual
/// repository is never touched — only the `.nucleic-worktrees` checkouts.
public func deleteProject(_ id: ProjectID) async {
guard let project = projectsByID[id] else { return }
let sessions = (try? await database.loadSessions(projectID: id)) ?? []
var failedContainers: [String] = []
for session in sessions {
if let controller = controllers[session.id] {
await controller.shutdown()
observers[session.id]?.cancel()
observers[session.id] = nil
controllers[session.id] = nil
}
if let name = await containerManager?.teardown(session.id, waitForActive: false) {
failedContainers.append(name)
}
if let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA {
let worktree = Worktree(
sessionID: session.id, path: path, branch: branch,
baseSHA: baseSHA, createdAt: session.createdAt)
try? await worktrees.discard(worktree, in: project, force: true)
}
try? await database.deleteSession(id: session.id)
summaries.removeAll { $0.id == session.id }
if openSessionID == session.id { openSessionID = nil }
}
reportSandboxCleanupFailures(failedContainers)
// Keep the project's captured ideas — just unassign them so they survive as
// unfiled to-dos rather than pointing at a project that no longer exists.
for var todo in todos where todo.projectID == id {
todo.projectID = nil
todo.updatedAt = now()
await persistTodo(todo)
}
do {
try await database.deleteProject(id: id)
await loadProjects()
} catch {
lastError = "Delete project failed: \(error)"
}
}
public func project(_ id: ProjectID) -> Project? { projectsByID[id] }
/// The project to act on by default: the open chat's project, else the first.
public var currentProject: Project? {
if let projectID = openSession?.projectID, let project = projectsByID[projectID] {
return project
}
return projects.first
}
/// Cmd-N: start a new chat in the current project.
@discardableResult
public func newSessionInCurrentProject() async -> SessionID? {
guard let project = currentProject else { return nil }
return await newSession(in: project)
}
/// Start a new chat in `project` seeded with `message` (the first turn begins
/// immediately) and open it. Used by the home dashboard's chat bar. An empty
/// message just opens a fresh idle chat.
@discardableResult
public func startChat(
in project: Project, message: String, model: String? = nil, effort: String? = nil,
base: GitRef? = nil, useWorktree: Bool = true, auto: Bool? = nil
) async -> SessionID? {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
do {
let id = try await createSession(
in: project, title: RandomName.generate(), prompt: text, base: base,
model: model, effort: effort, useWorktree: useWorktree, auto: auto)
openSessionID = id
return id
} catch {
lastError = "New chat failed: \(error)"
return nil
}
}
/// Local branches in a project's repo, for the new-chat base-branch selector.
/// Falls back to the project's default branch on any error.
public func branches(in project: Project) async -> [String] {
let result = try? await GitRunner().run(
["for-each-ref", "--format=%(refname:short)", "refs/heads"], in: project.rootPath)
let names = (result?.stdout ?? "")
.split(whereSeparator: \.isNewline).map(String.init)
.filter { !$0.isEmpty }
return names.isEmpty ? [project.defaultBranch.value] : names
}
/// Return to the home dashboard.
public func goHome() { openSessionID = nil; openProjectID = nil }
/// Show the project overview page in the detail pane (closing any open chat).
public func openProject(_ id: ProjectID) {
openSessionID = nil
openProjectID = id
}
/// Full session records for a project, most-recent first, for the overview
/// page. Carries the cached `summary` blurb that `SessionSummary` omits, so a
/// returning user can see what each chat was about at a glance.
public func sessions(for projectID: ProjectID) async -> [Session] {
let sessions = (try? await database.loadSessions(projectID: projectID)) ?? []
return sessions.sorted { $0.updatedAt > $1.updatedAt }
}
// MARK: - To-do inbox
/// The project the user is currently working in — the open chat's project, or
/// the open project page — else nil on the home dashboard.
public var contextProjectID: ProjectID? {
if let sessionID = openSessionID {
return openSession?.projectID
?? summaries.first(where: { $0.id == sessionID })?.projectID
}
return openProjectID
}
/// Open the quick-add sheet seeded with the project the user is currently in
/// (e.g. Cmd-T inside a session pre-tags that session's project); nil on home.
public func presentQuickTodo() {
presentQuickTodo(projectID: contextProjectID)
}
/// Open the quick-add sheet pre-tagging a specific project (nil = unfiled).
public func presentQuickTodo(projectID: ProjectID?) {
quickTodoProjectID = projectID
quickTodoPresented = true
}
public func todo(_ id: TodoID) -> Todo? { todos.first { $0.id == id } }
public func loadTodos() async {
todos = (try? await database.loadTodos()) ?? []
todos.sort { $0.createdAt < $1.createdAt }
regenerateTodoSummaries()
ensureTodoItemSummaries()
}
/// Open ideas grouped by project (each project in display order, then the
/// unassigned bucket last), with a glanceable summary per group.
public var todoGroups: [TodoGroup] {
let open = openTodos
guard !open.isEmpty else { return [] }
var byKey: [String: [Todo]] = [:]
for todo in open {
// Fold ideas pointing at a since-deleted project into "unassigned".
let key = todo.projectID.flatMap { projectsByID[$0] } != nil
? todo.projectID!.rawValue : ""
byKey[key, default: []].append(todo)
}
var groups: [TodoGroup] = []
for project in projects { // already sorted by name
guard let items = byKey[project.id.rawValue] else { continue }
groups.append(TodoGroup(
id: project.id.rawValue, projectID: project.id, projectName: project.name,
todos: items, summary: todoSummaries[project.id.rawValue] ?? ""))
}
if let unassigned = byKey[""] {
groups.append(TodoGroup(
id: "", projectID: nil, projectName: "Unassigned",
todos: unassigned, summary: todoSummaries[""] ?? ""))
}
return groups
}
/// Refresh each group's summary, skipping groups whose ideas are unchanged, and
/// dropping summaries for groups that no longer exist. Each summary is produced
/// off the main run loop and lands into `todoSummaries` as it completes.
private func regenerateTodoSummaries() {
let groups = todoGroups
let live = Set(groups.map(\.id))
todoSummaries = todoSummaries.filter { live.contains($0.key) }
todoSummarySignatures = todoSummarySignatures.filter { live.contains($0.key) }
let intelligence = self.intelligence
for group in groups {
let texts = group.todos.map(\.text)
let signature = texts.joined(separator: "\u{1}").hashValue
// Already summarized this exact set of ideas — leave it.
if todoSummarySignatures[group.id] == signature, todoSummaries[group.id] != nil { continue }
todoSummarySignatures[group.id] = signature
let id = group.id
Task {
let summary = await intelligence.summarizeTodos(texts)
// Only apply if this group still wants this exact signature.
guard self.todoSummarySignatures[id] == signature else { return }
self.todoSummaries[id] = summary
}
}
}
/// Capture a new idea. `projectID` is optional — an unassigned idea picks its
/// project at dispatch time. Returns nil (and surfaces nothing) for blank text.
@discardableResult
public func addTodo(_ text: String, projectID: ProjectID? = nil) async -> Todo? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let todo = Todo(text: trimmed, projectID: projectID, createdAt: now(), updatedAt: now())
do {
try await database.saveTodo(todo)
upsertTodo(todo)
summarizeTodoItem(todo.id)
return todo
} catch {
lastError = "Add to-do failed: \(error)"
return nil
}
}
/// Edit an idea's text and/or assigned project (e.g. from the home list).
public func updateTodo(_ id: TodoID, text: String? = nil, projectID: ProjectID?? = nil) async {
guard var todo = todos.first(where: { $0.id == id }) else { return }
var textChanged = false
if let text {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if trimmed != todo.text { todo.text = trimmed; todo.summary = nil; textChanged = true }
}
if let projectID { todo.projectID = projectID }
todo.updatedAt = now()
await persistTodo(todo)
if textChanged { summarizeTodoItem(id) } // re-summarize the new text
}
/// Reassign an idea to a different project, or `nil` to unfile it — for fixing a
/// wrong/forgotten project (via the row's move menu or a drag between groups).
/// Text and summary are unaffected.
public func moveTodo(_ id: TodoID, toProject projectID: ProjectID?) async {
guard var todo = todos.first(where: { $0.id == id }), todo.projectID != projectID else { return }
todo.projectID = projectID
todo.updatedAt = now()
await persistTodo(todo)
}
public func setTodoStatus(_ id: TodoID, _ status: TodoStatus) async {
guard var todo = todos.first(where: { $0.id == id }) else { return }
todo.status = status
todo.updatedAt = now()
await persistTodo(todo)
}
public func deleteTodo(_ id: TodoID) async {
try? await database.deleteTodo(id: id)
todos.removeAll { $0.id == id }
regenerateTodoSummaries()
}
/// Open ideas (not yet dispatched or done). FIFO (oldest first) by default, or in
/// the AI triage ranking once `triageSortEnabled` (most-impactful first; ideas with
/// no verdict — e.g. captured after the last pass — fall back to FIFO at the end).
public var openTodos: [Todo] {
let open = todos.filter { $0.status == .open }
guard triageSortEnabled else { return open.sorted { $0.createdAt < $1.createdAt } }
return open.sorted { a, b in
let ra = triageItems[a.id]?.rank ?? Int.max
let rb = triageItems[b.id]?.rank ?? Int.max
if ra != rb { return ra < rb }
return a.createdAt < b.createdAt
}
}
/// The triage verdict for an idea, if the last pass covered it (nil otherwise).
public func triageItem(for id: TodoID) -> TriageItem? { triageItems[id] }
/// Convenience: just the level (the row badge's color), if any.
public func triageLevel(for id: TodoID) -> TriageLevel? { triageItems[id]?.level }
/// Run an AI triage pass over the open ideas and switch the inbox from FIFO to the
/// resulting impact ranking. Levels and order come from the intelligence provider —
/// the on-device model when available, else a deterministic heuristic.
public func triageOpenTodos() async {
let open = todos.filter { $0.status == .open }.sorted { $0.createdAt < $1.createdAt }
guard !open.isEmpty else { return }
triaging = true
let inputs = open.map { TriageInput(id: $0.id, text: $0.text) }
let items = await intelligence.triageTodos(inputs)
triageItems = Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
triageSortEnabled = true
triaging = false
}
/// Drop the triage ordering and go back to FIFO. Keeps the computed verdicts so
/// toggling triage back on is instant until the ideas change and a new pass runs.
public func clearTriageSort() {
triageSortEnabled = false
}
/// Dispatch an idea: start a chat in `project` seeded with the idea's text, mark
/// the idea dispatched (linking the spawned session), and open it. The user
/// chooses when to do this — e.g. once other agents are idle.
@discardableResult
public func dispatchTodo(_ id: TodoID, in project: Project) async -> SessionID? {
guard let todo = todos.first(where: { $0.id == id }) else { return nil }
guard let sessionID = await startChat(in: project, message: todo.text) else { return nil }
var updated = todo
updated.status = .dispatched
updated.projectID = project.id
updated.dispatchedSessionID = sessionID
updated.updatedAt = now()
await persistTodo(updated)
return sessionID
}
private func persistTodo(_ todo: Todo) async {
do {
try await database.saveTodo(todo)
upsertTodo(todo)
} catch {
lastError = "Save to-do failed: \(error)"
}
}
private func upsertTodo(_ todo: Todo) {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index] = todo
} else {
todos.append(todo)
}
// Oldest first; a stable sort keeps insertion order for equal timestamps.
todos.sort { $0.createdAt < $1.createdAt }
regenerateTodoSummaries()
}
/// Generate per-item summaries for any open ideas that lack one (after a load).
private func ensureTodoItemSummaries() {
for todo in todos where todo.summary == nil && todo.status == .open {
summarizeTodoItem(todo.id)
}
}
/// Summarize one idea's text off the main run loop and store the result. Stores
/// `""` when the text needs no distinct summary, so it isn't retried every load.
private func summarizeTodoItem(_ id: TodoID) {
guard let todo = todos.first(where: { $0.id == id }) else { return }
let text = todo.text
let intelligence = self.intelligence
Task {
let raw = await intelligence.summarizeTodo(text)
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = (trimmed.isEmpty || trimmed.caseInsensitiveCompare(text) == .orderedSame)
? "" : trimmed
// Bail if the idea was edited (newer summarize will run) or already current.
// Stamp only the summary onto the CURRENT row synchronously (no stale write
// back after the save's await), so a concurrent move/edit/delete of this todo
// during persistence isn't clobbered.
guard let index = self.todos.firstIndex(where: { $0.id == id }),
self.todos[index].text == text, self.todos[index].summary != summary
else { return }
self.todos[index].summary = summary
try? await self.database.saveTodo(self.todos[index])
}
}
// MARK: - Conflict coordination (internal project management)
/// Register self as the conflict arbiter on the shared coordinator. Called once at
/// app startup (the coordinator holds it weakly).
public func activateConflictArbitration() async {
await conflictCoordinator?.setArbiter(self)
}
/// `ConflictArbiter`: an agent (in `sessionID`) is about to start `task` touching
/// `files`. Compare against every other active session's footprint; if nothing
/// overlaps, let it proceed silently. Otherwise surface a prompt and suspend until
/// the user answers Defer / Cancel / Override. Fail-open on any gap.
public func arbitrate(sessionID: SessionID, task: String, files: [String]) async -> ConflictResolution {
let trimmedTask = task.trimmingCharacters(in: .whitespacesAndNewlines)
let callerSession = await controllers[sessionID]?.snapshot.session
let candidateFiles = files.map { stripWorktreePrefix($0, callerSession?.worktreePath) }
let candidate = ConflictCandidate(task: trimmedTask, files: candidateFiles)
let active = await gatherActiveWork(excluding: sessionID)
let matches = ConflictDetector.detect(candidate, against: active)
guard !matches.isEmpty else { return .noConflict }
// Edit interception passes no task text; fall back to the session's title (its
// objective) so the prompt and any filed to-do are meaningful.
let promptTask = trimmedTask.isEmpty
? (callerSession?.title ?? "this agent's work") : trimmedTask
let prompt = ConflictPrompt(
id: UUID().uuidString, sessionID: sessionID, projectID: callerSession?.projectID,
sessionTitle: callerSession?.title ?? "an agent",
task: promptTask, matches: matches)
return await withCheckedContinuation { continuation in
conflictQueue.append((prompt, continuation))
if pendingConflict == nil { pendingConflict = prompt }
}
}
/// Resolve the conflict currently shown to the user, resuming the blocked agent and
/// advancing to the next queued prompt (if any).
public func resolveConflict(_ choice: ConflictChoice) async {
guard !conflictQueue.isEmpty else { return }
let (prompt, continuation) = conflictQueue.removeFirst()
let resolution: ConflictResolution
switch choice {
case .defer:
await addTodo(prompt.task, projectID: prompt.projectID)
resolution = .deferred
case .cancel:
resolution = .cancelled
case .proceed:
resolution = .proceed
}
pendingConflict = conflictQueue.first?.prompt
continuation.resume(returning: resolution)
}
/// Snapshot every other non-terminal session's task + changed-file footprint, the
/// input the detector reasons over. A transient git failure yields an empty footprint
/// (intent matching can still fire).
private func gatherActiveWork(excluding caller: SessionID) async -> [ActiveWork] {
var result: [ActiveWork] = []
for (sid, controller) in controllers where sid != caller {
let session = await controller.snapshot.session
guard !session.status.isTerminal else { continue }
let files = (try? await controller.diff().files.map(\.path)) ?? []
let task = [session.title, session.summary]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: " — ")
result.append(ActiveWork(
sessionID: sid, title: session.title, task: task, changedFiles: files))
}
return result
}
/// Reduce an agent-supplied path to repo-relative by dropping its worktree prefix if
/// present (agents report absolute paths inside their own worktree). Tries both the raw
/// stored path and its canonical form, since a sandboxed agent's cwd is the canonicalized
/// (symlink-resolved) container path, not the raw host path.
private func stripWorktreePrefix(_ path: String, _ worktree: String?) -> String {
guard let worktree, !worktree.isEmpty else { return path }
for prefix in [worktree, GitWorktreeManager.canonical(worktree)] where path.hasPrefix(prefix) {
return String(path.dropFirst(prefix.count))
}
return path
}
// MARK: - Dashboard
/// Aggregate stats + per-day activity for the home dashboard. Reads each
/// session's transcript to tally user turns by day (small JSONL files).
public func loadDashboard() async {
let projects = (try? await database.loadProjects()) ?? []
var sessions: [Session] = []
for project in projects {
sessions += (try? await database.loadSessions(projectID: project.id)) ?? []
}
let calendar = Calendar.current
var activity: [Date: Int] = [:]
var messages = 0
for session in sessions {
guard let (_, events) = try? TranscriptReader(
url: URL(fileURLWithPath: session.transcriptPath)).read()
else { continue }
for event in events {
if case .userText = event.kind {
messages += 1
activity[calendar.startOfDay(for: event.at), default: 0] += 1
}
}
}
let visible = sessions.filter { !$0.archived }
let active = visible.filter { !$0.status.isTerminal }.count
dashboard = DashboardStats(
projects: projects.count,
chats: visible.count,
activeChats: active,
messages: messages,
activeDays: activity.count)
activityByDay = activity
}
/// Non-archived chats for a project, favorites first, then most-recent.
public func summaries(for projectID: ProjectID) -> [SessionSummary] {
summaries
.filter { $0.projectID == projectID && !$0.archived }
.sorted { ($0.favorite ? 0 : 1, $1.updatedAt) < ($1.favorite ? 0 : 1, $0.updatedAt) }
}
public func archivedSummaries(for projectID: ProjectID) -> [SessionSummary] {
summaries
.filter { $0.projectID == projectID && $0.archived }
.sorted { $0.updatedAt > $1.updatedAt }
}
// MARK: - Session lifecycle
/// Create a randomly-named chat under a project and open it. The session's
/// worktree is created immediately; the agent doesn't run until the user sends
/// the first message in the chat composer.
@discardableResult
public func newSession(in project: Project) async -> SessionID? {
do {
let id = try await createSession(in: project, title: RandomName.generate(), prompt: "")
openSessionID = id
return id
} catch {
lastError = "New session failed: \(error)"
return nil
}
}
/// Create a worktree, open a transcript, spawn a (conversational) controller, and
/// persist. If `prompt` is non-empty the first turn starts immediately; otherwise
/// the session waits for the user's first message (PLAN M1).
@discardableResult
public func createSession(
in project: Project, title: String, prompt: String = "", base: GitRef? = nil,
model: String? = nil, effort: String? = nil, useWorktree: Bool = true, auto: Bool? = nil
) async throws -> SessionID {
let sessionID = SessionID.generate()
let backendID = project.defaultBackend ?? .claudeCode
let resolvedBase = base ?? project.defaultBranch
// With a worktree (the default), the chat runs on its own isolated branch.
// Without one, it runs directly in the project's main checkout on the chosen
// branch — no isolation, so merges/discards have nothing to remove.
let worktree: Worktree? = useWorktree
? try await worktrees.create(for: sessionID, in: project, base: resolvedBase, slug: title)
: nil
let worktreePath = worktree?.path ?? project.rootPath
let transcriptURL = transcriptsDir
.appendingPathComponent(sessionID.rawValue)
.appendingPathComponent("transcript.jsonl")
let header = SessionHeader(
sessionID: sessionID, backend: backendID, worktree: worktreePath, createdAt: now())
let writer = try TranscriptWriter(url: transcriptURL, header: header)
let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
let session = Session(
id: sessionID, projectID: project.id, backend: backendID,
title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running,
worktreePath: worktreePath, branch: worktree?.branch ?? resolvedBase.value,
baseSHA: worktree?.baseSHA,
model: model ?? defaultModel, effort: effort ?? defaultEffort,
transcriptPath: transcriptURL.path, auto: auto ?? defaultAuto,
createdAt: now(), updatedAt: now())
let controller = SessionController(
session: session, backend: backendFactory(session), transcript: writer,
worktreeManager: worktrees, project: project, worktree: worktree,
metadataStore: database, conversational: true, now: now)
controllers[sessionID] = controller
try? await database.saveSession(session)
upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
observe(controller, sessionID)
if !trimmedPrompt.isEmpty {
await controller.start(prompt: AgentInput(text: trimmedPrompt))
maybeNameSession(sessionID, firstMessage: trimmedPrompt)
}
return sessionID
}
public func respondToOpenApproval(_ id: ApprovalID, _ decision: Decision) async {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
do { try await controller.respondToApproval(id, decision) }
catch { lastError = "Approval failed: \(error)" }
}
public func sendToOpenSession(_ text: String) async {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
do {
try await controller.sendInput(AgentInput(text: text))
maybeNameSession(sessionID, firstMessage: text) // first send names the session
} catch {
lastError = "Send failed: \(error)"
}
}
/// Recompute the open session's worktree status/diffstat on demand (a UI
/// "refresh" affordance, and a deterministic settle point once a run is idle).
public func refreshOpenStatus() async {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
guard (try? await controller.refreshStatus()) != nil else { return }
let snapshot = await controller.snapshot
upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
}
public func interruptOpenSession() async {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
await controller.interrupt()
}
public func renameOpenSession(to title: String) async {
await mutateOpenSession { await $0.rename(title) }
}
/// Set the model (nil = default) for the open session's next turn.
public func setOpenSessionModel(_ model: String?) async {
await mutateOpenSession { await $0.setModel(model) }
}
/// Set the reasoning effort (nil = default) for the open session's next turn.
public func setOpenSessionEffort(_ effort: String?) async {
await mutateOpenSession { await $0.setEffort(effort) }
}
/// Toggle Claude's auto-approval mode for the open session's next turn.
public func setOpenSessionAuto(_ auto: Bool) async {
await mutateOpenSession { await $0.setAuto(auto) }
}
public func setSessionFavorite(_ id: SessionID, _ favorite: Bool) async {
await mutateSession(id) { await $0.setFavorite(favorite) }
}
public func setSessionArchived(_ id: SessionID, _ archived: Bool) async {
await mutateSession(id) { await $0.setArchived(archived) }
if archived {
if openSessionID == id { openSessionID = nil }
// Archiving puts the chat away — free its sandbox container immediately.
if let name = await containerManager?.teardown(id, waitForActive: false) {
reportSandboxCleanupFailures([name])
}
}
}
/// Delete a chat (any row, not just the open one): stop it, remove its worktree +
/// branch + records.
public func deleteSession(_ id: SessionID) async {
if let controller = controllers[id] {
do { try await controller.discard(force: true) }
catch { lastError = "Delete failed: \(error)" }
}
if let name = await containerManager?.teardown(id, waitForActive: false) {
reportSandboxCleanupFailures([name])
}
observers[id]?.cancel()
observers[id] = nil
controllers[id] = nil
try? await database.deleteSession(id: id)
summaries.removeAll { $0.id == id }
if openSessionID == id { openSessionID = nil }
}
private func mutateOpenSession(_ body: (SessionController) async -> Void) async {
guard let sessionID = openSessionID else { return }
await mutateSession(sessionID, body)
}
private func mutateSession(_ id: SessionID, _ body: (SessionController) async -> Void) async {
guard let controller = controllers[id] else { return }
await body(controller)
let snapshot = await controller.snapshot
if id == openSessionID { openSession = snapshot.session }
upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
}
@discardableResult
public func integrateOpenSession(strategy: IntegrationStrategy) async -> IntegrationResult? {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
do { return try await controller.integrate(strategy: strategy) }
catch { lastError = "Integrate failed: \(error)"; return nil }
}
/// Full Markdown export of the open session (metadata, backend/debug fields,
/// conversation, and raw event JSONL) plus a suggested filename.
public func exportOpenSession() async -> (filename: String, contents: String)? {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
let snapshot = await controller.snapshot
let events = await controller.transcriptSoFar()
let project = projectsByID[snapshot.session.projectID]
return (
ConversationExport.suggestedFileName(for: snapshot.session),
ConversationExport.markdown(session: snapshot.session, project: project, events: events))
}
public func discardOpenSession() async {
guard let sessionID = openSessionID else { return }
await deleteSession(sessionID)
}
// MARK: - Observation bridge (controller → @Observable state)
private func observe(_ controller: SessionController, _ sessionID: SessionID) {
observers[sessionID]?.cancel()
observers[sessionID] = Task { [weak self] in
let stream = await controller.subscribe()
for await event in stream {
guard let self else { return }
await self.ingestUI(sessionID, event)
}
}
}
private func ingestUI(_ sessionID: SessionID, _ event: AgentEvent) async {
guard let controller = controllers[sessionID] else { return }
// Recompute the live diff at turn boundaries so the summary's diffstat is fresh.
switch event.kind {
case .turnCompleted, .runFinished:
// Best-effort live diffstat; a transient git failure here is fine — the
// next turn or an explicit refreshOpenStatus() recomputes it.
_ = try? await controller.refreshStatus()
default:
break
}
// A finished turn left the session idle at .awaitingInput; classify (for every
// session, not just the open one) whether the agent is actually waiting on the
// user or has finished, so the sidebar's "Awaiting input"/"Done" is accurate.
if case .runFinished = event.kind { classifyDisposition(sessionID, controller) }
let snapshot = await controller.snapshot
upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
if sessionID == openSessionID {
openSession = snapshot.session
// Pull the full authoritative history rather than appending the single
// event: combined with the monotonic guard below, this is robust against
// interleaving with reloadOpen (both only ever grow the list).
applyOpenTranscript(await controller.transcriptSoFar(), for: sessionID)
openApprovals = snapshot.pendingApprovals
if case .runFinished = event.kind { regenerateSummary() } // turn ended
}
// Record how far the observer has drained this session's event stream — any
// work this event spawned (e.g. classification on runFinished) has been issued
// by now, so awaitOpenSessionSettled can tell when it's safe to drain tasks.
lastIngestedSeq[sessionID] = max(lastIngestedSeq[sessionID] ?? 0, event.seq)
}
private func reloadOpen() async {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
let snapshot = await controller.snapshot
let events = await controller.transcriptSoFar()
applyOpenTranscript(events, for: sessionID)
if sessionID == openSessionID {
openSession = snapshot.session
openApprovals = snapshot.pendingApprovals
// Show the cached summary immediately; only regenerate if we've never
// produced one for this chat (otherwise it refreshes on each turn end).
if let cached = snapshot.session.summary, !cached.isEmpty {
openSummary = cached
} else {
regenerateSummary()
}
}
}
// MARK: - Intelligence: summary + auto-naming
/// Regenerate the open session's summary card (called on open, on turn end, or
/// from the card's refresh button).
public func regenerateOpenSummary() { regenerateSummary() }
/// Await the open session's latest run to be fully observed and all the async work
/// it feeds — transcript ingestion, turn classification, and summary generation —
/// to quiesce. A test hook so assertions on the open transcript, summary state, or
/// `intelligence` call counts don't race the async pipelines behind them.
///
/// This is fully deterministic, not a sleep: it first joins the open session's run
/// so every event has been emitted, then waits for the event observer to actually
/// ingest up through that run's terminal seq (the observer lags the controller —
/// its `runFinished` ingestion is what fills `openTranscript` and spawns
/// classification). Once the observer has caught up, every follow-on task has been
/// *issued*; the final loop drains them, re-checking `intelligenceWorkSeq` because
/// they chain (a reload or a classification each spawns a summary) until none remain.
func awaitOpenSessionSettled() async {
if let sessionID = openSessionID, let controller = controllers[sessionID] {
await controller.join() // run done at the source: all events fanned out
let finalSeq = await controller.snapshot.session.lastSeq
// The observer drains a buffered stream; yield until it has ingested the
// terminal event (finite work, so this completes — it is not a timed wait).
while (lastIngestedSeq[sessionID] ?? 0) < finalSeq { await Task.yield() }
}
var seq = intelligenceWorkSeq &- 1
while seq != intelligenceWorkSeq {
seq = intelligenceWorkSeq
await reloadTask?.value
await classifyTask?.value
await summaryTask?.value
}
}
private func regenerateSummary() {
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
summaryToken += 1
let token = summaryToken
summarizing = true
intelligenceWorkSeq += 1
let intelligence = self.intelligence
summaryTask = Task {
let snapshot = await controller.snapshot
let events = await controller.transcriptSoFar()
let text = await intelligence.summarize(session: snapshot.session, events: events)
await controller.setSummary(text) // cache so reopen doesn't regenerate
guard token == self.summaryToken, sessionID == self.openSessionID else { return }
self.openSummary = text
self.summarizing = false
self.openSession = await controller.snapshot.session // reflect the cached summary
}
}
/// Classify the just-finished turn (asking vs. done) from the agent's last reply
/// and stamp it on the session, so the sidebar can distinguish "the agent needs
/// you" from "the agent finished" and the open session's summary card can switch to
/// a done recap. Runs off the main loop; the controller ignores a stale
/// classification if a new turn has since started.
private func classifyDisposition(_ sessionID: SessionID, _ controller: SessionController) {
let intelligence = self.intelligence
intelligenceWorkSeq += 1
classifyTask = Task {
let events = await controller.transcriptSoFar()
// No assistant prose this turn → leave it as plain "awaiting input".
guard let reply = HeuristicSummary.lastTurn(events).reply,
!reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return }
let disposition = await intelligence.classifyTurn(lastReply: reply)
await controller.setDisposition(disposition)
let snapshot = await controller.snapshot
self.upsertSummary(SessionSummary(
snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
if sessionID == self.openSessionID { self.openSession = snapshot.session }
if disposition == .completed { await self.releaseSandboxIfCompleted(sessionID) }
}
}
/// A session classified `.completed` has finished its work, so free its sandbox
/// container now rather than holding it until the idle timer — a later follow-up
/// message transparently starts a fresh one. Re-checks the live state so a follow-up
/// turn that began in the meantime isn't torn down, and drains the just-finished turn
/// (waitForActive) before removing.
private func releaseSandboxIfCompleted(_ sessionID: SessionID) async {
guard let containerManager, let controller = controllers[sessionID] else { return }
let session = await controller.snapshot.session
guard session.lastTurnDisposition == .completed, session.status == .awaitingInput,
projectsByID[session.projectID]?.sandbox?.enabled == true
else { return }
if let name = await containerManager.teardown(sessionID, waitForActive: true) {
reportSandboxCleanupFailures([name])
}
}
/// On a session's first user message, derive a concise title and rename. Runs at
/// most once per session. Uses the intelligence provider when it returns a name,
/// else a deterministic heuristic from the message — so a chat always gets named,
/// even with Intelligence off or the model unavailable.
private func maybeNameSession(_ id: SessionID, firstMessage: String) {
guard !namedSessions.contains(id) else { return }
namedSessions.insert(id)
let intelligence = self.intelligence
Task {
guard let controller = self.controllers[id] else { return }
// Only replace an auto-generated random name; never clobber a title the
// caller set explicitly.
guard RandomName.isGenerated(await controller.snapshot.session.title) else { return }
let generated = await intelligence.sessionName(fromFirstMessage: firstMessage)
let name = generated.flatMap { $0.isEmpty ? nil : $0 }
?? HeuristicTitle.fromMessage(firstMessage)
await controller.rename(name)
let snapshot = await controller.snapshot
self.upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
if id == self.openSessionID { self.openSession = snapshot.session }
}
}
/// Single guarded writer for `openTranscript`: only applies if it's still the
/// open session and the snapshot is at least as complete as what's shown, so a
/// late-arriving stale read can never shorten the transcript.
private func applyOpenTranscript(_ events: [AgentEvent], for sessionID: SessionID) {
guard sessionID == openSessionID, events.count >= openTranscript.count else { return }
openTranscript = events
}
private func upsertSummary(_ summary: SessionSummary) {
if let index = summaries.firstIndex(where: { $0.id == summary.id }) {
summaries[index] = summary
} else {
summaries.append(summary)
}
}
}