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
530 changes: 8 additions & 522 deletions Sources/Crow/App/AppDelegate.swift

Large diffs are not rendered by default.

194 changes: 194 additions & 0 deletions Sources/Crow/App/RPCHandlers/HookHandlers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import CrowCore
import CrowIPC
import Foundation

func hookHandlers(
appState: AppState,
notifManager: NotificationManager?
) -> [String: CommandRouter.Handler] {
[
"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")
}
let payload = params["payload"]?.objectValue ?? [:]

// Build a human-readable summary from the event
let summary: String = {
switch eventName {
case "PreToolUse", "PostToolUse", "PostToolUseFailure":
let tool = payload["tool_name"]?.stringValue ?? "unknown"
return "\(eventName): \(tool)"
case "Notification":
let msg = payload["message"]?.stringValue ?? ""
return "Notification: \(msg.prefix(80))"
case "Stop":
return "Claude finished responding"
case "StopFailure":
return "Claude stopped with error"
case "SessionStart":
return "Session started"
case "SessionEnd":
return "Session ended"
case "PermissionRequest":
return "Permission requested"
case "PermissionDenied":
return "Permission denied"
case "UserPromptSubmit":
return "User submitted prompt"
case "TaskCreated":
return "Task created"
case "TaskCompleted":
return "Task completed"
case "SubagentStart":
let agentType = payload["agent_type"]?.stringValue ?? "agent"
return "Subagent started: \(agentType)"
case "SubagentStop":
return "Subagent stopped"
case "PreCompact":
return "Context compaction starting"
case "PostCompact":
return "Context compaction finished"
default:
return eventName
}
}()

let event = HookEvent(
sessionID: sessionID,
eventName: eventName,
summary: summary
)

return await MainActor.run {
let state = appState.hookState(for: sessionID)

// 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
}

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

case "UserPromptSubmit":
state.claudeState = .working

case "Stop":
state.claudeState = .done
state.lastToolActivity = nil

case "StopFailure":
state.claudeState = .waiting

case "SessionStart":
let source = payload["source"]?.stringValue ?? "startup"
if source == "resume" {
state.claudeState = .done
} else {
state.claudeState = .idle
}

case "SessionEnd":
state.claudeState = .idle
state.lastToolActivity = nil

case "SubagentStart":
state.claudeState = .working

case "TaskCreated", "TaskCompleted", "SubagentStop":
// Stay in working state
if state.claudeState != .waiting {
state.claudeState = .working
}

default:
// PermissionDenied, PreCompact, PostCompact — state change
// handled by blanket notification clear above
if eventName == "PermissionDenied" {
state.claudeState = .working
state.lastToolActivity = nil
}
}

// Trigger notification/sound for this event
notifManager?.handleEvent(
sessionID: sessionID,
eventName: eventName,
payload: payload,
summary: summary
)

return [
"received": .bool(true),
"session_id": .string(sessionIDStr),
"event_name": .string(eventName),
]
}
},
]
}
36 changes: 36 additions & 0 deletions Sources/Crow/App/RPCHandlers/LinkHandlers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import CrowCore
import CrowIPC
import CrowPersistence
import Foundation

func linkHandlers(
appState: AppState,
store: JSONStore
) -> [String: CommandRouter.Handler] {
[
"add-link": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: idStr),
let label = params["label"]?.stringValue, !label.isEmpty,
let url = params["url"]?.stringValue, !url.isEmpty else {
throw RPCError.invalidParams("session_id, label, url required (non-empty)")
}
let link = SessionLink(sessionID: sessionID, label: label, url: url,
linkType: LinkType(rawValue: params["type"]?.stringValue ?? "custom") ?? .custom)
return await MainActor.run {
appState.links[sessionID, default: []].append(link)
store.mutate { $0.links.append(link) }
return ["link_id": .string(link.id.uuidString)]
}
},
"list-links": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else {
throw RPCError.invalidParams("session_id required")
}
let lnks = await MainActor.run { appState.links(for: id) }
let items: [JSONValue] = lnks.map { l in
.object(["id": .string(l.id.uuidString), "label": .string(l.label), "url": .string(l.url), "type": .string(l.linkType.rawValue)])
}
return ["links": .array(items)]
},
]
}
19 changes: 19 additions & 0 deletions Sources/Crow/App/RPCHandlers/RPCError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import CrowIPC
import Foundation

enum RPCError: Error, LocalizedError, RPCErrorCoded {
case invalidParams(String)
case applicationError(String)
var rpcErrorCode: Int {
switch self {
case .invalidParams: RPCErrorCode.invalidParams
case .applicationError: RPCErrorCode.applicationError
}
}
var errorDescription: String? {
switch self {
case .invalidParams(let msg): msg
case .applicationError(let msg): msg
}
}
}
133 changes: 133 additions & 0 deletions Sources/Crow/App/RPCHandlers/SessionHandlers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import CrowCore
import CrowIPC
import CrowPersistence
import Foundation

