diff --git a/Package.swift b/Package.swift index 0aec3a1..176505d 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( .package(path: "Packages/CrowProvider"), .package(path: "Packages/CrowPersistence"), .package(path: "Packages/CrowClaude"), + .package(path: "Packages/CrowCodex"), .package(path: "Packages/CrowIPC"), .package(path: "Packages/CrowTelemetry"), .package(path: "Packages/CrowCLI"), @@ -32,6 +33,7 @@ let package = Package( "CrowProvider", "CrowPersistence", "CrowClaude", + "CrowCodex", "CrowIPC", "CrowTelemetry", ], diff --git a/Packages/CrowCLI/Package.swift b/Packages/CrowCLI/Package.swift index 50a6dae..5238b42 100644 --- a/Packages/CrowCLI/Package.swift +++ b/Packages/CrowCLI/Package.swift @@ -9,6 +9,7 @@ let package = Package( ], dependencies: [ .package(path: "../CrowIPC"), + .package(path: "../CrowCodex"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), ], targets: [ @@ -16,6 +17,7 @@ let package = Package( name: "CrowCLILib", dependencies: [ "CrowIPC", + "CrowCodex", .product(name: "ArgumentParser", package: "swift-argument-parser"), ] ), diff --git a/Packages/CrowCLI/Sources/CrowCLILib/Commands/CodexNotifyCommand.swift b/Packages/CrowCLI/Sources/CrowCLILib/Commands/CodexNotifyCommand.swift new file mode 100644 index 0000000..32b1e53 --- /dev/null +++ b/Packages/CrowCLI/Sources/CrowCLILib/Commands/CodexNotifyCommand.swift @@ -0,0 +1,51 @@ +import ArgumentParser +import CrowCodex +import CrowIPC +import Foundation + +/// Bridge OpenAI Codex's `notify` command into Crow's hook-event RPC. +/// +/// Codex invokes `notify = ["crow", "codex-notify"]` after each turn +/// completion, passing the JSON payload as the final positional argument. +/// We translate that payload into a hook-event RPC with `agent_kind=codex`, +/// letting the server's existing pipeline drive state transitions and +/// notifications. The session is resolved server-side from the `cwd` field +/// in the payload — no `--session` flag is needed (and Codex couldn't +/// supply one anyway, since its config is global). +public struct CodexNotify: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "codex-notify", + abstract: "Bridge Codex's notify command into Crow's hook-event pipeline" + ) + + @Argument(parsing: .remaining, help: "Codex notify JSON payload (final positional arg)") + var payloadArg: [String] = [] + + public init() {} + + public func run() throws { + // Codex invokes us as `crow codex-notify `. Fall back + // to stdin so the command is testable / scriptable manually. + let json: String = { + if let last = payloadArg.last, !last.isEmpty { return last } + let data = FileHandle.standardInput.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + }() + + let translation = CodexNotifyPayload.translate(json) + + // Convert the [String: String] payload into the JSON-RPC value shape. + // (CrowCodex doesn't depend on JSONValue; the boundary lives here.) + let payload: [String: JSONValue] = translation.payload.reduce(into: [:]) { + $0[$1.key] = .string($1.value) + } + + // Use the shared forwarder so a missing Crow app silently no-ops + // (otherwise we'd pollute Codex's notify output with a stack trace). + try forwardHookEvent(params: [ + "event_name": .string(translation.eventName), + "agent_kind": .string("codex"), + "payload": .object(payload), + ]) + } +} diff --git a/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift b/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift index 377f448..11ae9df 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/Commands/HookEventCommand.swift @@ -4,47 +4,63 @@ import Foundation // MARK: - Hook Event Command -/// Forward a Claude Code hook event to the running Crow app. +/// Forward an agent hook event to the running Crow app. /// -/// Reads a JSON payload from stdin (piped by Claude Code) and forwards it +/// Reads a JSON payload from stdin (piped by the agent) and forwards it /// as an RPC call. Silent on success to avoid polluting hook output. +/// +/// `--session` is optional: agents whose hook config carries the Crow +/// session UUID (Claude Code) include it; agents whose hook config is +/// global (Codex) omit it, and the server resolves the session by matching +/// the payload's `cwd` against registered worktree paths. public struct HookEventCmd: ParsableCommand { public static let configuration = CommandConfiguration( commandName: "hook-event", - abstract: "Forward a Claude Code hook event to the app (reads JSON from stdin)" + abstract: "Forward an agent hook event to the app (reads JSON from stdin)" ) - @Option(name: .long, help: "Session UUID") var session: String + @Option(name: .long, help: "Session UUID (omit to resolve from payload cwd)") + var session: String? @Option(name: .long, help: "Event name (e.g., Stop, Notification, PreToolUse)") var event: String + @Option(name: .long, help: "Agent kind (e.g., claude-code, codex). Defaults to the session's stored agent.") + var agent: String? public init() {} public func validate() throws { - try validateUUID(session, label: "session UUID") + if let session, !session.isEmpty { + try validateUUID(session, label: "session UUID") + } } public func run() throws { let payload = parseHookPayload(from: FileHandle.standardInput.readDataToEndOfFile()) - try forwardHookEvent(sessionID: session, eventName: event, payload: payload) + + var params: [String: JSONValue] = [ + "event_name": .string(event), + "payload": .object(payload), + ] + if let session, !session.isEmpty { + params["session_id"] = .string(session) + } + if let agent, !agent.isEmpty { + params["agent_kind"] = .string(agent) + } + + try forwardHookEvent(params: params) } } -/// Forward a parsed hook event over the Unix socket. +/// Forward a hook-event RPC over the Unix socket. /// /// Silently no-ops when the Crow app is not running (socket connection /// refused or socket file absent). Hooks are fire-and-forget — a missing /// listener is an expected state, not an error, so we must not exit -/// non-zero or write to stderr (it pollutes Claude Code's hook output). +/// non-zero or write to stderr (it pollutes the agent's hook output). /// Other socket errors (timeout, write/read failures) and JSON-RPC /// errors still propagate so genuine misbehavior is visible. -func forwardHookEvent(sessionID: String, eventName: String, payload: [String: JSONValue]) throws { +func forwardHookEvent(params: [String: JSONValue]) throws { do { - let result = try rpc("hook-event", params: [ - "session_id": .string(sessionID), - "event_name": .string(eventName), - "payload": .object(payload), - ]) - - // Silent on success — only print on error + let result = try rpc("hook-event", params: params) if result["error"] != nil { printJSON(result) } 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/CrowCLI/Sources/CrowCLILib/CrowCommand.swift b/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift index a2e3e44..4da2483 100644 --- a/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift +++ b/Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift @@ -29,6 +29,7 @@ public struct CrowCommand: ParsableCommand { AddLink.self, ListLinks.self, HookEventCmd.self, + CodexNotify.self, ] ) diff --git a/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift b/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift index 304d9fe..9cebe2c 100644 --- a/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift +++ b/Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift @@ -59,9 +59,9 @@ import CrowIPC setenv("CROW_SOCKET", nonExistent, 1) defer { unsetenv("CROW_SOCKET") } - try forwardHookEvent( - sessionID: UUID().uuidString, - eventName: "Stop", - payload: [:] - ) + try forwardHookEvent(params: [ + "session_id": .string(UUID().uuidString), + "event_name": .string("Stop"), + "payload": .object([:]), + ]) } diff --git a/Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift new file mode 100644 index 0000000..0c74615 --- /dev/null +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift @@ -0,0 +1,109 @@ +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 supportsRemoteControl: Bool = true + public let launchCommandToken: String = "claude" + public let hookConfigWriter: any HookConfigWriter + public let stateSignalSource: any StateSignalSource + + private let launcher: ClaudeLauncher + + /// Standard search paths for the Claude CLI binary, in priority order. + static let claudeBinaryCandidates: [String] = [ + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/claude").path, + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + ] + + public init( + hookConfigWriter: any HookConfigWriter = ClaudeHookConfigWriter(), + stateSignalSource: any StateSignalSource = ClaudeHookSignalSource() + ) { + self.hookConfigWriter = hookConfigWriter + self.stateSignalSource = stateSignalSource + self.launcher = ClaudeLauncher() + } + + public func findBinary() -> String? { + for path in Self.claudeBinaryCandidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + public func autoLaunchCommand( + session: Session, + worktreePath: String, + remoteControlEnabled: Bool, + telemetryPort: UInt16? + ) -> String? { + let claudePath = findBinary() ?? "claude" + let rcArgs = ClaudeLaunchArgs.argsSuffix( + remoteControl: remoteControlEnabled, + sessionName: session.name + ) + + // OTEL telemetry env-var prefix when Crow's OTLP receiver is up. + let envPrefix: String + if let port = telemetryPort { + let vars = [ + "CLAUDE_CODE_ENABLE_TELEMETRY=1", + "OTEL_METRICS_EXPORTER=otlp", + "OTEL_LOGS_EXPORTER=otlp", + "OTEL_EXPORTER_OTLP_PROTOCOL=http/json", + "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:\(port)", + "OTEL_RESOURCE_ATTRIBUTES=crow.session.id=\(session.id.uuidString)", + ].joined(separator: " ") + envPrefix = "export \(vars) && " + } else { + envPrefix = "" + } + + // Review sessions read their pre-written prompt file on first launch + // only — on subsequent app restarts, fall through to `--continue` so + // the existing conversation resumes instead of re-running the entire + // review (CROW-224). Work sessions always resume. + if session.kind == .review && !session.reviewPromptDispatched { + let promptPath = (worktreePath as NSString) + .appendingPathComponent(".crow-review-prompt.md") + return "\(envPrefix)\(claudePath)\(rcArgs) \"$(cat \(promptPath))\"\n" + } + return "\(envPrefix)\(claudePath)\(rcArgs) --continue\n" + } + + 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..b442e82 --- /dev/null +++ b/Packages/CrowClaude/Sources/CrowClaude/ClaudeHookSignalSource.swift @@ -0,0 +1,144 @@ +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?, + currentLastTopLevelStopAt: Date? + ) -> 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 + // A new real turn has begun — clear the post-Stop guard so + // legitimate subagents in this turn can elevate state again. + transition.lastTopLevelStopAt = .clear + + case "Stop": + transition.newActivityState = .done + transition.toolActivity = .clear + transition.lastTopLevelStopAt = .set(Date()) + + case "StopFailure": + transition.newActivityState = .waiting + transition.lastTopLevelStopAt = .set(Date()) + + case "SessionStart": + let source = event.source ?? "startup" + if source == "resume" { + transition.newActivityState = .done + } else { + transition.newActivityState = .idle + } + transition.lastTopLevelStopAt = .clear + + case "SessionEnd": + transition.newActivityState = .idle + transition.toolActivity = .clear + transition.lastTopLevelStopAt = .clear + + case "SubagentStart": + // If a top-level Stop has already fired for this turn, the + // subagent is background work (e.g. the recap generator from + // Claude Code ≥ 2.1.108's awaySummaryEnabled feature). Don't + // elevate state — the user is genuinely done. + if currentLastTopLevelStopAt == nil { + transition.newActivityState = .working + } + + case "TaskCreated", "TaskCompleted", "SubagentStop": + // Stay in working state, but only while the turn is still live. + // After a top-level Stop, treat these as background activity + // and leave the activity state alone. Also don't clobber a + // pending question/permission. + if currentActivityState != .waiting && currentLastTopLevelStopAt == nil { + 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/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift b/Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift similarity index 100% rename from Packages/CrowCore/Sources/CrowCore/ClaudeLaunchArgs.swift rename to Packages/CrowClaude/Sources/CrowClaude/ClaudeLaunchArgs.swift diff --git a/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookSignalSourceTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookSignalSourceTests.swift new file mode 100644 index 0000000..7df2d3a --- /dev/null +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeHookSignalSourceTests.swift @@ -0,0 +1,244 @@ +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, + currentLastTopLevelStopAt: 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, + currentLastTopLevelStopAt: 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, + currentLastTopLevelStopAt: 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, + currentLastTopLevelStopAt: 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", + currentLastTopLevelStopAt: nil + ) + #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", + currentLastTopLevelStopAt: nil + ) + #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, + currentLastTopLevelStopAt: 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, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + } + + @Test func sessionStartResumeMarksDone() { + let t = source.transition( + for: event("SessionStart", source: "resume"), + currentActivityState: .idle, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + } + + @Test func sessionStartFreshMarksIdle() { + let t = source.transition( + for: event("SessionStart", source: "startup"), + currentActivityState: .done, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .idle) + } + + @Test func sessionEndMarksIdleAndClearsActivity() { + let t = source.transition( + for: event("SessionEnd"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: 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", + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == nil) // preserve .waiting + } + + @Test func taskEventsTransitionToWorkingFromOtherStates() { + let t = source.transition( + for: event("TaskCompleted"), + currentActivityState: .idle, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .working) + } + + // MARK: - Blanket notification clear + + @Test func nonNotificationEventClearsPendingNotification() { + let t = source.transition( + for: event("UserPromptSubmit"), + currentActivityState: .waiting, + currentNotificationType: "permission_prompt", + currentLastTopLevelStopAt: nil + ) + 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", + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == nil) + if case .clear = t.notification {} else { + Issue.record("expected clear") + } + } +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift similarity index 98% rename from Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift rename to Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift index f4172f5..94b1c8a 100644 --- a/Packages/CrowCore/Tests/CrowCoreTests/ClaudeLaunchArgsTests.swift +++ b/Packages/CrowClaude/Tests/CrowClaudeTests/ClaudeLaunchArgsTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -@testable import CrowCore +@testable import CrowClaude @Test func claudeLaunchArgsDisabledReturnsEmpty() { #expect(ClaudeLaunchArgs.argsSuffix(remoteControl: false, sessionName: nil) == "") diff --git a/Packages/CrowCodex/Package.swift b/Packages/CrowCodex/Package.swift new file mode 100644 index 0000000..d3562ca --- /dev/null +++ b/Packages/CrowCodex/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CrowCodex", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CrowCodex", targets: ["CrowCodex"]), + ], + dependencies: [ + .package(path: "../CrowCore"), + ], + targets: [ + .target(name: "CrowCodex", dependencies: ["CrowCore"]), + .testTarget(name: "CrowCodexTests", dependencies: ["CrowCodex"]), + ] +) diff --git a/Packages/CrowCodex/Sources/CrowCodex/CodexHookConfigWriter.swift b/Packages/CrowCodex/Sources/CrowCodex/CodexHookConfigWriter.swift new file mode 100644 index 0000000..d7624c6 --- /dev/null +++ b/Packages/CrowCodex/Sources/CrowCodex/CodexHookConfigWriter.swift @@ -0,0 +1,219 @@ +import Foundation +import CrowCore + +/// Writes hook configuration that OpenAI Codex picks up. Codex reads hooks +/// from `$CODEX_HOME/hooks.json` (default `~/.codex/hooks.json`) regardless +/// of which directory `codex` is invoked from — global, not per-worktree. +/// +/// Because `HookConfigWriter`'s per-session API doesn't fit Codex's global +/// model, the per-session methods are intentionally no-ops. Real work happens +/// via the static `installGlobalConfig` / `installGlobalTomlConfig` calls +/// invoked once at app launch. +public struct CodexHookConfigWriter: HookConfigWriter { + + /// All hook event names Codex can dispatch (from + /// codex-rs/hooks/schema/generated/*.input.schema.json). + static let allEvents = [ + "SessionStart", + "PreToolUse", + "PostToolUse", + "UserPromptSubmit", + "Stop", + "PermissionRequest", + ] + + /// Post-execution events safe to run async (fire-and-forget). + private static let asyncEvents: Set = ["PostToolUse", "Stop"] + + public init() {} + + // MARK: - HookConfigWriter Conformance (no-ops) + + /// No-op. Codex hooks are global, not per-worktree — see + /// `installGlobalConfig`. + public func writeHookConfig(worktreePath: String, sessionID: UUID, crowPath: String) throws {} + + /// No-op. Codex's global `hooks.json` stays in place when individual + /// sessions are deleted; it serves all sessions. + public func removeHookConfig(worktreePath: String) {} + + // MARK: - Global Configuration + + /// Build the hooks dict in the schema Codex expects. Each event invokes + /// ` hook-event --agent codex --event ` with no `--session` + /// flag — the crow server resolves the session from `cwd` in the payload. + static func generateHooks(crowPath: String) -> [String: Any] { + var hooks: [String: Any] = [:] + for event in allEvents { + let command = "\(crowPath) hook-event --agent codex --event \(event)" + var entry: [String: Any] = [ + "type": "command", + "command": command, + "timeout": 5, + ] + if asyncEvents.contains(event) { + entry["async"] = true + } + hooks[event] = [ + ["hooks": [entry]] as [String: Any] + ] + } + return hooks + } + + /// Install or refresh `/hooks.json` with Crow's 6 hook + /// commands. Idempotent — re-running just rewrites the same content. + /// Preserves any user-authored entries for events Crow doesn't manage. + public static func installGlobalConfig(codexHome: String, crowPath: String) throws { + try FileManager.default.createDirectory(atPath: codexHome, withIntermediateDirectories: true) + let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") + + // Read existing hooks.json if present so user-authored entries for + // events outside `allEvents` survive. + var existing: [String: Any] = [:] + if let data = FileManager.default.contents(atPath: hooksPath), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + existing = parsed + } + var existingHooks = existing["hooks"] as? [String: Any] ?? [:] + let ours = generateHooks(crowPath: crowPath) + for (eventName, config) in ours { + existingHooks[eventName] = config + } + existing["hooks"] = existingHooks + + let data = try JSONSerialization.data( + withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys]) + try data.write(to: URL(fileURLWithPath: hooksPath)) + } + + /// Install or update `/config.toml` with the + /// `features.codex_hooks = true` flag and the Crow `notify` line. + /// Preserves any other user-authored config — minimal line-oriented merge + /// avoids pulling in a TOML dependency for two simple keys. + public static func installGlobalTomlConfig(codexHome: String, crowPath: String) throws { + try FileManager.default.createDirectory(atPath: codexHome, withIntermediateDirectories: true) + let tomlPath = (codexHome as NSString).appendingPathComponent("config.toml") + + var content: String = "" + if let data = FileManager.default.contents(atPath: tomlPath), + let text = String(data: data, encoding: .utf8) { + content = text + } + + let notifyLine = "notify = [\"\(escapeTomlString(crowPath))\", \"codex-notify\"]" + content = upsertTomlLine(content, key: "notify", line: notifyLine) + content = upsertTomlSectionLine( + content, + section: "features", + key: "codex_hooks", + line: "codex_hooks = true" + ) + + try content.write(toFile: tomlPath, atomically: true, encoding: .utf8) + } + + // MARK: - TOML Line Editing (Minimal) + + /// Replace or append a top-level (no section) `key = …` line in `content`. + static func upsertTomlLine(_ content: String, key: String, line: String) -> String { + var lines = content.components(separatedBy: "\n") + var inSection = false + for (i, raw) in lines.enumerated() { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + inSection = true + continue + } + if !inSection, lineKey(of: raw) == key { + lines[i] = line + return lines.joined(separator: "\n") + } + } + // Not found — append at the top (before any section header) for + // top-level keys, or at end if no headers. + if let firstSection = lines.firstIndex(where: { + let t = $0.trimmingCharacters(in: .whitespaces) + return t.hasPrefix("[") && t.hasSuffix("]") + }) { + lines.insert(line, at: firstSection) + // Add a separator newline if the previous line wasn't blank. + if firstSection > 0, + !lines[firstSection - 1].trimmingCharacters(in: .whitespaces).isEmpty { + lines.insert("", at: firstSection) + } + } else { + if !content.isEmpty && !content.hasSuffix("\n") { + lines.append("") + } + lines.append(line) + } + return lines.joined(separator: "\n") + } + + /// Replace or insert `key = …` inside `[section]`. Adds the section if + /// missing. + static func upsertTomlSectionLine( + _ content: String, + section: String, + key: String, + line: String + ) -> String { + var lines = content.components(separatedBy: "\n") + var sectionStart: Int? = nil + var sectionEnd: Int = lines.count + let sectionHeader = "[\(section)]" + for (i, raw) in lines.enumerated() { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed == sectionHeader { + sectionStart = i + continue + } + if let _ = sectionStart, trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + sectionEnd = i + break + } + } + + if let start = sectionStart { + // Search for existing key inside the section. + for i in (start + 1).. String? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { return nil } + guard let eq = trimmed.firstIndex(of: "=") else { return nil } + let key = trimmed[trimmed.startIndex.. String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Packages/CrowCodex/Sources/CrowCodex/CodexLauncher.swift b/Packages/CrowCodex/Sources/CrowCodex/CodexLauncher.swift new file mode 100644 index 0000000..e2e5f38 --- /dev/null +++ b/Packages/CrowCodex/Sources/CrowCodex/CodexLauncher.swift @@ -0,0 +1,77 @@ +import Foundation +import CrowCore + +/// Generates initial prompts for OpenAI Codex sessions. Mirrors the shape of +/// `ClaudeLauncher` but emits Codex-flavored content — no `/plan` slash +/// command, no `dangerouslyDisableSandbox` directives, plan-first preamble. +/// +/// Phase C MVP launches `codex` bare (the user types into the TUI), so this +/// type isn't wired into the auto-launch path yet. Phase D will use it for a +/// Codex-flavored `crow-workspace` skill. +public actor CodexLauncher { + public init() {} + + public func generatePrompt( + session: Session, + worktrees: [SessionWorktree], + ticketURL: String?, + provider: Provider? + ) -> String { + var lines: [String] = [] + lines.append("Before editing anything, sketch a brief plan covering:") + lines.append("- The files you'll touch and why") + lines.append("- Any migrations or cascading updates") + lines.append("- How you'll verify the change") + lines.append("Then proceed once the approach is clear.") + lines.append("") + lines.append("# Workspace Context") + lines.append("") + lines.append("| Repository | Path | Branch | Description |") + lines.append("|------------|------|--------|-------------|") + + for wt in worktrees { + lines.append("| \(wt.repoName) | \(wt.worktreePath) | \(wt.branch) | |") + } + + if let url = ticketURL { + lines.append("") + lines.append("## Ticket") + lines.append("") + + switch provider { + case .github: + lines.append("```bash") + lines.append("gh issue view \(url) --comments") + lines.append("```") + case .gitlab: + lines.append("```bash") + lines.append("glab issue view \(url) --comments") + lines.append("```") + case nil: + lines.append("URL: \(url)") + } + } + + lines.append("") + lines.append("## Instructions") + lines.append("1. Study the ticket thoroughly") + lines.append("2. Create an implementation plan") + + return lines.joined(separator: "\n") + } + + /// Write `prompt` to a temp file and return the launch command. + public func launchCommand(sessionID: UUID, worktreePath: String, prompt: String) throws -> String { + let tmpDir = FileManager.default.temporaryDirectory + let promptPath = tmpDir.appendingPathComponent("crow-codex-\(sessionID.uuidString)-prompt.md") + try prompt.write(to: promptPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: promptPath.path) + return "cd \(Self.shellEscape(worktreePath)) && codex \"$(cat \(Self.shellEscape(promptPath.path)))\"\n" + } + + private static func shellEscape(_ str: String) -> String { + let escaped = str.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" + } +} diff --git a/Packages/CrowCodex/Sources/CrowCodex/CodexNotifyPayload.swift b/Packages/CrowCodex/Sources/CrowCodex/CodexNotifyPayload.swift new file mode 100644 index 0000000..e0ded48 --- /dev/null +++ b/Packages/CrowCodex/Sources/CrowCodex/CodexNotifyPayload.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Translates OpenAI Codex's notify-command JSON payload into a hook-event +/// shape the rest of Crow can consume. Codex invokes the configured `notify` +/// command with the JSON payload as the final positional argument; the +/// payload uses kebab-case keys (`agent-turn-complete`, `thread-id`, +/// `turn-id`, `cwd`, `last-assistant-message`). +/// +/// Returns a `(eventName, payload)` pair as plain strings — `CrowCodex` has +/// no dependency on `CrowIPC`'s `JSONValue` type, so the CLI converts the +/// `[String: String]` payload into the JSON-RPC shape at the boundary. +public enum CodexNotifyPayload { + public struct Translation: Sendable, Equatable { + public let eventName: String + public let payload: [String: String] + } + + /// Translate a Codex notify JSON string. The mapping is intentionally + /// minimal for MVP — the notify path is a Tier-2 fallback, so we only + /// need to recognize `agent-turn-complete` to drive the `.done` + /// transition. Anything else falls through as a generic `Notification` + /// event (no state change after the blanket clear). + public static func translate(_ json: String) -> Translation { + let data = json.data(using: .utf8) ?? Data() + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + // Unparseable — surface the raw text for debugging visibility. + return Translation( + eventName: "Notification", + payload: ["message": json] + ) + } + + // Forward all string-valued top-level keys, normalizing kebab-case + // to snake_case so the signal source / RPC handler doesn't need a + // per-agent switch on key style. + var payload: [String: String] = [:] + for (key, value) in root { + guard let s = stringify(value) else { continue } + payload[normalizeKey(key)] = s + } + + let type = (root["type"] as? String) ?? "" + switch type { + case "agent-turn-complete": + return Translation(eventName: "Stop", payload: payload) + default: + return Translation(eventName: "Notification", payload: payload) + } + } + + /// Normalize Codex's kebab-case keys to the snake_case the rest of the + /// pipeline expects (e.g. `cwd` stays `cwd`, `turn-id` becomes + /// `turn_id`, `last-assistant-message` becomes `last_assistant_message`). + private static func normalizeKey(_ key: String) -> String { + key.replacingOccurrences(of: "-", with: "_") + } + + /// Best-effort string extraction so callers can pass the payload through + /// to a string-keyed dict. Numbers and booleans get stringified; nested + /// objects/arrays are dropped (we don't currently need them). + private static func stringify(_ value: Any) -> String? { + switch value { + case let s as String: + return s + case let i as Int: + return String(i) + case let d as Double: + return String(d) + case let b as Bool: + return String(b) + default: + return nil + } + } +} diff --git a/Packages/CrowCodex/Sources/CrowCodex/CodexScaffolder.swift b/Packages/CrowCodex/Sources/CrowCodex/CodexScaffolder.swift new file mode 100644 index 0000000..366f4ed --- /dev/null +++ b/Packages/CrowCodex/Sources/CrowCodex/CodexScaffolder.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Writes Codex-specific files into `{devRoot}` so the agent has the +/// context it expects. Today this means just `AGENTS.md` (Codex's analog of +/// `CLAUDE.md`). Phase D may add `.agents/skills/` and per-skill files. +public enum CodexScaffolder { + /// Idempotent. Re-running preserves any user-authored "Known Issues / + /// Corrections" section the same way `Scaffolder` does for `CLAUDE.md`. + public static func scaffold(devRoot: String) throws { + let agentsPath = (devRoot as NSString).appendingPathComponent("AGENTS.md") + let template = bundledAgentsMD() + + let fm = FileManager.default + let userCorrectionsMarker = "## Known Issues / Corrections" + + if fm.fileExists(atPath: agentsPath), + let existing = try? String(contentsOfFile: agentsPath, encoding: .utf8), + let markerRange = existing.range(of: userCorrectionsMarker) { + // Preserve the user-edited section below the marker. + let userCorrections = String(existing[markerRange.lowerBound...]) + let templateBase: String + if let templateMarker = template.range(of: userCorrectionsMarker) { + templateBase = String(template[.. String { + if let content = loadFromRepo("Resources/AGENTS.md.template") { + return content + } + if let url = Bundle.main.url(forResource: "AGENTS.md", withExtension: "template"), + let content = try? String(contentsOf: url) { + return content + } + return """ + # Crow — Codex Workspace Context + + You are operating inside a Crow-managed development root. Sessions + live in worktrees under workspace folders here. Use the `crow` CLI + for session, worktree, and metadata operations. + + See `crow --help` for the CLI reference. + + ## Known Issues / Corrections + """ + } + + private static func loadFromRepo(_ relativePath: String) -> String? { + let execURL = URL(fileURLWithPath: ProcessInfo.processInfo.arguments[0]) + var dir = execURL.deletingLastPathComponent() + for _ in 0..<10 { + if FileManager.default.fileExists(atPath: dir.appendingPathComponent("Package.swift").path) { + let filePath = dir.appendingPathComponent(relativePath) + if let content = try? String(contentsOf: filePath) { + return content + } + return nil + } + dir = dir.deletingLastPathComponent() + } + return nil + } +} diff --git a/Packages/CrowCodex/Sources/CrowCodex/CodexSignalSource.swift b/Packages/CrowCodex/Sources/CrowCodex/CodexSignalSource.swift new file mode 100644 index 0000000..38908b0 --- /dev/null +++ b/Packages/CrowCodex/Sources/CrowCodex/CodexSignalSource.swift @@ -0,0 +1,87 @@ +import Foundation +import CrowCore + +/// Translates OpenAI Codex hook events into `AgentStateTransition` values. +/// Codex uses Claude Code's hook engine internally (verified empirically +/// against `codex` 0.123.0 — `ClaudeHooksEngine` is referenced in the binary +/// and the input schemas are byte-compatible), so the payload keys are the +/// same and the state transitions mirror the Claude side for the events +/// Codex emits. +/// +/// Codex emits 6 hook events: `SessionStart`, `PreToolUse`, `PostToolUse`, +/// `UserPromptSubmit`, `Stop`, `PermissionRequest`. No `Notification`, +/// `SubagentStart`, `TaskCreated`, `TaskCompleted`, or other Claude-only +/// events. +/// +/// `currentLastTopLevelStopAt` is tracked for protocol consistency (Stop +/// sets it; UserPromptSubmit / SessionStart clear it) but isn't read by any +/// transition — Codex doesn't have an analog of Claude's +/// `awaySummaryEnabled` recap subagent that would fire post-Stop. +public struct CodexSignalSource: StateSignalSource { + public init() {} + + public func transition( + for event: AgentHookEvent, + currentActivityState: AgentActivityState, + currentNotificationType: String?, + currentLastTopLevelStopAt: Date? + ) -> AgentStateTransition { + // Same blanket-clear policy as Claude: every event except + // `PermissionRequest` clears the pending notification (Codex doesn't + // emit `Notification` events, so it's not in the exclusion list). + let blanketClear = event.eventName != "PermissionRequest" + var transition = AgentStateTransition( + notification: blanketClear ? .clear : .leave + ) + + switch event.eventName { + case "SessionStart": + let source = event.source ?? "startup" + transition.newActivityState = source == "resume" ? .done : .idle + transition.lastTopLevelStopAt = .clear + + case "PreToolUse": + let toolName = event.toolName ?? "unknown" + 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 "UserPromptSubmit": + transition.newActivityState = .working + transition.lastTopLevelStopAt = .clear + + case "Stop": + transition.newActivityState = .done + transition.toolActivity = .clear + transition.lastTopLevelStopAt = .set(Date()) + + case "PermissionRequest": + // Same precedence rule as Claude — don't override a prior + // "question" notification (Codex doesn't actually emit AskUserQuestion, + // so this is mostly defensive, kept for cross-agent parity). + if currentNotificationType != "question" { + transition.notification = .set(HookNotification( + message: "Permission requested", + notificationType: "permission_prompt" + )) + } + transition.newActivityState = .waiting + transition.toolActivity = .clear + + default: + // Unknown events get the blanket notification clear and nothing + // else — Codex's event vocabulary may grow over time without + // requiring code changes for events that don't change state. + break + } + + return transition + } +} diff --git a/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift b/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift new file mode 100644 index 0000000..0e4b483 --- /dev/null +++ b/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift @@ -0,0 +1,90 @@ +import Foundation +import CrowCore + +/// `CodingAgent` conformer for the OpenAI Codex CLI. Mirrors the shape of +/// `ClaudeCodeAgent` while honoring Codex's quirks — global `~/.codex/` +/// configuration, no `--rc` remote-control support, no `--continue`-style +/// resume in MVP. +public struct OpenAICodexAgent: CodingAgent { + public let kind: AgentKind = .codex + public let displayName: String = "OpenAI Codex" + /// Visually distinct from Claude's `"sparkles"`. Easy to swap once + /// branding firms up. + public let iconSystemName: String = "terminal.fill" + public let supportsRemoteControl: Bool = false + public let launchCommandToken: String = "codex" + public let hookConfigWriter: any HookConfigWriter + public let stateSignalSource: any StateSignalSource + + private let launcher: CodexLauncher + + /// Standard search paths for the `codex` binary, in priority order. + /// Homebrew-cask installs Codex at the first path on macOS. + static let codexBinaryCandidates: [String] = [ + "/opt/homebrew/bin/codex", + "/usr/local/bin/codex", + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/bin/codex").path, + ] + + public init( + hookConfigWriter: any HookConfigWriter = CodexHookConfigWriter(), + stateSignalSource: any StateSignalSource = CodexSignalSource() + ) { + self.hookConfigWriter = hookConfigWriter + self.stateSignalSource = stateSignalSource + self.launcher = CodexLauncher() + } + + public func findBinary() -> String? { + for path in Self.codexBinaryCandidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } + + public func autoLaunchCommand( + session: Session, + worktreePath: String, + remoteControlEnabled: Bool, + telemetryPort: UInt16? + ) -> String? { + // Review-on-Codex isn't supported in Phase C — the review skill is + // Claude-only. Returning nil tells `SessionService.launchAgent` to + // log and skip rather than producing a malformed command. + guard session.kind == .work else { return nil } + + // Bare `codex` launch — the user types their prompt into the TUI. + // No env prefix (Codex has no OTEL equivalent), no `--continue` + // (MVP doesn't auto-resume), no `--rc` (Codex doesn't do remote + // control). The terminal's cwd is already the worktree path. + return "codex\n" + } + + 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/Packages/CrowCodex/Tests/CrowCodexTests/CodexHookConfigWriterTests.swift b/Packages/CrowCodex/Tests/CrowCodexTests/CodexHookConfigWriterTests.swift new file mode 100644 index 0000000..6bdcd56 --- /dev/null +++ b/Packages/CrowCodex/Tests/CrowCodexTests/CodexHookConfigWriterTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import CrowCodex +@testable import CrowCore + +@Suite("CodexHookConfigWriter") +struct CodexHookConfigWriterTests { + private func makeTempCodexHome() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func writeHookConfigIsNoOp() throws { + // Per-session writes are no-ops — Codex hooks are global. + let writer = CodexHookConfigWriter() + let tmp = try makeTempCodexHome() + defer { try? FileManager.default.removeItem(at: tmp) } + try writer.writeHookConfig( + worktreePath: tmp.path, + sessionID: UUID(), + crowPath: "/usr/local/bin/crow" + ) + // No file should have been created in the worktree. + let files = try FileManager.default.contentsOfDirectory(atPath: tmp.path) + #expect(files.isEmpty) + } + + @Test func installGlobalConfigWritesAllSixEvents() throws { + let codexHome = try makeTempCodexHome() + defer { try? FileManager.default.removeItem(at: codexHome) } + try CodexHookConfigWriter.installGlobalConfig( + codexHome: codexHome.path, + crowPath: "/opt/homebrew/bin/crow" + ) + + let hooksPath = codexHome.appendingPathComponent("hooks.json") + let data = try Data(contentsOf: hooksPath) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + + #expect(hooks.count == 6) + for event in ["SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop", "PermissionRequest"] { + #expect(hooks[event] != nil, "missing hook entry for \(event)") + } + + // Spot-check the command shape. + let entries = hooks["PreToolUse"] as! [[String: Any]] + let inner = entries.first!["hooks"] as! [[String: Any]] + let command = inner.first!["command"] as! String + #expect(command == "/opt/homebrew/bin/crow hook-event --agent codex --event PreToolUse") + } + + @Test func installGlobalConfigPreservesUserEntries() throws { + let codexHome = try makeTempCodexHome() + defer { try? FileManager.default.removeItem(at: codexHome) } + + // Pre-seed a user-managed hook for a non-Crow event. + let hooksPath = codexHome.appendingPathComponent("hooks.json") + let preExisting: [String: Any] = [ + "hooks": [ + "CustomUserEvent": [ + ["hooks": [["type": "command", "command": "/usr/local/bin/my-tool"]]] + ] + ] + ] + let data = try JSONSerialization.data(withJSONObject: preExisting) + try data.write(to: hooksPath) + + try CodexHookConfigWriter.installGlobalConfig( + codexHome: codexHome.path, + crowPath: "/usr/local/bin/crow" + ) + + let after = try Data(contentsOf: hooksPath) + let json = try JSONSerialization.jsonObject(with: after) as! [String: Any] + let hooks = json["hooks"] as! [String: Any] + #expect(hooks["CustomUserEvent"] != nil, "user-managed hook entry should be preserved") + #expect(hooks["Stop"] != nil, "Crow's Stop hook should still be installed") + } + + @Test func installGlobalConfigIsIdempotent() throws { + let codexHome = try makeTempCodexHome() + defer { try? FileManager.default.removeItem(at: codexHome) } + try CodexHookConfigWriter.installGlobalConfig(codexHome: codexHome.path, crowPath: "/bin/crow") + let first = try Data(contentsOf: codexHome.appendingPathComponent("hooks.json")) + try CodexHookConfigWriter.installGlobalConfig(codexHome: codexHome.path, crowPath: "/bin/crow") + let second = try Data(contentsOf: codexHome.appendingPathComponent("hooks.json")) + #expect(first == second) + } + + // MARK: - TOML config + + @Test func installGlobalTomlConfigCreatesFreshFile() throws { + let codexHome = try makeTempCodexHome() + defer { try? FileManager.default.removeItem(at: codexHome) } + try CodexHookConfigWriter.installGlobalTomlConfig( + codexHome: codexHome.path, + crowPath: "/opt/homebrew/bin/crow" + ) + let toml = try String(contentsOf: codexHome.appendingPathComponent("config.toml")) + #expect(toml.contains("notify = [\"/opt/homebrew/bin/crow\", \"codex-notify\"]")) + #expect(toml.contains("[features]")) + #expect(toml.contains("codex_hooks = true")) + } + + @Test func installGlobalTomlConfigPreservesUserSettings() throws { + let codexHome = try makeTempCodexHome() + defer { try? FileManager.default.removeItem(at: codexHome) } + + let preExisting = """ + # User config + model = "gpt-4o" + + [features] + memories = true + """ + try preExisting.write( + toFile: codexHome.appendingPathComponent("config.toml").path, + atomically: true, encoding: .utf8 + ) + + try CodexHookConfigWriter.installGlobalTomlConfig( + codexHome: codexHome.path, + crowPath: "/usr/local/bin/crow" + ) + + let toml = try String(contentsOf: codexHome.appendingPathComponent("config.toml")) + // User entries preserved. + #expect(toml.contains("model = \"gpt-4o\"")) + #expect(toml.contains("memories = true")) + // Crow entries added. + #expect(toml.contains("notify = ")) + #expect(toml.contains("codex_hooks = true")) + } +} diff --git a/Packages/CrowCodex/Tests/CrowCodexTests/CodexNotifyPayloadTests.swift b/Packages/CrowCodex/Tests/CrowCodexTests/CodexNotifyPayloadTests.swift new file mode 100644 index 0000000..007c7fe --- /dev/null +++ b/Packages/CrowCodex/Tests/CrowCodexTests/CodexNotifyPayloadTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CrowCodex + +@Suite("CodexNotifyPayload") +struct CodexNotifyPayloadTests { + @Test func agentTurnCompleteMapsToStop() { + let json = """ + {"type":"agent-turn-complete","cwd":"/Users/x/Dev/repo","turn-id":"abc","last-assistant-message":"done"} + """ + let result = CodexNotifyPayload.translate(json) + #expect(result.eventName == "Stop") + #expect(result.payload["cwd"] == "/Users/x/Dev/repo") + // kebab-case keys are normalized to snake_case so the rest of the + // pipeline doesn't need a per-agent switch. + #expect(result.payload["turn_id"] == "abc") + #expect(result.payload["last_assistant_message"] == "done") + } + + @Test func unknownTypeMapsToNotification() { + let json = """ + {"type":"some-future-event","detail":"hello"} + """ + let result = CodexNotifyPayload.translate(json) + #expect(result.eventName == "Notification") + #expect(result.payload["type"] == "some-future-event") + } + + @Test func unparseableJSONFallsBackToNotification() { + let result = CodexNotifyPayload.translate("not even close to json") + #expect(result.eventName == "Notification") + #expect(result.payload["message"] == "not even close to json") + } + + @Test func missingTypeMapsToNotification() { + let result = CodexNotifyPayload.translate("{\"cwd\":\"/x\"}") + #expect(result.eventName == "Notification") + #expect(result.payload["cwd"] == "/x") + } +} diff --git a/Packages/CrowCodex/Tests/CrowCodexTests/CodexScaffolderTests.swift b/Packages/CrowCodex/Tests/CrowCodexTests/CodexScaffolderTests.swift new file mode 100644 index 0000000..246a58c --- /dev/null +++ b/Packages/CrowCodex/Tests/CrowCodexTests/CodexScaffolderTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CrowCodex + +@Suite("CodexScaffolder") +struct CodexScaffolderTests { + private func makeTempDevRoot() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-scaffold-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func scaffoldWritesAgentsMD() throws { + let devRoot = try makeTempDevRoot() + defer { try? FileManager.default.removeItem(at: devRoot) } + + try CodexScaffolder.scaffold(devRoot: devRoot.path) + + let agents = try String( + contentsOf: devRoot.appendingPathComponent("AGENTS.md"), + encoding: .utf8 + ) + // The fallback bundled template (or the resource) must contain + // some Crow-specific marker. + #expect(agents.contains("Crow")) + #expect(agents.contains("Known Issues / Corrections")) + } + + @Test func scaffoldPreservesUserCorrections() throws { + let devRoot = try makeTempDevRoot() + defer { try? FileManager.default.removeItem(at: devRoot) } + + // First pass: write the template. + try CodexScaffolder.scaffold(devRoot: devRoot.path) + + // Append a user-authored Known Issues entry. + let agentsPath = devRoot.appendingPathComponent("AGENTS.md").path + let template = try String(contentsOfFile: agentsPath, encoding: .utf8) + let edited = template.replacingOccurrences( + of: "## Known Issues / Corrections", + with: "## Known Issues / Corrections\n\n- Use `crow new-session --agent codex` for Codex sessions." + ) + try edited.write(toFile: agentsPath, atomically: true, encoding: .utf8) + + // Second pass: should preserve the edits. + try CodexScaffolder.scaffold(devRoot: devRoot.path) + + let after = try String(contentsOfFile: agentsPath, encoding: .utf8) + #expect(after.contains("Use `crow new-session --agent codex`")) + } +} diff --git a/Packages/CrowCodex/Tests/CrowCodexTests/CodexSignalSourceTests.swift b/Packages/CrowCodex/Tests/CrowCodexTests/CodexSignalSourceTests.swift new file mode 100644 index 0000000..8b6d881 --- /dev/null +++ b/Packages/CrowCodex/Tests/CrowCodexTests/CodexSignalSourceTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing +@testable import CrowCodex +@testable import CrowCore + +@Suite("CodexSignalSource") +struct CodexSignalSourceTests { + private let source = CodexSignalSource() + + private func event( + _ name: String, + toolName: String? = nil, + source: String? = nil + ) -> AgentHookEvent { + AgentHookEvent( + sessionID: UUID(), + eventName: name, + toolName: toolName, + source: source, + summary: name + ) + } + + // MARK: - The 6 events Codex emits + + @Test func sessionStartFreshIdle() { + let t = source.transition( + for: event("SessionStart", source: "startup"), + currentActivityState: .done, + currentNotificationType: nil, + currentLastTopLevelStopAt: Date() + ) + #expect(t.newActivityState == .idle) + if case .clear = t.lastTopLevelStopAt {} else { + Issue.record("SessionStart should clear lastTopLevelStopAt") + } + } + + @Test func sessionStartResumeMarksDone() { + let t = source.transition( + for: event("SessionStart", source: "resume"), + currentActivityState: .idle, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + } + + @Test func preToolUseSetsWorking() { + let t = source.transition( + for: event("PreToolUse", toolName: "Bash"), + currentActivityState: .idle, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .working) + if case .set(let activity) = t.toolActivity { + #expect(activity.toolName == "Bash") + #expect(activity.isActive == true) + } else { + Issue.record("expected tool activity set active") + } + } + + @Test func postToolUseMarksInactive() { + let t = source.transition( + for: event("PostToolUse", toolName: "Bash"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == nil) + if case .set(let activity) = t.toolActivity { + #expect(activity.isActive == false) + } else { + Issue.record("expected inactive tool activity") + } + } + + @Test func userPromptSubmitClearsLastStopAt() { + let t = source.transition( + for: event("UserPromptSubmit"), + currentActivityState: .done, + currentNotificationType: nil, + currentLastTopLevelStopAt: Date() + ) + #expect(t.newActivityState == .working) + if case .clear = t.lastTopLevelStopAt {} else { + Issue.record("UserPromptSubmit should clear lastTopLevelStopAt") + } + } + + @Test func stopSetsLastStopAt() { + let t = source.transition( + for: event("Stop"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .done) + if case .clear = t.toolActivity {} else { + Issue.record("Stop should clear tool activity") + } + if case .set = t.lastTopLevelStopAt {} else { + Issue.record("Stop should set lastTopLevelStopAt") + } + } + + @Test func permissionRequestWaits() { + let t = source.transition( + for: event("PermissionRequest"), + currentActivityState: .working, + currentNotificationType: nil, + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == .waiting) + if case .set(let n) = t.notification { + #expect(n.notificationType == "permission_prompt") + } else { + Issue.record("expected permission_prompt notification") + } + if case .clear = t.toolActivity {} else { + Issue.record("expected toolActivity cleared") + } + } + + @Test func permissionRequestPreservesQuestionNotification() { + let t = source.transition( + for: event("PermissionRequest"), + currentActivityState: .waiting, + currentNotificationType: "question", + currentLastTopLevelStopAt: nil + ) + if case .leave = t.notification {} else { + Issue.record("question notification should not be overridden") + } + } + + // MARK: - Blanket clear + + @Test func nonPermissionRequestClearsPendingNotification() { + let t = source.transition( + for: event("PreToolUse", toolName: "Bash"), + currentActivityState: .waiting, + currentNotificationType: "permission_prompt", + currentLastTopLevelStopAt: nil + ) + if case .clear = t.notification {} else { + Issue.record("non-PermissionRequest events should clear pending notification") + } + } + + @Test func unknownEventAppliesBlanketClearOnly() { + let t = source.transition( + for: event("FuturisticUnknownEvent"), + currentActivityState: .working, + currentNotificationType: "permission_prompt", + currentLastTopLevelStopAt: nil + ) + #expect(t.newActivityState == nil) + if case .clear = t.notification {} else { + Issue.record("unknown events should still clear pending notification") + } + } +} diff --git a/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift b/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift new file mode 100644 index 0000000..d8aba01 --- /dev/null +++ b/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import CrowCodex +@testable import CrowCore + +@Suite("OpenAICodexAgent") +struct OpenAICodexAgentTests { + private let agent = OpenAICodexAgent() + + @Test func protocolMembers() { + #expect(agent.kind == .codex) + #expect(agent.displayName == "OpenAI Codex") + #expect(agent.iconSystemName == "terminal.fill") + #expect(agent.supportsRemoteControl == false) + #expect(agent.launchCommandToken == "codex") + } + + @Test func autoLaunchCommandWorkSession() { + let session = Session(name: "test", agentKind: .codex) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + telemetryPort: nil + ) + #expect(cmd == "codex\n") + } + + @Test func autoLaunchCommandIgnoresTelemetryAndRemoteControl() { + // Codex has no OTEL exporter and doesn't honor --rc — toggling these + // shouldn't change the launch text. + let session = Session(name: "test", agentKind: .codex) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: true, + telemetryPort: 4318 + ) + #expect(cmd == "codex\n") + } + + @Test func autoLaunchCommandReviewSessionUnsupported() { + let session = Session(name: "review", kind: .review, agentKind: .codex) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + telemetryPort: nil + ) + #expect(cmd == nil) // Codex review sessions aren't supported in MVP. + } + + @Test func findBinaryReturnsNilWhenAbsent() { + // We can't easily mock FileManager.isExecutableFile, but we CAN + // verify the search returns nil when the candidate paths don't + // resolve. This relies on the test environment not having a + // codex binary at the homedir candidate path — the homebrew path + // may or may not exist depending on the developer machine, so we + // accept either outcome and just verify the result type. + _ = agent.findBinary() // smoke test: must not crash + } +} 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..4132a69 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentKind.swift @@ -0,0 +1,19 @@ +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") + + /// The OpenAI Codex agent. + public static let codex = AgentKind(rawValue: "codex") +} 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..ed5f765 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/AgentStateTransition.swift @@ -0,0 +1,51 @@ +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) + } + + public enum LastTopLevelStopAtUpdate: Sendable { + case leave + case clear + case set(Date) + } + + /// 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 + + /// Whether/how to mutate `SessionHookState.lastTopLevelStopAt` — used to + /// suppress activity-state elevation from background subagent work that + /// fires after the user's turn has already ended. + public var lastTopLevelStopAt: LastTopLevelStopAtUpdate + + public init( + newActivityState: AgentActivityState? = nil, + notification: NotificationUpdate = .leave, + toolActivity: ToolActivityUpdate = .leave, + lastTopLevelStopAt: LastTopLevelStopAtUpdate = .leave + ) { + self.newActivityState = newActivityState + self.notification = notification + self.toolActivity = toolActivity + self.lastTopLevelStopAt = lastTopLevelStopAt + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift b/Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift new file mode 100644 index 0000000..cf74375 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/CodingAgent.swift @@ -0,0 +1,69 @@ +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 } + + /// Whether this agent supports Crow's "remote control" feature (the + /// `--rc --name` flags Claude Code uses to register a session in + /// claude.ai's Remote Control panel). Drives whether the + /// `RemoteControlBadge` is shown for this agent's sessions. + var supportsRemoteControl: Bool { get } + + /// The shell token that identifies a command as launching this agent. + /// Used by the `send` RPC handler to decide whether a managed-terminal + /// command needs hook-config + env-var prep before being forwarded. + /// Examples: `"claude"`, `"codex"`. + var launchCommandToken: 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 } + + /// Resolve this agent's binary on disk, or return `nil` if it isn't + /// installed. Drives binary-presence gating for the per-session picker + /// and the launch-command builder below. + func findBinary() -> String? + + /// Build the full shell command (ending with `\n`) that auto-launches + /// this agent in `worktreePath`. Returns `nil` when the agent can't be + /// launched — typically because the binary is missing or the session + /// kind is unsupported. + func autoLaunchCommand( + session: Session, + worktreePath: String, + remoteControlEnabled: Bool, + telemetryPort: UInt16? + ) -> String? + + /// 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..6c02521 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Agent/StateSignalSource.swift @@ -0,0 +1,17 @@ +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?, + currentLastTopLevelStopAt: Date? + ) -> AgentStateTransition +} diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index fdafa66..ed2d220 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -43,6 +43,11 @@ public final class AppState { /// launch; worker sessions and CLI-spawned terminals are unaffected. public var managerAutoPermissionMode: Bool = true + /// 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. @@ -202,8 +207,8 @@ public final class AppState { /// Called when user clicks "Start Review" for multiple selected PR review requests (batch mode). public var onBatchStartReview: (([String]) -> Void)? // receives array of PR URLs - /// 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 when the user clicks "Retry" on a failed terminal surface. public var onRetryTerminal: ((UUID) -> Void)? // receives terminal ID @@ -289,6 +294,21 @@ public final class AppState { worktrees[sessionID] ?? [] } + /// Resolve a session UUID by matching against the worktree path of every + /// known session. Returns the first match, or `nil` if no session has a + /// worktree at the given path. Used by the hook-event RPC handler when + /// the agent (e.g. Codex) doesn't carry the session UUID in its hook + /// invocation — the `cwd` field of the payload is matched against + /// worktree paths to recover the session. + public func sessionID(forWorktreePath path: String) -> UUID? { + for (sessionID, wts) in worktrees { + if wts.contains(where: { $0.worktreePath == path }) { + return sessionID + } + } + return nil + } + public func links(for sessionID: UUID) -> [SessionLink] { links[sessionID] ?? [] } @@ -403,13 +423,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 e49aa0e..e96abaf 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -17,6 +17,9 @@ public struct AppConfig: Codable, Sendable, Equatable { /// Opt into the tmux backend (#198). Surfaced in Settings → Experimental. /// Read once at app launch; takes effect on next relaunch. public var experimentalTmuxBackend: Bool + /// 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] = [], @@ -27,7 +30,8 @@ public struct AppConfig: Codable, Sendable, Equatable { managerAutoPermissionMode: Bool = true, telemetry: TelemetryConfig = TelemetryConfig(), autoRespond: AutoRespondSettings = AutoRespondSettings(), - experimentalTmuxBackend: Bool = false + experimentalTmuxBackend: Bool = false, + defaultAgentKind: AgentKind = .claudeCode ) { self.workspaces = workspaces self.defaults = defaults @@ -38,6 +42,7 @@ public struct AppConfig: Codable, Sendable, Equatable { self.telemetry = telemetry self.autoRespond = autoRespond self.experimentalTmuxBackend = experimentalTmuxBackend + self.defaultAgentKind = defaultAgentKind } public init(from decoder: Decoder) throws { @@ -51,10 +56,11 @@ public struct AppConfig: Codable, Sendable, Equatable { telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig() autoRespond = try container.decodeIfPresent(AutoRespondSettings.self, forKey: .autoRespond) ?? AutoRespondSettings() experimentalTmuxBackend = try container.decodeIfPresent(Bool.self, forKey: .experimentalTmuxBackend) ?? false + defaultAgentKind = try container.decodeIfPresent(AgentKind.self, forKey: .defaultAgentKind) ?? .claudeCode } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond, experimentalTmuxBackend + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, managerAutoPermissionMode, telemetry, autoRespond, experimentalTmuxBackend, defaultAgentKind } } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift b/Packages/CrowCore/Sources/CrowCore/Models/Enums.swift index a3e3dbb..a83e953 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 @@ -43,7 +43,7 @@ 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 { @@ -51,7 +51,7 @@ public enum TerminalReadiness: String, Codable, Sendable, Comparable { 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 dde6a44..40b9644 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? @@ -22,6 +23,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, @@ -34,6 +36,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 @@ -43,16 +46,17 @@ public struct Session: Identifiable, Codable, Sendable { self.reviewPromptDispatched = reviewPromptDispatched } - // Backward-compatible decoding: default `kind` to `.work` when missing - // from older persisted data. `reviewPromptDispatched` defaults to `true` - // when missing so existing review sessions don't re-trigger their prompt - // on the first launch after upgrade (CROW-224). + // Backward-compatible decoding: default `kind`, `agentKind`, and + // `reviewPromptDispatched` when missing from older persisted data. + // `reviewPromptDispatched` defaults to `true` so existing review sessions + // don't re-trigger their prompt on first launch after upgrade (CROW-224). 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/AppStateSessionLookupTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppStateSessionLookupTests.swift new file mode 100644 index 0000000..641b000 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppStateSessionLookupTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CrowCore + +@MainActor +@Test func sessionIDForWorktreePathReturnsMatchingSession() { + let appState = AppState() + let sessionA = UUID() + let sessionB = UUID() + appState.worktrees[sessionA] = [ + SessionWorktree( + sessionID: sessionA, repoName: "alpha", + repoPath: "/repos/alpha", worktreePath: "/wt/alpha", + branch: "main" + ), + ] + appState.worktrees[sessionB] = [ + SessionWorktree( + sessionID: sessionB, repoName: "beta", + repoPath: "/repos/beta", worktreePath: "/wt/beta", + branch: "main" + ), + ] + + #expect(appState.sessionID(forWorktreePath: "/wt/alpha") == sessionA) + #expect(appState.sessionID(forWorktreePath: "/wt/beta") == sessionB) +} + +@MainActor +@Test func sessionIDForUnknownWorktreePathReturnsNil() { + let appState = AppState() + appState.worktrees[UUID()] = [ + SessionWorktree( + sessionID: UUID(), repoName: "foo", + repoPath: "/r", worktreePath: "/wt/foo", + branch: "main" + ), + ] + #expect(appState.sessionID(forWorktreePath: "/wt/does-not-exist") == nil) +} 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 b280fe0..55e936c 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -48,7 +48,8 @@ public struct SessionDetailView: View { Text(session.name) .font(.system(size: 18, weight: .bold)) .foregroundStyle(CorveilTheme.gold) - if appState.isRemoteControlActive(sessionID: session.id) { + if AgentRegistry.shared.agent(for: session.agentKind)?.supportsRemoteControl == true, + appState.isRemoteControlActive(sessionID: session.id) { RemoteControlBadge() } } @@ -87,6 +88,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) @@ -510,13 +526,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 { @@ -565,8 +581,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 d9bb4db..b1c0749 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionListView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionListView.swift @@ -443,8 +443,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. @@ -461,6 +461,10 @@ struct SessionRow: View { appState.terminals(for: session.id).contains { $0.backend == .tmux } } + private var agent: (any CodingAgent)? { + AgentRegistry.shared.agent(for: session.agentKind) + } + var body: some View { HStack(spacing: 8) { if isSelectionMode { @@ -513,11 +517,18 @@ struct SessionRow: 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) .lineLimit(1) - if appState.isRemoteControlActive(sessionID: session.id) { + if agent?.supportsRemoteControl == true, + appState.isRemoteControlActive(sessionID: session.id) { RemoteControlBadge(compact: true) } Spacer() @@ -542,7 +553,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 { @@ -551,8 +562,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 } } } @@ -585,7 +596,7 @@ struct SessionRow: View { .fill(.blue) .frame(width: 8, height: 8) .accessibilityLabel("Shell ready") - case .claudeLaunched: + case .agentLaunched: if needsAttention { Circle() .fill(.orange) @@ -596,7 +607,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) @@ -638,7 +649,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 @@ -662,7 +673,7 @@ struct SessionRow: View { .foregroundStyle(.orange) } } else { - switch claudeState { + switch activityState { case .working: HStack(spacing: 3) { Image(systemName: "bolt.fill") @@ -697,7 +708,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 ad3e76f..054e41c 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -147,6 +147,20 @@ public struct SettingsView: View { .font(.caption) } + Section("Defaults") { + 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) + Text("Selected agent runs new sessions. Disabled until a second agent (e.g., Codex) is registered.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Sidebar") { Toggle("Hide session details", isOn: $config.sidebar.hideSessionDetails) .onChange(of: config.sidebar.hideSessionDetails) { _, _ in save() } diff --git a/Resources/AGENTS.md.template b/Resources/AGENTS.md.template new file mode 100644 index 0000000..368cc87 --- /dev/null +++ b/Resources/AGENTS.md.template @@ -0,0 +1,56 @@ +# Crow — Codex Workspace Context + +You are operating inside a Crow-managed development root. Crow orchestrates +sessions in worktrees under workspace folders here, and provides a CLI for +session, worktree, and metadata operations. + +## Crow CLI Reference + +The `crow` CLI talks to the Crow app over a Unix socket. The app must be +running. Commands return JSON on stdout. + +### Sessions +``` +crow new-session --name "feature-name" [--agent codex] +crow rename-session --session "new-name" +crow select-session --session +crow list-sessions +crow get-session --session +crow set-status --session active|paused|inReview|completed|archived +crow delete-session --session +``` + +### Worktrees +``` +crow add-worktree --session --repo "name" --repo-path "/main/repo" --path "/worktree/path" --branch "feature/..." [--primary] +crow list-worktrees --session +``` + +### Terminals +``` +crow new-terminal --session --cwd "/path" [--name "..."] [--command "..."] [--managed] +crow list-terminals --session +crow close-terminal --session --terminal +crow rename-terminal --session --terminal "new name" +crow send --session --terminal "text to send" +``` + +### Metadata +``` +crow set-ticket --session --url "..." [--title "..."] [--number N] +crow add-link --session --label "..." --url "..." --type ticket|pr|repo|custom +crow list-links --session +``` + +## Important Notes + +- `--session` always expects a full UUID, not a session name. +- Use `$TMPDIR` (not `/tmp`) for temporary files. +- Worktree paths sit directly under the workspace folder: + `{devRoot}/{workspace}/{repo}-{number}-{slug}` — never inside a + `worktrees/` subdirectory. +- For new branches use `--no-track` so accidental pushes go to your branch, + not `main`: + `git worktree add /path -b feature/name --no-track origin/main` + +## Known Issues / Corrections diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 09636cc..77a0d0e 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -1,5 +1,7 @@ import AppKit import SwiftUI +import CrowClaude +import CrowCodex import CrowCore import CrowUI import CrowPersistence @@ -189,6 +191,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func launchMainApp() { guard let devRoot else { return } + // Register the Claude Code agent in the shared registry — always + // present, since the Manager terminal and the default-agent picker + // both rely on it. + AgentRegistry.shared.register(ClaudeCodeAgent()) + + // Conditionally register the OpenAI Codex agent — only when its + // binary is on disk. Keeps the per-session picker clean for users + // who haven't installed Codex. + let codexAgent = OpenAICodexAgent() + if codexAgent.findBinary() != nil { + AgentRegistry.shared.register(codexAgent) + NSLog("[Crow] OpenAI Codex agent registered") + } + // Initialize libghostty NSLog("[Crow] Initializing Ghostty") GhosttyApp.shared.initialize() @@ -251,6 +267,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSLog("[Crow] Scaffold update failed: %@", error.localizedDescription) } + // Codex-specific dev-root and global config — only when Codex is + // registered. AGENTS.md goes into devRoot; hooks.json + config.toml + // go into ~/.codex (or $CODEX_HOME). All idempotent; safe to re-run. + if AgentRegistry.shared.agent(for: .codex) != nil { + do { + try CodexScaffolder.scaffold(devRoot: devRoot) + } catch { + NSLog("[Crow] Codex scaffold failed: %@", error.localizedDescription) + } + if let crowPath = ClaudeHookConfigWriter.findCrowBinary() { + let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] + ?? NSString(string: "~/.codex").expandingTildeInPath + do { + try CodexHookConfigWriter.installGlobalConfig(codexHome: codexHome, crowPath: crowPath) + try CodexHookConfigWriter.installGlobalTomlConfig(codexHome: codexHome, crowPath: crowPath) + } catch { + NSLog("[Crow] Codex global config install failed: %@", error.localizedDescription) + } + } + } + // Initialize persistence let store = JSONStore() self.store = store @@ -263,6 +300,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.managerAutoPermissionMode = config.managerAutoPermissionMode appState.excludeReviewRepos = config.defaults.excludeReviewRepos appState.excludeTicketRepos = config.defaults.excludeTicketRepos + 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) @@ -276,7 +314,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Check for runtime dependencies (non-blocking) Task { let missing = await Task.detached { - let tools = ["gh", "git", "claude", "glab", "code"] + let tools = ["gh", "git", "claude", "codex", "glab", "code"] return tools.filter { !ShellEnvironment.shared.hasCommand($0) } }.value if !missing.isEmpty { @@ -308,8 +346,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { service?.setSessionActive(id: id) } - appState.onLaunchClaude = { [weak service] terminalID in - service?.launchClaude(terminalID: terminalID) + appState.onLaunchAgent = { [weak service] terminalID in + service?.launchAgent(terminalID: terminalID) } appState.onRetryTerminal = { [weak service] terminalID in @@ -680,6 +718,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.managerAutoPermissionMode = config.managerAutoPermissionMode appState.excludeReviewRepos = config.defaults.excludeReviewRepos appState.excludeTicketRepos = config.defaults.excludeTicketRepos + appState.defaultAgentKind = config.defaultAgentKind } // MARK: - Socket Server @@ -711,11 +750,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 @@ -1024,16 +1072,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) } - // For managed terminals receiving a claude command, write hook config - // before sending so Claude picks up the hooks on startup. + // For managed terminals receiving an agent-launching + // command, write hook config (and inject OTEL env vars + // for Claude) before forwarding so the agent picks up + // hooks on startup. The agent dispatch is driven by the + // session's `agentKind` and the agent's + // `launchCommandToken` (e.g. "claude", "codex"). if let terminals = capturedAppState.terminals[sessionID], let terminal = terminals.first(where: { $0.id == terminalID }), terminal.isManaged, - text.contains("claude") { + let session = capturedAppState.sessions.first(where: { $0.id == sessionID }), + let agent = AgentRegistry.shared.agent(for: session.agentKind), + text.contains(agent.launchCommandToken) { if let worktree = capturedAppState.primaryWorktree(for: sessionID), - let crowPath = HookConfigGenerator.findCrowBinary() { + let crowPath = ClaudeHookConfigWriter.findCrowBinary() { do { - try HookConfigGenerator.writeHookConfig( + try agent.hookConfigWriter.writeHookConfig( worktreePath: worktree.worktreePath, sessionID: sessionID, crowPath: crowPath @@ -1043,8 +1097,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { sessionID.uuidString, error.localizedDescription) } } - // Inject OTEL telemetry env vars so analytics flow back to Crow - if let port = capturedTelemetryPort { + // OTEL telemetry env vars are Claude-specific — + // Codex has no equivalent OTLP exporter. + if agent.kind == .claudeCode, let port = capturedTelemetryPort { let vars = [ "CLAUDE_CODE_ENABLE_TELEMETRY=1", "OTEL_METRICS_EXPORTER=otlp", @@ -1055,7 +1110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ].joined(separator: " ") text = "export \(vars) && \(text)" } - capturedAppState.terminalReadiness[terminalID] = .claudeLaunched + capturedAppState.terminalReadiness[terminalID] = .agentLaunched } if let routedTerminal { @@ -1094,20 +1149,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return ["links": .array(items)] }, "hook-event": { @Sendable params in - guard let sessionIDStr = params["session_id"]?.stringValue, - let sessionID = UUID(uuidString: sessionIDStr), - let eventName = params["event_name"]?.stringValue else { - throw RPCError.invalidParams("session_id and event_name required") + guard let eventName = params["event_name"]?.stringValue else { + throw RPCError.invalidParams("event_name required") } let payload = params["payload"]?.objectValue ?? [:] - if hookDebug { - let shortID = String(sessionIDStr.prefix(8)) - let keys = payload.keys.sorted().joined(separator: ",") - NSLog("[hook-event] session=\(shortID) event=\(eventName) payload-keys=[\(keys)]") - } - - // Build a human-readable summary from the event + // session_id is now optional — Codex's global hooks don't + // know the Crow session UUID, so the server resolves it via + // the `cwd` field in the payload. + let providedSessionID = params["session_id"]?.stringValue + .flatMap(UUID.init(uuidString:)) + let requestedAgentKind = params["agent_kind"]?.stringValue + .flatMap { $0.isEmpty ? nil : AgentKind(rawValue: $0) } + let cwd = payload["cwd"]?.stringValue + + // Build a human-readable summary from the event (independent + // of session resolution). let summary: String = { switch eventName { case "PreToolUse", "PostToolUse", "PostToolUseFailure": @@ -1117,9 +1174,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let msg = payload["message"]?.stringValue ?? "" return "Notification: \(msg.prefix(80))" case "Stop": - return "Claude finished responding" + return "Agent finished responding" case "StopFailure": - return "Claude stopped with error" + return "Agent stopped with error" case "SessionStart": return "Session started" case "SessionEnd": @@ -1148,138 +1205,97 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } }() - let event = HookEvent( - sessionID: sessionID, - eventName: eventName, - summary: summary - ) - - return await MainActor.run { - let state = capturedAppState.hookState(for: sessionID) - let stateBefore = state.claudeState - - // Append to ring buffer (keep last 50 events per session) - 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 + return try await MainActor.run { + // Resolve session — explicit param wins, else look up by + // worktree path matching `cwd`. + let sessionID: UUID + if let provided = providedSessionID { + sessionID = provided + } else if let cwd, let resolved = capturedAppState.sessionID(forWorktreePath: cwd) { + sessionID = resolved + } else { + throw RPCError.invalidParams("session_id required or resolvable from payload cwd") } + let sessionIDStr = sessionID.uuidString - 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 - ) - - 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 + if hookDebug { + let shortID = String(sessionIDStr.prefix(8)) + let keys = payload.keys.sorted().joined(separator: ",") + NSLog("[hook-event] session=\(shortID) event=\(eventName) payload-keys=[\(keys)]") + } - case "UserPromptSubmit": - state.claudeState = .working - // A new real turn has begun — clear the post-Stop guard so - // legitimate subagents in this turn can elevate state again. - state.lastTopLevelStopAt = nil + let event = HookEvent( + sessionID: sessionID, + eventName: eventName, + summary: summary + ) - case "Stop": - state.claudeState = .done - state.lastToolActivity = nil - state.lastTopLevelStopAt = Date() + // 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 + ) - case "StopFailure": - state.claudeState = .waiting - state.lastTopLevelStopAt = Date() + // Resolve the agent: explicit kind param > session's + // stored agentKind > app default. + let session = capturedAppState.sessions.first(where: { $0.id == sessionID }) + let resolvedKind = requestedAgentKind + ?? session?.agentKind + ?? capturedAppState.defaultAgentKind + let signalSource = AgentRegistry.shared.agent(for: resolvedKind)?.stateSignalSource - case "SessionStart": - let source = payload["source"]?.stringValue ?? "startup" - if source == "resume" { - state.claudeState = .done - } else { - state.claudeState = .idle - } - state.lastTopLevelStopAt = nil + let state = capturedAppState.hookState(for: sessionID) + let stateBefore = state.activityState - case "SessionEnd": - state.claudeState = .idle - state.lastToolActivity = nil - state.lastTopLevelStopAt = nil + // Append to ring buffer (keep last 50 events per session) + state.hookEvents.append(event) + if state.hookEvents.count > 50 { state.hookEvents.removeFirst(state.hookEvents.count - 50) } - case "SubagentStart": - // If a top-level Stop has already fired for this turn, the - // subagent is background work (e.g. the recap generator from - // Claude Code ≥ 2.1.108's awaySummaryEnabled feature). Don't - // elevate state — the user is genuinely done. - if state.lastTopLevelStopAt == nil { - state.claudeState = .working + // 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, + currentLastTopLevelStopAt: state.lastTopLevelStopAt + ) + if let newActivityState = transition.newActivityState { + state.activityState = newActivityState } - - case "TaskCreated", "TaskCompleted", "SubagentStop": - // Stay in working state, but only while the turn is still live. - // After a top-level Stop, treat these as background activity - // and leave claudeState alone. - if state.claudeState != .waiting && state.lastTopLevelStopAt == nil { - 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 + } + switch transition.lastTopLevelStopAt { + case .leave: + break + case .clear: + state.lastTopLevelStopAt = nil + case .set(let date): + state.lastTopLevelStopAt = date } } @@ -1291,9 +1307,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { summary: summary ) - if hookDebug && state.claudeState != stateBefore { + if hookDebug && state.activityState != stateBefore { let shortID = String(sessionIDStr.prefix(8)) - NSLog("[hook-event] session=\(shortID) event=\(eventName) state=\(stateBefore.rawValue)→\(state.claudeState.rawValue)") + NSLog("[hook-event] session=\(shortID) event=\(eventName) state=\(stateBefore.rawValue)→\(state.activityState.rawValue)") } return [ diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 345df73..22d8370 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 @@ -262,23 +263,23 @@ 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 } - // Auto-launch Claude now that the shell is ready. + // Auto-launch the agent now that the shell is ready. // Previously this was triggered by the SwiftUI view's onChange, // but with offscreen pre-init the view may not be rendered yet. - self.launchClaude(terminalID: terminalID) + self.launchAgent(terminalID: terminalID) case .failed: NSLog("[SessionService] terminal \(terminalID) failed to launch surface") self.appState.terminalReadiness[terminalID] = .failed - // Do not auto-launch Claude; the UI will surface a Retry button. + // Do not auto-launch the agent; the UI will surface a Retry button. } } // tmux-backed terminals report readiness via SentinelWaiter rather // than the 5s sleep. We funnel that into the same TerminalReadiness - // state machine so downstream consumers (launchClaude) work without + // state machine so downstream consumers (launchAgent) work without // backend-specific branches. Tmux backend skips the .surfaceCreated // intermediate state — its window is created synchronously by // registerTerminal — so we go straight to .shellReady. @@ -288,31 +289,32 @@ final class SessionService { NSLog("[SessionService] tmux readiness: terminal=\(terminalID), state=\(readiness), current=\(currentState)") if readiness == .shellReady, currentState < .shellReady { self.appState.terminalReadiness[terminalID] = .shellReady - self.launchClaude(terminalID: terminalID) + self.launchAgent(terminalID: terminalID) } } } - /// Send `claude --continue` (or a review prompt for review sessions) to a terminal and mark it as launched. - /// - /// Writes hook configuration to the session's worktree first so that - /// Claude Code picks up the hooks on startup. - func launchClaude(terminalID: UUID) { + /// Auto-launch the session's coding agent in `terminalID`. Dispatches via + /// the registered `CodingAgent` for the session's `agentKind`, which + /// builds both the hook configuration and the launch command. + func launchAgent(terminalID: UUID) { guard appState.terminalReadiness[terminalID] == .shellReady else { return } // Only auto-launch for restored/recovered terminals, not brand-new ones guard appState.autoLaunchTerminals.remove(terminalID) != nil else { return } // Find the session this terminal belongs to - let sessionID = appState.terminals.first(where: { _, terminals in + guard let sessionID = appState.terminals.first(where: { _, terminals in terminals.contains(where: { $0.id == terminalID }) - })?.key - - // Write/refresh hook config for the session's worktree - if let sessionID, - let worktree = appState.primaryWorktree(for: sessionID), - let crowPath = HookConfigGenerator.findCrowBinary() { + })?.key, + let session = appState.sessions.first(where: { $0.id == sessionID }), + let worktree = appState.primaryWorktree(for: sessionID), + let agent = AgentRegistry.shared.agent(for: session.agentKind) else { return } + + // Write/refresh hook config (Claude path). Codex's writer is a + // no-op — its global config was installed once at app launch. + if let crowPath = ClaudeHookConfigWriter.findCrowBinary() { do { - try HookConfigGenerator.writeHookConfig( + try agent.hookConfigWriter.writeHookConfig( worktreePath: worktree.worktreePath, sessionID: sessionID, crowPath: crowPath @@ -323,63 +325,38 @@ final class SessionService { } } - let claudePath = Self.findClaudeBinary() ?? "claude" - let sessionName = sessionID.flatMap { id in appState.sessions.first(where: { $0.id == id })?.name } let rcEnabled = appState.remoteControlEnabled - let rcArgs = ClaudeLaunchArgs.argsSuffix(remoteControl: rcEnabled, sessionName: sessionName) - - // Build OTEL telemetry env var prefix if enabled - let envPrefix: String - if let port = telemetryPort, let sessionID { - let vars = [ - "CLAUDE_CODE_ENABLE_TELEMETRY=1", - "OTEL_METRICS_EXPORTER=otlp", - "OTEL_LOGS_EXPORTER=otlp", - "OTEL_EXPORTER_OTLP_PROTOCOL=http/json", - "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:\(port)", - "OTEL_RESOURCE_ATTRIBUTES=crow.session.id=\(sessionID.uuidString)", - ].joined(separator: " ") - envPrefix = "export \(vars) && " - } else { - envPrefix = "" - } - - // Look up the SessionTerminal so we can route through TerminalRouter - // (works for both .ghostty and .tmux backends). Falls back to the - // legacy direct-send path if the terminal is unknown. - let routedTerminal: SessionTerminal? = sessionID.flatMap { sid in - appState.terminals[sid]?.first(where: { $0.id == terminalID }) + // The agent's autoLaunchCommand mirrors this condition — the review + // prompt file is only used on first launch (CROW-224). Compute it + // here so we know whether to flip `reviewPromptDispatched` after the + // command goes out. + let reviewPromptJustDispatched = session.kind == .review + && !session.reviewPromptDispatched + guard let command = agent.autoLaunchCommand( + session: session, + worktreePath: worktree.worktreePath, + remoteControlEnabled: rcEnabled, + telemetryPort: telemetryPort + ) else { + NSLog("[SessionService] Agent %@ could not build a launch command for session %@", + agent.kind.rawValue, sessionID.uuidString) + return } - // Review-kind sessions dispatch their `/crow-review-pr` prompt on first - // launch only — on subsequent app restarts, fall through to - // `claude --continue` so the existing conversation resumes instead of - // re-running the entire review (CROW-224). - var reviewPromptJustDispatched = false - let claudeText: String = { - if let sessionID, - let session = appState.sessions.first(where: { $0.id == sessionID }), - session.kind == .review, - !session.reviewPromptDispatched, - let worktree = appState.primaryWorktree(for: sessionID) { - let promptPath = (worktree.worktreePath as NSString).appendingPathComponent(".crow-review-prompt.md") - reviewPromptJustDispatched = true - return "\(envPrefix)\(claudePath)\(rcArgs) \"$(cat \(promptPath))\"\n" - } else { - return "\(envPrefix)\(claudePath)\(rcArgs) --continue\n" - } - }() - if let routedTerminal { - TerminalRouter.send(routedTerminal, text: claudeText) + // Route through TerminalRouter so tmux-backed terminals get the text + // via tmux send-keys; Ghostty terminals fall through to the direct + // TerminalManager path. + if let routedTerminal = appState.terminals[sessionID]?.first(where: { $0.id == terminalID }) { + TerminalRouter.send(routedTerminal, text: command) } else { - TerminalManager.shared.send(id: terminalID, text: claudeText) + TerminalManager.shared.send(id: terminalID, text: command) } - appState.terminalReadiness[terminalID] = .claudeLaunched - if rcEnabled { + appState.terminalReadiness[terminalID] = .agentLaunched + if rcEnabled && agent.supportsRemoteControl { appState.remoteControlActiveTerminals.insert(terminalID) } - if reviewPromptJustDispatched, let sessionID { + if reviewPromptJustDispatched { if let idx = appState.sessions.firstIndex(where: { $0.id == sessionID }) { appState.sessions[idx].reviewPromptDispatched = true } @@ -419,10 +396,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) @@ -510,7 +490,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 @@ -802,6 +782,7 @@ final class SessionService { let session = Session( name: dirName, status: .active, + agentKind: appState.defaultAgentKind, ticketURL: ticket.url, ticketTitle: ticket.title, ticketNumber: ticket.number, @@ -1023,6 +1004,7 @@ final class SessionService { let session = Session( name: "review-\(repoName)-\(prNumber)", kind: .review, + agentKind: appState.defaultAgentKind, ticketTitle: prTitle, provider: .github )