diff --git a/Packages/CrowCLI/Sources/CrowCLILib/Commands/SessionCommands.swift b/Packages/CrowCLI/Sources/CrowCLILib/Commands/SessionCommands.swift index 322c465..25ba8f9 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/Commands/SessionCommands.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/Commands/SessionCommands.swift @@ -10,11 +10,17 @@ import Foundation public struct NewSession: ParsableCommand { public static let configuration = CommandConfiguration(commandName: "new-session", abstract: "Create a new session") @Option(name: .long, help: "Session name") var name: String + @Option(name: .long, help: "Agent kind (e.g. claude-code). Defaults to the configured default agent.") + var agent: String? public init() {} public func run() throws { - let result = try rpc("new-session", params: ["name": .string(name)]) + var params: [String: JSONValue] = ["name": .string(name)] + if let agent, !agent.isEmpty { + params["agent_kind"] = .string(agent) + } + let result = try rpc("new-session", params: params) printJSON(result) } } diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift new file mode 100644 index 0000000..6e5b72f --- /dev/null +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift @@ -0,0 +1,51 @@ +import Foundation +import CrowCore + +/// `CodingAgent` conformer for Claude Code. Wraps the existing `ClaudeLauncher` +/// prompt/launch-command logic and bundles the Claude-specific hook writer +/// and state-machine signal source so the main app can treat everything +/// through the generic `CodingAgent` interface. +public struct ClaudeCodeAgent: CodingAgent { + public let kind: AgentKind = .claudeCode + public let displayName: String = "Claude Code" + public let iconSystemName: String = "sparkles" + public let hookConfigWriter: any HookConfigWriter + public let stateSignalSource: any StateSignalSource + + private let launcher: ClaudeLauncher + + public init( + hookConfigWriter: any HookConfigWriter = ClaudeHookConfigWriter(), + stateSignalSource: any StateSignalSource = ClaudeHookSignalSource() + ) { + self.hookConfigWriter = hookConfigWriter + self.stateSignalSource = stateSignalSource + self.launcher = ClaudeLauncher() + } + + public func generatePrompt( + session: Session, + worktrees: [SessionWorktree], + ticketURL: String?, + provider: Provider? + ) async -> String { + await launcher.generatePrompt( + session: session, + worktrees: worktrees, + ticketURL: ticketURL, + provider: provider + ) + } + + public func launchCommand( + sessionID: UUID, + worktreePath: String, + prompt: String + ) async throws -> String { + try await launcher.launchCommand( + sessionID: sessionID, + worktreePath: worktreePath, + prompt: prompt + ) + } +} diff --git a/Sources/Crow/App/HookConfigGenerator.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift similarity index 85% rename from Sources/Crow/App/HookConfigGenerator.swift rename to Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift index 245bd29..b171f93 100644 --- a/Sources/Crow/App/HookConfigGenerator.swift +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookConfigWriter.swift @@ -1,10 +1,14 @@ import Foundation +import CrowCore -/// Generates and manages Claude Code hook configuration for session worktrees. -struct HookConfigGenerator { +/// Writes Claude Code's hook configuration into a worktree's +/// `.claude/settings.local.json`. Conforms to `HookConfigWriter` so the main +/// app can treat the configuration step generically; the concrete event list +/// and file format stay local to CrowClaude. +public struct ClaudeHookConfigWriter: HookConfigWriter { /// All hook event names we register. - private static let allEvents = [ + static let allEvents = [ "SessionStart", "SessionEnd", "Stop", "StopFailure", "Notification", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "PermissionDenied", "UserPromptSubmit", @@ -19,8 +23,7 @@ struct HookConfigGenerator { "PostToolUse", "PostToolUseFailure", ] - /// Marker key to identify our hooks vs user hooks. - private static let markerComment = "crow-managed" + public init() {} // MARK: - Generate Hook Configuration @@ -50,11 +53,11 @@ struct HookConfigGenerator { return hooks } - // MARK: - Write / Merge Configuration + // MARK: - HookConfigWriter Conformance /// Write hook configuration to a worktree's .claude/settings.local.json. /// Uses a merge strategy: preserves user settings, only updates our hook entries. - static func writeHookConfig( + public func writeHookConfig( worktreePath: String, sessionID: UUID, crowPath: String @@ -75,7 +78,7 @@ struct HookConfigGenerator { var existingHooks = settings["hooks"] as? [String: Any] ?? [:] // Generate our hooks - let ourHooks = generateHooks(sessionID: sessionID, crowPath: crowPath) + let ourHooks = Self.generateHooks(sessionID: sessionID, crowPath: crowPath) // Merge: our hooks overwrite matching event names, user hooks for other events are preserved for (eventName, hookConfig) in ourHooks { @@ -90,7 +93,7 @@ struct HookConfigGenerator { } /// Remove our hook entries from a worktree's settings.local.json, preserving user settings. - static func removeHookConfig(worktreePath: String) { + public func removeHookConfig(worktreePath: String) { let settingsPath = (worktreePath as NSString) .appendingPathComponent(".claude/settings.local.json") @@ -101,7 +104,7 @@ struct HookConfigGenerator { } // Remove our managed event entries - for event in allEvents { + for event in Self.allEvents { hooks.removeValue(forKey: event) } @@ -116,7 +119,7 @@ struct HookConfigGenerator { do { try FileManager.default.removeItem(atPath: settingsPath) } catch { - NSLog("[HookConfigGenerator] Failed to remove empty settings file at %@: %@", + NSLog("[ClaudeHookConfigWriter] Failed to remove empty settings file at %@: %@", settingsPath, error.localizedDescription) } } else { @@ -125,7 +128,7 @@ struct HookConfigGenerator { withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) try updatedData.write(to: URL(fileURLWithPath: settingsPath)) } catch { - NSLog("[HookConfigGenerator] Failed to write updated settings to %@: %@", + NSLog("[ClaudeHookConfigWriter] Failed to write updated settings to %@: %@", settingsPath, error.localizedDescription) } } @@ -134,7 +137,7 @@ struct HookConfigGenerator { // MARK: - Find crow Binary /// Find the crow binary, checking common install locations. - static func findCrowBinary() -> String? { + public static func findCrowBinary() -> String? { // Check same directory as running executable first (development builds) let execURL = URL(fileURLWithPath: ProcessInfo.processInfo.arguments[0]) let buildDir = execURL.deletingLastPathComponent() diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookSignalSource.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookSignalSource.swift new file mode 100644 index 0000000..63dfc26 --- /dev/null +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookSignalSource.swift @@ -0,0 +1,128 @@ +import Foundation +import CrowCore + +/// Translates Claude Code hook events into `AgentStateTransition` values. +/// This is the state machine that AppDelegate used to embed inline; moving it +/// here keeps the per-agent behavior next to `ClaudeHookConfigWriter` and lets +/// the hook-event handler stay small. +/// +/// Pure: returns a transition, never mutates shared state. Callers apply the +/// transition to `SessionHookState` after receiving it. +public struct ClaudeHookSignalSource: StateSignalSource { + public init() {} + + public func transition( + for event: AgentHookEvent, + currentActivityState: AgentActivityState, + currentNotificationType: String? + ) -> AgentStateTransition { + // Most events clear any pending notification. Notification and + // PermissionRequest are the two cases that may *set* the pending + // notification themselves, so we don't preemptively clear for them. + let blanketClear = event.eventName != "Notification" + && event.eventName != "PermissionRequest" + var transition = AgentStateTransition( + notification: blanketClear ? .clear : .leave + ) + + switch event.eventName { + case "PreToolUse": + let toolName = event.toolName ?? "unknown" + if toolName == "AskUserQuestion" { + transition.notification = .set(HookNotification( + message: "Claude has a question", + notificationType: "question" + )) + transition.newActivityState = .waiting + transition.toolActivity = .clear + } else { + transition.toolActivity = .set(ToolActivity( + toolName: toolName, isActive: true + )) + transition.newActivityState = .working + } + + case "PostToolUse": + let toolName = event.toolName ?? "unknown" + transition.toolActivity = .set(ToolActivity( + toolName: toolName, isActive: false + )) + + case "PostToolUseFailure": + let toolName = event.toolName ?? "unknown" + transition.toolActivity = .set(ToolActivity( + toolName: toolName, isActive: false + )) + + case "Notification": + let message = event.message ?? "" + let notifType = event.notificationType ?? "" + if notifType == "permission_prompt" { + transition.notification = .set(HookNotification( + message: message, notificationType: notifType + )) + transition.newActivityState = .waiting + } else if notifType == "idle_prompt" { + // At the prompt — clear any stale permission notification. + // Don't change activity state (Stop already set it to .done). + transition.notification = .clear + } + + case "PermissionRequest": + // Don't override a "question" notification — AskUserQuestion + // triggers both PreToolUse and PermissionRequest, and the question + // badge is more specific than generic "Permission". + if currentNotificationType != "question" { + transition.notification = .set(HookNotification( + message: "Permission requested", + notificationType: "permission_prompt" + )) + } + transition.newActivityState = .waiting + transition.toolActivity = .clear + + case "UserPromptSubmit": + transition.newActivityState = .working + + case "Stop": + transition.newActivityState = .done + transition.toolActivity = .clear + + case "StopFailure": + transition.newActivityState = .waiting + + case "SessionStart": + let source = event.source ?? "startup" + if source == "resume" { + transition.newActivityState = .done + } else { + transition.newActivityState = .idle + } + + case "SessionEnd": + transition.newActivityState = .idle + transition.toolActivity = .clear + + case "SubagentStart": + transition.newActivityState = .working + + case "TaskCreated", "TaskCompleted", "SubagentStop": + // Stay in working state unless the session is already waiting on + // user input (don't clobber a pending question/permission). + if currentActivityState != .waiting { + transition.newActivityState = .working + } + + case "PermissionDenied": + transition.newActivityState = .working + transition.toolActivity = .clear + + default: + // PreCompact, PostCompact, and any unknown event just get the + // blanket notification clear applied above. + break + } + + return transition + } +} diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookSignalSourceTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookSignalSourceTests.swift new file mode 100644 index 0000000..966999d --- /dev/null +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookSignalSourceTests.swift @@ -0,0 +1,229 @@ +import Foundation +import Testing +@testable import CrowClaude +@testable import CrowCore + +// Exercises the state machine that used to live inline in AppDelegate's +// hook-event handler. Each case mirrors a branch of that switch so the +// behavior stays verifiably identical after extraction. + +@Suite("ClaudeHookSignalSource") +struct ClaudeHookSignalSourceTests { + private let source = ClaudeHookSignalSource() + + private func event( + _ name: String, + toolName: String? = nil, + source: String? = nil, + message: String? = nil, + notificationType: String? = nil, + agentType: String? = nil + ) -> AgentHookEvent { + AgentHookEvent( + sessionID: UUID(), + eventName: name, + toolName: toolName, + source: source, + message: message, + notificationType: notificationType, + agentType: agentType, + summary: name + ) + } + + // MARK: - PreToolUse + + @Test func preToolUseAskUserQuestionWaits() { + let t = source.transition( + for: event("PreToolUse", toolName: "AskUserQuestion"), + currentActivityState: .idle, + currentNotificationType: nil + ) + #expect(t.newActivityState == .waiting) + if case .set(let n) = t.notification { + #expect(n.notificationType == "question") + } else { + Issue.record("expected .set notification") + } + if case .clear = t.toolActivity {} else { + Issue.record("expected tool activity cleared") + } + } + + @Test func preToolUseOtherStartsWorking() { + let t = source.transition( + for: event("PreToolUse", toolName: "Bash"), + currentActivityState: .idle, + currentNotificationType: nil + ) + #expect(t.newActivityState == .working) + if case .set(let activity) = t.toolActivity { + #expect(activity.toolName == "Bash") + #expect(activity.isActive == true) + } else { + Issue.record("expected .set tool activity") + } + } + + // MARK: - PostToolUse + + @Test func postToolUseMarksActivityInactive() { + let t = source.transition( + for: event("PostToolUse", toolName: "Bash"), + currentActivityState: .working, + currentNotificationType: nil + ) + #expect(t.newActivityState == nil) + if case .set(let activity) = t.toolActivity { + #expect(activity.isActive == false) + } else { + Issue.record("expected .set inactive tool activity") + } + } + + // MARK: - Notification + + @Test func permissionPromptNotificationWaits() { + let t = source.transition( + for: event("Notification", message: "Approve?", notificationType: "permission_prompt"), + currentActivityState: .working, + currentNotificationType: nil + ) + #expect(t.newActivityState == .waiting) + if case .set(let n) = t.notification { + #expect(n.notificationType == "permission_prompt") + } else { + Issue.record("expected permission_prompt set") + } + } + + @Test func idlePromptNotificationClearsPending() { + let t = source.transition( + for: event("Notification", notificationType: "idle_prompt"), + currentActivityState: .done, + currentNotificationType: "permission_prompt" + ) + #expect(t.newActivityState == nil) // don't change — Stop already set .done + if case .clear = t.notification {} else { + Issue.record("expected notification cleared") + } + } + + // MARK: - PermissionRequest + + @Test func permissionRequestDoesNotOverrideQuestion() { + let t = source.transition( + for: event("PermissionRequest"), + currentActivityState: .waiting, + currentNotificationType: "question" + ) + #expect(t.newActivityState == .waiting) + if case .leave = t.notification {} else { + Issue.record("expected existing question notification preserved") + } + } + + @Test func permissionRequestSetsPermissionWhenNoQuestion() { + let t = source.transition( + for: event("PermissionRequest"), + currentActivityState: .working, + currentNotificationType: nil + ) + #expect(t.newActivityState == .waiting) + if case .set(let n) = t.notification { + #expect(n.notificationType == "permission_prompt") + } else { + Issue.record("expected permission_prompt set") + } + if case .clear = t.toolActivity {} else { + Issue.record("expected tool activity cleared") + } + } + + // MARK: - Lifecycle states + + @Test func stopMarksDone() { + let t = source.transition( + for: event("Stop"), + currentActivityState: .working, + currentNotificationType: nil + ) + #expect(t.newActivityState == .done) + } + + @Test func sessionStartResumeMarksDone() { + let t = source.transition( + for: event("SessionStart", source: "resume"), + currentActivityState: .idle, + currentNotificationType: nil + ) + #expect(t.newActivityState == .done) + } + + @Test func sessionStartFreshMarksIdle() { + let t = source.transition( + for: event("SessionStart", source: "startup"), + currentActivityState: .done, + currentNotificationType: nil + ) + #expect(t.newActivityState == .idle) + } + + @Test func sessionEndMarksIdleAndClearsActivity() { + let t = source.transition( + for: event("SessionEnd"), + currentActivityState: .working, + currentNotificationType: nil + ) + #expect(t.newActivityState == .idle) + if case .clear = t.toolActivity {} else { + Issue.record("expected activity cleared") + } + } + + // MARK: - Task/Subagent preserve waiting + + @Test func taskEventsDoNotOverrideWaiting() { + let t = source.transition( + for: event("TaskCreated"), + currentActivityState: .waiting, + currentNotificationType: "question" + ) + #expect(t.newActivityState == nil) // preserve .waiting + } + + @Test func taskEventsTransitionToWorkingFromOtherStates() { + let t = source.transition( + for: event("TaskCompleted"), + currentActivityState: .idle, + currentNotificationType: nil + ) + #expect(t.newActivityState == .working) + } + + // MARK: - Blanket notification clear + + @Test func nonNotificationEventClearsPendingNotification() { + let t = source.transition( + for: event("UserPromptSubmit"), + currentActivityState: .waiting, + currentNotificationType: "permission_prompt" + ) + if case .clear = t.notification {} else { + Issue.record("expected blanket clear for non-Notification event") + } + #expect(t.newActivityState == .working) + } + + @Test func unknownEventAppliesBlanketClearOnly() { + let t = source.transition( + for: event("PreCompact"), + currentActivityState: .working, + currentNotificationType: "permission_prompt" + ) + #expect(t.newActivityState == nil) + if case .clear = t.notification {} else { + Issue.record("expected clear") + } + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/AgentHookEvent.swift b/Packages/CrowCore/Sources/CrowCore/Agent/AgentHookEvent.swift new file mode 100644 index 0000000..3615086 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentHookEvent.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A normalized hook event delivered from an agent's runtime (e.g. a Claude +/// Code hook) into the state pipeline. +/// +/// Only the fields the state machine and notification layer actually consume +/// are modeled here; the raw payload lives in the RPC layer and is flattened +/// into this struct before it crosses the `StateSignalSource` boundary. Keeps +/// `CrowCore` free of a JSON-value dependency. +public struct AgentHookEvent: Sendable { + public let sessionID: UUID + public let eventName: String + public let toolName: String? + public let source: String? + public let message: String? + public let notificationType: String? + public let agentType: String? + public let summary: String + + public init( + sessionID: UUID, + eventName: String, + toolName: String? = nil, + source: String? = nil, + message: String? = nil, + notificationType: String? = nil, + agentType: String? = nil, + summary: String + ) { + self.sessionID = sessionID + self.eventName = eventName + self.toolName = toolName + self.source = source + self.message = message + self.notificationType = notificationType + self.agentType = agentType + self.summary = summary + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift b/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift new file mode 100644 index 0000000..9832458 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Identifier for a coding agent implementation (Claude Code today; others later). +/// +/// Declared as a `RawRepresentable` struct rather than an enum so downstream +/// packages can register additional kinds without modifying `CrowCore`. +public struct AgentKind: Hashable, Sendable, Codable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + /// The Claude Code agent. + public static let claudeCode = AgentKind(rawValue: "claude-code") +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/AgentRegistry.swift b/Packages/CrowCore/Sources/CrowCore/Agent/AgentRegistry.swift new file mode 100644 index 0000000..9a4dd49 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentRegistry.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Process-wide registry of `CodingAgent` implementations, keyed by +/// `AgentKind`. Phase A registers exactly one agent (Claude Code); later +/// phases let users pick an agent per session. +public final class AgentRegistry: @unchecked Sendable { + public static let shared = AgentRegistry() + + private let lock = NSLock() + private var agents: [AgentKind: any CodingAgent] = [:] + private var defaultKind: AgentKind? + + public init() {} + + /// Register `agent`. If no default has been set yet, the first registered + /// agent becomes the default. + public func register(_ agent: any CodingAgent) { + lock.lock(); defer { lock.unlock() } + agents[agent.kind] = agent + if defaultKind == nil { + defaultKind = agent.kind + } + } + + public func agent(for kind: AgentKind) -> (any CodingAgent)? { + lock.lock(); defer { lock.unlock() } + return agents[kind] + } + + /// The agent to use when the caller doesn't specify one. Falls back to + /// the first-registered agent. + public var defaultAgent: (any CodingAgent)? { + lock.lock(); defer { lock.unlock() } + guard let kind = defaultKind else { return nil } + return agents[kind] + } + + /// Explicitly set the default agent by kind. Caller must ensure the kind + /// has already been registered. + public func setDefault(_ kind: AgentKind) { + lock.lock(); defer { lock.unlock() } + defaultKind = kind + } + + public func allAgents() -> [any CodingAgent] { + lock.lock(); defer { lock.unlock() } + return Array(agents.values) + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/AgentStateTransition.swift b/Packages/CrowCore/Sources/CrowCore/Agent/AgentStateTransition.swift new file mode 100644 index 0000000..a5fe593 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentStateTransition.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A batch of per-session state changes produced by a `StateSignalSource` in +/// response to an `AgentHookEvent`. The hook-event RPC handler applies this +/// transition to `SessionHookState` — the signal source never touches state +/// itself, making the state machine testable in isolation. +public struct AgentStateTransition: Sendable { + public enum NotificationUpdate: Sendable { + case leave + case clear + case set(HookNotification) + } + + public enum ToolActivityUpdate: Sendable { + case leave + case clear + case set(ToolActivity) + } + + /// New activity state, or `nil` to leave the current state untouched. + public var newActivityState: AgentActivityState? + + /// Whether/how to mutate `SessionHookState.pendingNotification`. + public var notification: NotificationUpdate + + /// Whether/how to mutate `SessionHookState.lastToolActivity`. + public var toolActivity: ToolActivityUpdate + + public init( + newActivityState: AgentActivityState? = nil, + notification: NotificationUpdate = .leave, + toolActivity: ToolActivityUpdate = .leave + ) { + self.newActivityState = newActivityState + self.notification = notification + self.toolActivity = toolActivity + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift b/Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift new file mode 100644 index 0000000..dcc8022 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift @@ -0,0 +1,41 @@ +import Foundation + +/// A coding agent that Crow can launch in a terminal and observe via hook +/// events. Phase A wraps the existing Claude Code integration; later phases +/// introduce additional conformers. +public protocol CodingAgent: Sendable { + /// Stable identifier for this agent implementation. + var kind: AgentKind { get } + + /// Human-readable name shown in pickers, tooltips, and the session detail + /// header (e.g. "Claude Code"). + var displayName: String { get } + + /// SF Symbol name rendered in the sidebar row and pickers. Kept as a + /// string so `CrowCore` stays SwiftUI-free; consumers resolve it via + /// `Image(systemName:)`. + var iconSystemName: String { get } + + /// Writer for the per-worktree hook configuration file. + var hookConfigWriter: any HookConfigWriter { get } + + /// State-machine implementation that converts hook events into + /// `AgentStateTransition` values. + var stateSignalSource: any StateSignalSource { get } + + /// Build the initial prompt for this agent based on the session context. + func generatePrompt( + session: Session, + worktrees: [SessionWorktree], + ticketURL: String?, + provider: Provider? + ) async -> String + + /// Materialize `prompt` to disk (if needed) and return the shell command + /// that starts the agent with that prompt in `worktreePath`. + func launchCommand( + sessionID: UUID, + worktreePath: String, + prompt: String + ) async throws -> String +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/HookConfigWriter.swift b/Packages/CrowCore/Sources/CrowCore/Agent/HookConfigWriter.swift new file mode 100644 index 0000000..6137d6f --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/HookConfigWriter.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Writes (and later removes) the per-session hook configuration that an +/// agent reads to emit lifecycle events back to Crow. For Claude Code this +/// is `.claude/settings.local.json`; other agents will grow their own +/// conformers. +public protocol HookConfigWriter: Sendable { + /// Install hook entries for `sessionID` in the worktree. Must preserve any + /// user-authored entries that aren't managed by Crow. + func writeHookConfig(worktreePath: String, sessionID: UUID, crowPath: String) throws + + /// Remove Crow-managed hook entries from the worktree, preserving user + /// settings. Used when a session is deleted. + func removeHookConfig(worktreePath: String) +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/StateSignalSource.swift b/Packages/CrowCore/Sources/CrowCore/Agent/StateSignalSource.swift new file mode 100644 index 0000000..20157b4 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/StateSignalSource.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Translates raw agent runtime events (hook events today, other transports +/// later) into `AgentStateTransition` values. One implementation per agent. +/// +/// Implementations must be pure: given the same inputs they produce the same +/// transition, with no external side effects. Side effects (persistence, +/// notifications, telemetry) are driven by the caller after applying the +/// transition. +public protocol StateSignalSource: Sendable { + func transition( + for event: AgentHookEvent, + currentActivityState: AgentActivityState, + currentNotificationType: String? + ) -> AgentStateTransition +} diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 93653a3..d83eddd 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -17,6 +17,11 @@ public final class AppState { /// controlled from claude.ai / the Claude mobile app. Mirrors `AppConfig.remoteControlEnabled`. public var remoteControlEnabled: Bool = false + /// The agent seeded into new sessions when the caller doesn't pick one. + /// Mirrors `AppConfig.defaultAgentKind` so creation flows can read the + /// current default without a config round-trip. + public var defaultAgentKind: AgentKind = .claudeCode + /// Terminal IDs whose Claude Code was launched with `--rc` — drives the /// per-session indicator badge. Survives toggle changes so existing sessions /// keep showing the badge until they're restarted. @@ -161,8 +166,8 @@ public final class AppState { /// Called when user clicks "Start Review" for a PR review request. public var onStartReview: ((String) -> Void)? // receives PR URL - /// Called to launch Claude in a terminal that just became ready. - public var onLaunchClaude: ((UUID) -> Void)? // receives terminal ID + /// Called to launch the coding agent in a terminal that just became ready. + public var onLaunchAgent: ((UUID) -> Void)? // receives terminal ID /// Called to add a new plain-shell terminal tab to a session. public var onAddTerminal: ((UUID) -> Void)? // receives session ID @@ -333,13 +338,13 @@ public struct GitHubRateLimit: Equatable, Sendable { // MARK: - Per-Session Hook State -/// Observable wrapper for per-session hook/Claude state. +/// Observable wrapper for per-session agent/hook state. /// Using a reference-type @Observable class ensures that mutations to one session's /// state only invalidate views observing THAT session's instance — not all sessions. @MainActor @Observable public final class SessionHookState { - public var claudeState: ClaudeState = .idle + public var activityState: AgentActivityState = .idle public var pendingNotification: HookNotification? public var lastToolActivity: ToolActivity? public var hookEvents: [HookEvent] = [] diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index 1c84d7a..1013f0c 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -12,6 +12,9 @@ public struct AppConfig: Codable, Sendable, Equatable { public var sidebar: SidebarSettings public var remoteControlEnabled: Bool public var telemetry: TelemetryConfig + /// The agent used for newly created sessions when none is specified. + /// Existing persisted configs without this key decode to `.claudeCode`. + public var defaultAgentKind: AgentKind public init( workspaces: [WorkspaceInfo] = [], @@ -19,7 +22,8 @@ public struct AppConfig: Codable, Sendable, Equatable { notifications: NotificationSettings = NotificationSettings(), sidebar: SidebarSettings = SidebarSettings(), remoteControlEnabled: Bool = false, - telemetry: TelemetryConfig = TelemetryConfig() + telemetry: TelemetryConfig = TelemetryConfig(), + defaultAgentKind: AgentKind = .claudeCode ) { self.workspaces = workspaces self.defaults = defaults @@ -27,6 +31,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.sidebar = sidebar self.remoteControlEnabled = remoteControlEnabled self.telemetry = telemetry + self.defaultAgentKind = defaultAgentKind } public init(from decoder: Decoder) throws { @@ -37,10 +42,11 @@ public struct AppConfig: Codable, Sendable, Equatable { sidebar = try container.decodeIfPresent(SidebarSettings.self, forKey: .sidebar) ?? SidebarSettings() remoteControlEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteControlEnabled) ?? false telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig() + defaultAgentKind = try container.decodeIfPresent(AgentKind.self, forKey: .defaultAgentKind) ?? .claudeCode } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, telemetry + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, telemetry, defaultAgentKind } } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift index bc3284a..3423869 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift @@ -29,8 +29,8 @@ public enum LinkType: String, Codable, Sendable { case custom } -/// Claude Code process state as inferred from PTY output. -public enum ClaudeState: String, Codable, Sendable { +/// Coding-agent activity state as inferred from hook events. +public enum AgentActivityState: String, Codable, Sendable { case idle case working case waiting @@ -42,14 +42,14 @@ public enum TerminalReadiness: String, Codable, Sendable, Comparable { case uninitialized // GhosttySurfaceView exists but createSurface() not called case surfaceCreated // ghostty_surface_t exists, shell process spawning case shellReady // Shell prompt detected (probe file appeared) - case claudeLaunched // claude --continue has been sent + case agentLaunched // Agent launch command has been sent private var sortOrder: Int { switch self { case .uninitialized: 0 case .surfaceCreated: 1 case .shellReady: 2 - case .claudeLaunched: 3 + case .agentLaunched: 3 } } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Session.swift b/Packages/CrowCore/Sources/CrowCore/Models/Session.swift index 25e34cb..5f06998 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/Session.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/Session.swift @@ -6,6 +6,7 @@ public struct Session: Identifiable, Codable, Sendable { public var name: String public var status: SessionStatus public var kind: SessionKind + public var agentKind: AgentKind public var ticketURL: String? public var ticketTitle: String? public var ticketNumber: Int? @@ -18,6 +19,7 @@ public struct Session: Identifiable, Codable, Sendable { name: String, status: SessionStatus = .active, kind: SessionKind = .work, + agentKind: AgentKind = .claudeCode, ticketURL: String? = nil, ticketTitle: String? = nil, ticketNumber: Int? = nil, @@ -29,6 +31,7 @@ public struct Session: Identifiable, Codable, Sendable { self.name = name self.status = status self.kind = kind + self.agentKind = agentKind self.ticketURL = ticketURL self.ticketTitle = ticketTitle self.ticketNumber = ticketNumber @@ -37,13 +40,15 @@ public struct Session: Identifiable, Codable, Sendable { self.updatedAt = updatedAt } - // Backward-compatible decoding: default `kind` to `.work` when missing from older persisted data. + // Backward-compatible decoding: default `kind` and `agentKind` when + // missing from older persisted data. public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) status = try container.decode(SessionStatus.self, forKey: .status) kind = try container.decodeIfPresent(SessionKind.self, forKey: .kind) ?? .work + agentKind = try container.decodeIfPresent(AgentKind.self, forKey: .agentKind) ?? .claudeCode ticketURL = try container.decodeIfPresent(String.self, forKey: .ticketURL) ticketTitle = try container.decodeIfPresent(String.self, forKey: .ticketTitle) ticketNumber = try container.decodeIfPresent(Int.self, forKey: .ticketNumber) diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigAgentKindTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigAgentKindTests.swift new file mode 100644 index 0000000..06f1dd0 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigAgentKindTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import CrowCore + +// Coverage for Phase B's `AppConfig.defaultAgentKind` field. + +@Test func appConfigDefaultAgentKindIsClaudeCode() { + let config = AppConfig() + #expect(config.defaultAgentKind == .claudeCode) +} + +@Test func appConfigDefaultAgentKindRoundTrip() throws { + var config = AppConfig() + config.defaultAgentKind = AgentKind(rawValue: "codex") + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + + #expect(decoded.defaultAgentKind == AgentKind(rawValue: "codex")) +} + +@Test func appConfigLegacyJSONWithoutDefaultAgentKindUsesClaudeCode() throws { + // Simulates a config.json written before Phase B existed. The field must + // default to `.claudeCode` on decode so the app keeps booting. + let json = """ + { + "workspaces": [], + "remoteControlEnabled": true + } + """.data(using: .utf8)! + + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.defaultAgentKind == .claudeCode) + #expect(config.remoteControlEnabled == true) +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/SessionAgentKindTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/SessionAgentKindTests.swift new file mode 100644 index 0000000..ee4676a --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/SessionAgentKindTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CrowCore + +// Coverage for the Phase B addition of `Session.agentKind`. The most +// important property is backward compatibility: a `sessions.json` written +// before this field existed must continue to load. + +@Test func sessionDefaultAgentKindIsClaudeCode() { + let session = Session(name: "test") + #expect(session.agentKind == .claudeCode) +} + +@Test func sessionAgentKindRoundTrip() throws { + let session = Session( + name: "codex-session", + agentKind: AgentKind(rawValue: "codex") + ) + let data = try JSONEncoder().encode(session) + let decoded = try JSONDecoder().decode(Session.self, from: data) + + #expect(decoded.agentKind == AgentKind(rawValue: "codex")) + #expect(decoded.agentKind.rawValue == "codex") +} + +@Test func sessionCustomAgentKindRoundTripPreservesRawValue() throws { + let original = Session(name: "aider", agentKind: AgentKind(rawValue: "aider")) + let data = try JSONEncoder().encode(original) + + // Inspect the on-disk shape directly: the persisted `agentKind` field + // must be a plain string so future-phase consumers don't need to know + // the struct's encoding. + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(json?["agentKind"] as? String == "aider") +} + +@Test func sessionLegacyJSONWithoutAgentKindDecodesToClaudeCode() throws { + // Simulates a Session record written before Phase B existed: no + // `agentKind` key at all. Must decode cleanly to `.claudeCode`. + let id = UUID() + let date = Date() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let dateString = ISO8601DateFormatter().string(from: date) + + let json: [String: Any] = [ + "id": id.uuidString, + "name": "legacy-session", + "status": "active", + "createdAt": dateString, + "updatedAt": dateString, + ] + let data = try JSONSerialization.data(withJSONObject: json) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let session = try decoder.decode(Session.self, from: data) + + #expect(session.agentKind == .claudeCode) + #expect(session.name == "legacy-session") +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/TerminalReadinessTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/TerminalReadinessTests.swift index 2f4b287..cd56457 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/TerminalReadinessTests.swift +++ b/Packages/CrowCore/Tests/CrowCoreTests/TerminalReadinessTests.swift @@ -10,19 +10,19 @@ struct TerminalReadinessTests { @Test func statesAreOrdered() { #expect(TerminalReadiness.uninitialized < .surfaceCreated) #expect(TerminalReadiness.surfaceCreated < .shellReady) - #expect(TerminalReadiness.shellReady < .claudeLaunched) + #expect(TerminalReadiness.shellReady < .agentLaunched) } @Test func transitiveOrdering() { - #expect(TerminalReadiness.uninitialized < .claudeLaunched) + #expect(TerminalReadiness.uninitialized < .agentLaunched) #expect(TerminalReadiness.uninitialized < .shellReady) - #expect(TerminalReadiness.surfaceCreated < .claudeLaunched) + #expect(TerminalReadiness.surfaceCreated < .agentLaunched) } @Test func equalStatesAreNotLessThan() { #expect(!(TerminalReadiness.uninitialized < .uninitialized)) #expect(!(TerminalReadiness.shellReady < .shellReady)) - #expect(!(TerminalReadiness.claudeLaunched < .claudeLaunched)) + #expect(!(TerminalReadiness.agentLaunched < .agentLaunched)) } // MARK: - Equality @@ -31,12 +31,12 @@ struct TerminalReadinessTests { #expect(TerminalReadiness.uninitialized == .uninitialized) #expect(TerminalReadiness.surfaceCreated == .surfaceCreated) #expect(TerminalReadiness.shellReady == .shellReady) - #expect(TerminalReadiness.claudeLaunched == .claudeLaunched) + #expect(TerminalReadiness.agentLaunched == .agentLaunched) } @Test func differentStatesAreNotEqual() { #expect(TerminalReadiness.uninitialized != .surfaceCreated) - #expect(TerminalReadiness.shellReady != .claudeLaunched) + #expect(TerminalReadiness.shellReady != .agentLaunched) } // MARK: - Raw Values @@ -45,13 +45,13 @@ struct TerminalReadinessTests { #expect(TerminalReadiness.uninitialized.rawValue == "uninitialized") #expect(TerminalReadiness.surfaceCreated.rawValue == "surfaceCreated") #expect(TerminalReadiness.shellReady.rawValue == "shellReady") - #expect(TerminalReadiness.claudeLaunched.rawValue == "claudeLaunched") + #expect(TerminalReadiness.agentLaunched.rawValue == "agentLaunched") } // MARK: - Codable @Test func codableRoundTrip() throws { - let cases: [TerminalReadiness] = [.uninitialized, .surfaceCreated, .shellReady, .claudeLaunched] + let cases: [TerminalReadiness] = [.uninitialized, .surfaceCreated, .shellReady, .agentLaunched] let encoder = JSONEncoder() let decoder = JSONDecoder() diff --git a/Packages/CrowUI/Sources/CrowUI/CreateSessionView.swift b/Packages/CrowUI/Sources/CrowUI/CreateSessionView.swift index d537f49..c5c40f4 100644 --- a/Packages/CrowUI/Sources/CrowUI/CreateSessionView.swift +++ b/Packages/CrowUI/Sources/CrowUI/CreateSessionView.swift @@ -8,6 +8,11 @@ public struct CreateSessionView: View { @Bindable var appState: AppState @Environment(\.dismiss) private var dismiss @State private var name = "" + @State private var agentKind: AgentKind = .claudeCode + + private var availableAgents: [any CodingAgent] { + AgentRegistry.shared.allAgents() + } public init(appState: AppState) { self.appState = appState @@ -28,6 +33,14 @@ public struct CreateSessionView: View { .textFieldStyle(.roundedBorder) .onSubmit { createSession() } + Picker("Agent", selection: $agentKind) { + ForEach(availableAgents, id: \.kind) { agent in + Label(agent.displayName, systemImage: agent.iconSystemName) + .tag(agent.kind) + } + } + .disabled(availableAgents.count < 2) + HStack { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) @@ -41,13 +54,16 @@ public struct CreateSessionView: View { } .padding(24) .frame(width: 400) + .onAppear { + agentKind = appState.defaultAgentKind + } } private func createSession() { let trimmed = name.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } - let session = Session(name: trimmed) + let session = Session(name: trimmed, agentKind: agentKind) appState.sessions.append(session) appState.selectedSessionID = session.id dismiss() diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index 0715b68..351b033 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -87,6 +87,21 @@ public struct SessionDetailView: View { .padding(.vertical, 6) } + // Row 2.5: Agent — read-only label for non-Manager sessions so + // users can see which agent was chosen at creation time. The + // Manager tab is pinned to Claude Code and hides this row per spec. + if session.id != AppState.managerSessionID, + let agent = AgentRegistry.shared.agent(for: session.agentKind) { + Divider().overlay(CorveilTheme.borderSubtle).padding(.horizontal, 16) + + HStack(spacing: 16) { + DetailLabel(icon: agent.iconSystemName, text: "Agent: \(agent.displayName)") + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + // Row 3: Links + Actions (only if there's content to show) if session.ticketURL != nil || !sessionLinks.isEmpty || session.id != AppState.managerSessionID { Divider().overlay(CorveilTheme.borderSubtle).padding(.horizontal, 16) @@ -367,13 +382,13 @@ struct StatusBadge: View { // MARK: - Readiness-Aware Terminal Wrapper /// Wraps a TerminalSurfaceView with readiness tracking. -/// Auto-launches `claude --continue` when the shell becomes ready on first focus. +/// Auto-launches the coding agent when the shell becomes ready on first focus. struct ReadinessAwareTerminal: View { let terminal: SessionTerminal @Bindable var appState: AppState private var readiness: TerminalReadiness { - appState.terminalReadiness[terminal.id] ?? .claudeLaunched // Default for non-tracked terminals + appState.terminalReadiness[terminal.id] ?? .agentLaunched // Default for non-tracked terminals } var body: some View { @@ -400,8 +415,8 @@ struct ReadinessAwareTerminal: View { } .onChange(of: readiness) { oldValue, newValue in if newValue == .shellReady { - // Shell just became ready — auto-launch Claude - appState.onLaunchClaude?(terminal.id) + // Shell just became ready — auto-launch the agent + appState.onLaunchAgent?(terminal.id) } } } diff --git a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift index 3f5fb33..7c96105 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -256,8 +256,8 @@ struct SessionRow: View { appState.prStatus[session.id] } - private var claudeState: ClaudeState { - appState.hookState(for: session.id).claudeState + private var activityState: AgentActivityState { + appState.hookState(for: session.id).activityState } /// Readiness of the primary terminal for this session. @@ -267,10 +267,20 @@ struct SessionRow: View { return appState.terminalReadiness[primary.id] } + private var agent: (any CodingAgent)? { + AgentRegistry.shared.agent(for: session.agentKind) + } + var body: some View { VStack(alignment: .leading, spacing: 3) { // Row 1: Name + status indicator HStack(spacing: 4) { + if let agent { + Image(systemName: agent.iconSystemName) + .font(.caption2) + .foregroundStyle(CorveilTheme.textSecondary) + .help(agent.displayName) + } Text(session.name) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(CorveilTheme.textPrimary) @@ -300,7 +310,7 @@ struct SessionRow: View { // Row 4: Issue badge + PR badge + Claude state let hasIssueBadge = session.ticketNumber != nil - let hasBadges = hasIssueBadge || prLink != nil || claudeState != .idle + let hasBadges = hasIssueBadge || prLink != nil || activityState != .idle if hasBadges { HStack(spacing: 6) { if let num = session.ticketNumber { @@ -309,8 +319,8 @@ struct SessionRow: View { if let pr = prLink { PRBadge(label: pr.label, status: prStatus) } - if claudeState != .idle || appState.hookState(for: session.id).pendingNotification != nil { - claudeStateBadge + if activityState != .idle || appState.hookState(for: session.id).pendingNotification != nil { + activityStateBadge } } } @@ -351,7 +361,7 @@ struct SessionRow: View { .fill(.blue) .frame(width: 8, height: 8) .accessibilityLabel("Shell ready") - case .claudeLaunched: + case .agentLaunched: if needsAttention { Circle() .fill(.orange) @@ -362,7 +372,7 @@ struct SessionRow: View { .scaleEffect(1.6) ) .accessibilityLabel("Needs attention") - } else if claudeState == .working { + } else if activityState == .working { Circle() .fill(.green) .frame(width: 8, height: 8) @@ -404,7 +414,7 @@ struct SessionRow: View { } @ViewBuilder - private var claudeStateBadge: some View { + private var activityStateBadge: some View { let activity = appState.hookState(for: session.id).lastToolActivity let notification = appState.hookState(for: session.id).pendingNotification @@ -428,7 +438,7 @@ struct SessionRow: View { .foregroundStyle(.orange) } } else { - switch claudeState { + switch activityState { case .working: HStack(spacing: 3) { Image(systemName: "bolt.fill") @@ -463,7 +473,7 @@ struct SessionRow: View { private var rowBackgroundColor: Color { if needsAttention { return Color.orange.opacity(0.12) - } else if claudeState == .done && terminalReadiness == .claudeLaunched { + } else if activityState == .done && terminalReadiness == .agentLaunched { return CorveilTheme.bgDone } return CorveilTheme.bgCard diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index a08cf59..9ff4b71 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -137,6 +137,15 @@ public struct SettingsView: View { } .onChange(of: config.defaults.provider) { _, _ in save() } + Picker("Default Agent", selection: $config.defaultAgentKind) { + ForEach(AgentRegistry.shared.allAgents(), id: \.kind) { agent in + Label(agent.displayName, systemImage: agent.iconSystemName) + .tag(agent.kind) + } + } + .onChange(of: config.defaultAgentKind) { _, _ in save() } + .disabled(AgentRegistry.shared.allAgents().count < 2) + TextField("Branch Prefix", text: $config.defaults.branchPrefix) .textFieldStyle(.roundedBorder) .onSubmit { save() } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 9b258e9..19bd918 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import CrowClaude import CrowCore import CrowUI import CrowPersistence @@ -87,6 +88,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func launchMainApp() { guard let devRoot else { return } + // Register the Claude Code agent in the shared registry. Phase A only + // has one agent; later phases will register additional conformers and + // let users pick one per session. + AgentRegistry.shared.register(ClaudeCodeAgent()) + // Initialize libghostty NSLog("[Crow] Initializing Ghostty") GhosttyApp.shared.initialize() @@ -113,6 +119,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // set before hydrateState so the Manager terminal's stored command can // be rebuilt to include (or drop) `--rc` before its surface is pre-initialized. appState.remoteControlEnabled = config.remoteControlEnabled + appState.defaultAgentKind = config.defaultAgentKind // Create session service and hydrate state let service = SessionService(store: store, appState: appState, telemetryPort: config.telemetry.enabled ? config.telemetry.port : nil) @@ -155,7 +162,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { service?.setSessionInReview(id: id) } - appState.onLaunchClaude = { [weak service] terminalID in + appState.onLaunchAgent = { [weak service] terminalID in service?.launchClaude(terminalID: terminalID) } @@ -470,11 +477,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard AppDelegate.isValidSessionName(name) else { throw RPCError.invalidParams("Invalid session name (max \(AppDelegate.maxSessionNameLength) chars, no control characters)") } + // Optional `agent_kind` param (e.g. "claude-code"). Falls + // back to the app-wide default when absent or empty. + let requestedAgentKind = params["agent_kind"]?.stringValue + .flatMap { $0.isEmpty ? nil : AgentKind(rawValue: $0) } return await MainActor.run { - let session = Session(name: name) + let agentKind = requestedAgentKind ?? capturedAppState.defaultAgentKind + let session = Session(name: name, agentKind: agentKind) capturedAppState.sessions.append(session) capturedStore.mutate { $0.sessions.append(session) } - return ["session_id": .string(session.id.uuidString), "name": .string(session.name)] + return [ + "session_id": .string(session.id.uuidString), + "name": .string(session.name), + "agent_kind": .string(session.agentKind.rawValue), + ] } }, "rename-session": { @Sendable params in @@ -741,9 +757,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { terminal.isManaged, text.contains("claude") { if let worktree = capturedAppState.primaryWorktree(for: sessionID), - let crowPath = HookConfigGenerator.findCrowBinary() { + let crowPath = ClaudeHookConfigWriter.findCrowBinary() { do { - try HookConfigGenerator.writeHookConfig( + try ClaudeHookConfigWriter().writeHookConfig( worktreePath: worktree.worktreePath, sessionID: sessionID, crowPath: crowPath @@ -765,7 +781,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ].joined(separator: " ") text = "export \(vars) && \(text)" } - capturedAppState.terminalReadiness[terminalID] = .claudeLaunched + capturedAppState.terminalReadiness[terminalID] = .agentLaunched } TerminalManager.shared.send(id: terminalID, text: text) @@ -851,6 +867,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { summary: summary ) + // Flatten the raw JSON payload into the typed AgentHookEvent + // that the state-machine signal source consumes. Keeps + // CrowCore free of JSONValue, and localizes the field + // extraction in one place. + let agentEvent = AgentHookEvent( + sessionID: sessionID, + eventName: eventName, + toolName: payload["tool_name"]?.stringValue, + source: payload["source"]?.stringValue, + message: payload["message"]?.stringValue, + notificationType: payload["notification_type"]?.stringValue, + agentType: payload["agent_type"]?.stringValue, + summary: summary + ) + + // Phase A: always route through the default (Claude Code) + // agent. Later phases will look up a per-session agent. + let signalSource = AgentRegistry.shared.defaultAgent?.stateSignalSource + return await MainActor.run { let state = capturedAppState.hookState(for: sessionID) @@ -858,109 +893,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate { state.hookEvents.append(event) if state.hookEvents.count > 50 { state.hookEvents.removeFirst(state.hookEvents.count - 50) } - // Update derived state based on event type. - // Clear pending notification on ANY event that indicates - // Claude moved past the waiting state (except Notification - // itself, which may SET the pending state). - if eventName != "Notification" && eventName != "PermissionRequest" { - state.pendingNotification = nil - } - - switch eventName { - case "PreToolUse": - let toolName = payload["tool_name"]?.stringValue ?? "unknown" - if toolName == "AskUserQuestion" { - // Question for the user — set attention state - state.pendingNotification = HookNotification( - message: "Claude has a question", - notificationType: "question" - ) - state.claudeState = .waiting - state.lastToolActivity = nil - } else { - state.lastToolActivity = ToolActivity( - toolName: toolName, isActive: true - ) - state.claudeState = .working - } - - case "PostToolUse": - let toolName = payload["tool_name"]?.stringValue ?? "unknown" - state.lastToolActivity = ToolActivity( - toolName: toolName, isActive: false + // Ask the agent for the state transition and apply it. + // The signal source is pure — all side effects (persistence, + // notifications, etc.) stay here in the handler. + if let signalSource { + let transition = signalSource.transition( + for: agentEvent, + currentActivityState: state.activityState, + currentNotificationType: state.pendingNotification?.notificationType ) - - case "PostToolUseFailure": - let toolName = payload["tool_name"]?.stringValue ?? "unknown" - state.lastToolActivity = ToolActivity( - toolName: toolName, isActive: false - ) - - case "Notification": - let message = payload["message"]?.stringValue ?? "" - let notifType = payload["notification_type"]?.stringValue ?? "" - if notifType == "permission_prompt" { - // Permission needed — show attention state - state.pendingNotification = HookNotification( - message: message, notificationType: notifType - ) - state.claudeState = .waiting - } else if notifType == "idle_prompt" { - // Claude is at the prompt — clear any stale permission notification - // but don't change claudeState (Stop already set it to .done) - state.pendingNotification = nil - } - - case "PermissionRequest": - // Don't override a "question" notification — AskUserQuestion - // triggers both PreToolUse and PermissionRequest, and the - // question badge is more specific than generic "Permission" - if state.pendingNotification?.notificationType != "question" { - state.pendingNotification = HookNotification( - message: "Permission requested", - notificationType: "permission_prompt" - ) - } - state.claudeState = .waiting - state.lastToolActivity = nil - - case "UserPromptSubmit": - state.claudeState = .working - - case "Stop": - state.claudeState = .done - state.lastToolActivity = nil - - case "StopFailure": - state.claudeState = .waiting - - case "SessionStart": - let source = payload["source"]?.stringValue ?? "startup" - if source == "resume" { - state.claudeState = .done - } else { - state.claudeState = .idle + if let newActivityState = transition.newActivityState { + state.activityState = newActivityState } - - case "SessionEnd": - state.claudeState = .idle - state.lastToolActivity = nil - - case "SubagentStart": - state.claudeState = .working - - case "TaskCreated", "TaskCompleted", "SubagentStop": - // Stay in working state - if state.claudeState != .waiting { - state.claudeState = .working + switch transition.notification { + case .leave: + break + case .clear: + state.pendingNotification = nil + case .set(let notification): + state.pendingNotification = notification } - - default: - // PermissionDenied, PreCompact, PostCompact — state change - // handled by blanket notification clear above - if eventName == "PermissionDenied" { - state.claudeState = .working + switch transition.toolActivity { + case .leave: + break + case .clear: state.lastToolActivity = nil + case .set(let activity): + state.lastToolActivity = activity } } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 0813cb0..0a1ba62 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import CrowClaude import CrowCore import CrowPersistence import CrowTerminal @@ -157,7 +158,7 @@ final class SessionService { } case .shellReady: // Only advance forward — the `send` handler may have already - // set .claudeLaunched before this timer fires. + // set .agentLaunched before this timer fires. if currentState < .shellReady { self.appState.terminalReadiness[terminalID] = .shellReady } @@ -186,9 +187,9 @@ final class SessionService { // Write/refresh hook config for the session's worktree if let sessionID, let worktree = appState.primaryWorktree(for: sessionID), - let crowPath = HookConfigGenerator.findCrowBinary() { + let crowPath = ClaudeHookConfigWriter.findCrowBinary() { do { - try HookConfigGenerator.writeHookConfig( + try ClaudeHookConfigWriter().writeHookConfig( worktreePath: worktree.worktreePath, sessionID: sessionID, crowPath: crowPath @@ -231,7 +232,7 @@ final class SessionService { TerminalManager.shared.send(id: terminalID, text: "\(envPrefix)\(claudePath)\(rcArgs) --continue\n") } - appState.terminalReadiness[terminalID] = .claudeLaunched + appState.terminalReadiness[terminalID] = .agentLaunched if rcEnabled { appState.remoteControlActiveTerminals.insert(terminalID) } @@ -242,10 +243,13 @@ final class SessionService { func ensureManagerSession(devRoot: String) { let managerID = AppState.managerSessionID if !appState.sessions.contains(where: { $0.id == managerID }) { + // Manager is pinned to Claude Code per the agent-abstraction + // spec — never honors AppConfig.defaultAgentKind. let manager = Session( id: managerID, name: "Manager", - status: .active + status: .active, + agentKind: .claudeCode ) appState.sessions.insert(manager, at: 0) @@ -328,7 +332,7 @@ final class SessionService { } // Remove our hook config from settings.local.json before deleting the worktree - HookConfigGenerator.removeHookConfig(worktreePath: wt.worktreePath) + ClaudeHookConfigWriter().removeHookConfig(worktreePath: wt.worktreePath) do { // Remove the worktree @@ -620,6 +624,7 @@ final class SessionService { let session = Session( name: dirName, status: .active, + agentKind: appState.defaultAgentKind, ticketURL: ticket.url, ticketTitle: ticket.title, ticketNumber: ticket.number, @@ -824,6 +829,7 @@ final class SessionService { let session = Session( name: "review-\(repoName)-\(prNumber)", kind: .review, + agentKind: appState.defaultAgentKind, ticketTitle: prTitle, provider: .github )