func sessionHandlers(
appState: AppState,
store: JSONStore,
service: SessionService
) -> [String: CommandRouter.Handler] {
[
"new-session": { @Sendable params in
let name = params["name"]?.stringValue ?? "untitled"
guard Validation.isValidSessionName(name) else {
throw RPCError.invalidParams("Invalid session name (max \(Validation.maxSessionNameLength) chars, no control characters)")
}
return await MainActor.run {
let session = Session(name: name)
appState.sessions.append(session)
store.mutate { $0.sessions.append(session) }
return ["session_id": .string(session.id.uuidString), "name": .string(session.name)]
}
},
"rename-session": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue,
let id = UUID(uuidString: idStr),
let name = params["name"]?.stringValue else {
throw RPCError.invalidParams("session_id and name required")
}
guard Validation.isValidSessionName(name) else {
throw RPCError.invalidParams("Invalid session name (max \(Validation.maxSessionNameLength) chars, no control characters)")
}
return try await MainActor.run {
guard let idx = appState.sessions.firstIndex(where: { $0.id == id }) else {
throw RPCError.applicationError("Session not found")
}
appState.sessions[idx].name = name
store.mutate { data in
if let i = data.sessions.firstIndex(where: { $0.id == id }) { data.sessions[i].name = name }
}
return ["session_id": .string(idStr), "name": .string(name)]
}
},
"select-session": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue,
let id = UUID(uuidString: idStr) else {
throw RPCError.invalidParams("session_id required")
}
await MainActor.run { appState.selectedSessionID = id }
return ["session_id": .string(idStr)]
},
"list-sessions": { @Sendable _ in
let sessions = await MainActor.run { appState.sessions }
let items: [JSONValue] = sessions.map { s in
.object(["id": .string(s.id.uuidString), "name": .string(s.name), "status": .string(s.status.rawValue)])
}
return ["sessions": .array(items)]
},
"get-session": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else {
throw RPCError.invalidParams("session_id required")
}
return try await MainActor.run {
guard let s = appState.sessions.first(where: { $0.id == id }) else {
throw RPCError.applicationError("Session not found")
}
let fmt = ISO8601DateFormatter()
return [
"id": .string(s.id.uuidString),
"name": .string(s.name),
"status": .string(s.status.rawValue),
"ticket_url": s.ticketURL.map { .string($0) } ?? .null,
"ticket_title": s.ticketTitle.map { .string($0) } ?? .null,
"ticket_number": s.ticketNumber.map { .int($0) } ?? .null,
"provider": s.provider.map { .string($0.rawValue) } ?? .null,
"created_at": .string(fmt.string(from: s.createdAt)),
"updated_at": .string(fmt.string(from: s.updatedAt)),
]
}
},
"set-status": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr),
let statusStr = params["status"]?.stringValue, let status = SessionStatus(rawValue: statusStr) else {
throw RPCError.invalidParams("session_id and status required")
}
return try await MainActor.run {
guard let idx = appState.sessions.firstIndex(where: { $0.id == id }) else {
throw RPCError.applicationError("Session not found")
}
appState.sessions[idx].status = status
appState.sessions[idx].updatedAt = Date()
store.mutate { data in
if let i = data.sessions.firstIndex(where: { $0.id == id }) {
data.sessions[i].status = status
data.sessions[i].updatedAt = Date()
}
}
return ["session_id": .string(idStr), "status": .string(statusStr)]
}
},
"delete-session": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else {
throw RPCError.invalidParams("session_id required")
}
guard id != AppState.managerSessionID else { throw RPCError.applicationError("Cannot delete manager session") }
await service.deleteSession(id: id)
return ["deleted": .bool(true)]
},
"set-ticket": { @Sendable params in
guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else {
throw RPCError.invalidParams("session_id required")
}
return try await MainActor.run {
guard let idx = appState.sessions.firstIndex(where: { $0.id == id }) else {
throw RPCError.applicationError("Session not found")
}
if let url = params["url"]?.stringValue {
appState.sessions[idx].ticketURL = url
// Auto-detect provider from URL
if appState.sessions[idx].provider == nil {
appState.sessions[idx].provider = Validation.detectProviderFromURL(url)
}
}
if let title = params["title"]?.stringValue { appState.sessions[idx].ticketTitle = title }
if let num = params["number"]?.intValue { appState.sessions[idx].ticketNumber = num }
store.mutate { data in
if let i = data.sessions.firstIndex(where: { $0.id == id }) { data.sessions[i] = appState.sessions[idx] }
}
return ["session_id": .string(idStr)]
}
},
]
}
Loading
Loading