Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
51 changes: 51 additions & 0 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation
import CrowCore

/// `CodingAgent` conformer for Claude Code. Wraps the existing `ClaudeLauncher`
/// prompt/launch-command logic and bundles the Claude-specific hook writer
/// and state-machine signal source so the main app can treat everything
/// through the generic `CodingAgent` interface.
public struct ClaudeCodeAgent: CodingAgent {
public let kind: AgentKind = .claudeCode
public let displayName: String = "Claude Code"
public let iconSystemName: String = "sparkles"
public let hookConfigWriter: any HookConfigWriter
public let stateSignalSource: any StateSignalSource

private let launcher: ClaudeLauncher

public init(
hookConfigWriter: any HookConfigWriter = ClaudeHookConfigWriter(),
stateSignalSource: any StateSignalSource = ClaudeHookSignalSource()
) {
self.hookConfigWriter = hookConfigWriter
self.stateSignalSource = stateSignalSource
self.launcher = ClaudeLauncher()
}

public func generatePrompt(
session: Session,
worktrees: [SessionWorktree],
ticketURL: String?,
provider: Provider?
) async -> String {
await launcher.generatePrompt(
session: session,
worktrees: worktrees,
ticketURL: ticketURL,
provider: provider
)
}

public func launchCommand(
sessionID: UUID,
worktreePath: String,
prompt: String
) async throws -> String {
try await launcher.launchCommand(
sessionID: sessionID,
worktreePath: worktreePath,
prompt: prompt
)
}
}
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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")

Expand All @@ -101,7 +104,7 @@ struct HookConfigGenerator {
}

// Remove our managed event entries
for event in allEvents {
for event in Self.allEvents {
hooks.removeValue(forKey: event)
}

Expand All @@ -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 {
Expand All @@ -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)
}
}
Expand All @@ -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()
Expand Down
128 changes: 128 additions & 0 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeHookSignalSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation
import CrowCore

/// Translates Claude Code hook events into `AgentStateTransition` values.
/// This is the state machine that AppDelegate used to embed inline; moving it
/// here keeps the per-agent behavior next to `ClaudeHookConfigWriter` and lets
/// the hook-event handler stay small.
///
/// Pure: returns a transition, never mutates shared state. Callers apply the
/// transition to `SessionHookState` after receiving it.
public struct ClaudeHookSignalSource: StateSignalSource {
public init() {}

public func transition(
for event: AgentHookEvent,
currentActivityState: AgentActivityState,
currentNotificationType: String?
) -> AgentStateTransition {
// Most events clear any pending notification. Notification and
// PermissionRequest are the two cases that may *set* the pending
// notification themselves, so we don't preemptively clear for them.
let blanketClear = event.eventName != "Notification"
&& event.eventName != "PermissionRequest"
var transition = AgentStateTransition(
notification: blanketClear ? .clear : .leave
)

switch event.eventName {
case "PreToolUse":
let toolName = event.toolName ?? "unknown"
if toolName == "AskUserQuestion" {
transition.notification = .set(HookNotification(
message: "Claude has a question",
notificationType: "question"
))
transition.newActivityState = .waiting
transition.toolActivity = .clear
} else {
transition.toolActivity = .set(ToolActivity(
toolName: toolName, isActive: true
))
transition.newActivityState = .working
}

case "PostToolUse":
let toolName = event.toolName ?? "unknown"
transition.toolActivity = .set(ToolActivity(
toolName: toolName, isActive: false
))

case "PostToolUseFailure":
let toolName = event.toolName ?? "unknown"
transition.toolActivity = .set(ToolActivity(
toolName: toolName, isActive: false
))

case "Notification":
let message = event.message ?? ""
let notifType = event.notificationType ?? ""
if notifType == "permission_prompt" {
transition.notification = .set(HookNotification(
message: message, notificationType: notifType
))
transition.newActivityState = .waiting
} else if notifType == "idle_prompt" {
// At the prompt — clear any stale permission notification.
// Don't change activity state (Stop already set it to .done).
transition.notification = .clear
}

case "PermissionRequest":
// Don't override a "question" notification — AskUserQuestion
// triggers both PreToolUse and PermissionRequest, and the question
// badge is more specific than generic "Permission".
if currentNotificationType != "question" {
transition.notification = .set(HookNotification(
message: "Permission requested",
notificationType: "permission_prompt"
))
}
transition.newActivityState = .waiting
transition.toolActivity = .clear

case "UserPromptSubmit":
transition.newActivityState = .working

case "Stop":
transition.newActivityState = .done
transition.toolActivity = .clear

case "StopFailure":
transition.newActivityState = .waiting

case "SessionStart":
let source = event.source ?? "startup"
if source == "resume" {
transition.newActivityState = .done
} else {
transition.newActivityState = .idle
}

case "SessionEnd":
transition.newActivityState = .idle
transition.toolActivity = .clear

case "SubagentStart":
transition.newActivityState = .working

case "TaskCreated", "TaskCompleted", "SubagentStop":
// Stay in working state unless the session is already waiting on
// user input (don't clobber a pending question/permission).
if currentActivityState != .waiting {
transition.newActivityState = .working
}

case "PermissionDenied":
transition.newActivityState = .working
transition.toolActivity = .clear

default:
// PreCompact, PostCompact, and any unknown event just get the
// blanket notification clear applied above.
break
}

return transition
}
}
Loading