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
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -32,6 +33,7 @@ let package = Package(
"CrowProvider",
"CrowPersistence",
"CrowClaude",
"CrowCodex",
"CrowIPC",
"CrowTelemetry",
],
Expand Down
2 changes: 2 additions & 0 deletions Packages/CrowCLI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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: [
.target(
name: "CrowCLILib",
dependencies: [
"CrowIPC",
"CrowCodex",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <json-payload>`. 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),
])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
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
1 change: 1 addition & 0 deletions Packages/CrowCLI/Sources/CrowCLILib/CrowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public struct CrowCommand: ParsableCommand {
AddLink.self,
ListLinks.self,
HookEventCmd.self,
CodexNotify.self,
]
)

Expand Down
10 changes: 5 additions & 5 deletions Packages/CrowCLI/Tests/CrowCLITests/HookEventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([:]),
])
}
109 changes: 109 additions & 0 deletions Packages/CrowClaude/Sources/CrowClaude/ClaudeCodeAgent.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading
Loading