diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index a8e2f7a..6f2f28f 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -371,512 +371,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Socket Server - /// Maximum allowed length for session names. - private nonisolated static let maxSessionNameLength = Validation.maxSessionNameLength - - /// Validate that a path is within the configured devRoot to prevent path traversal. - private nonisolated static func isPathWithinDevRoot(_ path: String, devRoot: String) -> Bool { - Validation.isPathWithinRoot(path, root: devRoot) - } - - /// Validate a session name contains no control characters and is within length limits. - private nonisolated static func isValidSessionName(_ name: String) -> Bool { - Validation.isValidSessionName(name) - } - private func startSocketServer(store: JSONStore, devRoot: String, sessionService: SessionService) { - let capturedAppState = appState - let capturedStore = store - let capturedNotifManager = notificationManager - let capturedService = sessionService - - let router = CommandRouter(handlers: [ - "new-session": { @Sendable params in - let name = params["name"]?.stringValue ?? "untitled" - guard AppDelegate.isValidSessionName(name) else { - throw RPCError.invalidParams("Invalid session name (max \(AppDelegate.maxSessionNameLength) chars, no control characters)") - } - return await MainActor.run { - let session = Session(name: name) - capturedAppState.sessions.append(session) - capturedStore.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 AppDelegate.isValidSessionName(name) else { - throw RPCError.invalidParams("Invalid session name (max \(AppDelegate.maxSessionNameLength) chars, no control characters)") - } - return try await MainActor.run { - guard let idx = capturedAppState.sessions.firstIndex(where: { $0.id == id }) else { - throw RPCError.applicationError("Session not found") - } - capturedAppState.sessions[idx].name = name - capturedStore.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 { capturedAppState.selectedSessionID = id } - return ["session_id": .string(idStr)] - }, - "list-sessions": { @Sendable _ in - let sessions = await MainActor.run { capturedAppState.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 = capturedAppState.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 = capturedAppState.sessions.firstIndex(where: { $0.id == id }) else { - throw RPCError.applicationError("Session not found") - } - capturedAppState.sessions[idx].status = status - capturedAppState.sessions[idx].updatedAt = Date() - capturedStore.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 capturedService.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 = capturedAppState.sessions.firstIndex(where: { $0.id == id }) else { - throw RPCError.applicationError("Session not found") - } - if let url = params["url"]?.stringValue { - capturedAppState.sessions[idx].ticketURL = url - // Auto-detect provider from URL - if capturedAppState.sessions[idx].provider == nil { - capturedAppState.sessions[idx].provider = Validation.detectProviderFromURL(url) - } - } - if let title = params["title"]?.stringValue { capturedAppState.sessions[idx].ticketTitle = title } - if let num = params["number"]?.intValue { capturedAppState.sessions[idx].ticketNumber = num } - capturedStore.mutate { data in - if let i = data.sessions.firstIndex(where: { $0.id == id }) { data.sessions[i] = capturedAppState.sessions[idx] } - } - return ["session_id": .string(idStr)] - } - }, - "add-worktree": { @Sendable params in - guard let idStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: idStr), - let repo = params["repo"]?.stringValue, !repo.isEmpty, - let path = params["path"]?.stringValue, !path.isEmpty, - let branch = params["branch"]?.stringValue, !branch.isEmpty else { - throw RPCError.invalidParams("session_id, repo, path, branch required (non-empty)") - } - // Validate path is within devRoot to prevent path traversal - guard AppDelegate.isPathWithinDevRoot(path, devRoot: devRoot) else { - throw RPCError.invalidParams("Worktree path must be within the configured devRoot") - } - // repo_path is the main repo (for git commands). Defaults to path if not provided. - let repoPath = params["repo_path"]?.stringValue ?? path - guard AppDelegate.isPathWithinDevRoot(repoPath, devRoot: devRoot) else { - throw RPCError.invalidParams("repo_path must be within the configured devRoot") - } - let wt = SessionWorktree(sessionID: sessionID, repoName: repo, repoPath: repoPath, worktreePath: path, - branch: branch, isPrimary: params["primary"]?.boolValue ?? false) - return await MainActor.run { - capturedAppState.worktrees[sessionID, default: []].append(wt) - capturedStore.mutate { $0.worktrees.append(wt) } - return ["worktree_id": .string(wt.id.uuidString), "session_id": .string(idStr), "path": .string(path)] - } - }, - "list-worktrees": { @Sendable params in - guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else { - throw RPCError.invalidParams("session_id required") - } - let wts = await MainActor.run { capturedAppState.worktrees(for: id) } - let items: [JSONValue] = wts.map { wt in - .object(["id": .string(wt.id.uuidString), "repo": .string(wt.repoName), "path": .string(wt.worktreePath), - "branch": .string(wt.branch), "primary": .bool(wt.isPrimary)]) - } - return ["worktrees": .array(items)] - }, - "new-terminal": { @Sendable params in - guard let idStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: idStr), - let cwd = params["cwd"]?.stringValue else { - throw RPCError.invalidParams("session_id and cwd required") - } - // Validate cwd is within devRoot to prevent path traversal - guard AppDelegate.isPathWithinDevRoot(cwd, devRoot: devRoot) else { - throw RPCError.invalidParams("Terminal cwd must be within the configured devRoot") - } - // Resolve claude binary path if command references claude - var command = params["command"]?.stringValue - if let cmd = command, cmd.contains("claude") { - command = AppDelegate.resolveClaudeInCommand(cmd) - } - let isManaged = params["managed"]?.boolValue ?? false - let defaultName = isManaged ? "Claude Code" : "Shell" - let terminal = SessionTerminal(sessionID: sessionID, name: params["name"]?.stringValue ?? defaultName, - cwd: cwd, command: command, isManaged: isManaged) - return await MainActor.run { - capturedAppState.terminals[sessionID, default: []].append(terminal) - capturedStore.mutate { $0.terminals.append(terminal) } - // Track readiness only for managed work session terminals - if isManaged && sessionID != AppState.managerSessionID { - capturedAppState.terminalReadiness[terminal.id] = .uninitialized - TerminalManager.shared.trackReadiness(for: terminal.id) - } - // Pre-initialize in offscreen window so shell starts immediately - TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: cwd, command: command) - return ["terminal_id": .string(terminal.id.uuidString), "session_id": .string(idStr)] - } - }, - "list-terminals": { @Sendable params in - guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else { - throw RPCError.invalidParams("session_id required") - } - // Global terminals are not exposed via the session CLI - if id == AppState.globalTerminalSessionID { - return ["terminals": .array([])] - } - let terms = await MainActor.run { capturedAppState.terminals(for: id) } - let items: [JSONValue] = terms.map { t in - .object(["id": .string(t.id.uuidString), "name": .string(t.name), "session_id": .string(t.sessionID.uuidString), "managed": .bool(t.isManaged)]) - } - return ["terminals": .array(items)] - }, - "close-terminal": { @Sendable params in - guard let sessionIDStr = params["session_id"]?.stringValue, - let sessionID = UUID(uuidString: sessionIDStr), - let terminalIDStr = params["terminal_id"]?.stringValue, - let terminalID = UUID(uuidString: terminalIDStr) else { - throw RPCError.invalidParams("session_id and terminal_id required") - } - return try await MainActor.run { - guard let terminals = capturedAppState.terminals[sessionID], - let terminal = terminals.first(where: { $0.id == terminalID }) else { - throw RPCError.applicationError("Terminal not found") - } - guard !terminal.isManaged else { - throw RPCError.applicationError("Cannot close managed terminal") - } - TerminalManager.shared.destroy(id: terminalID) - capturedAppState.terminals[sessionID]?.removeAll { $0.id == terminalID } - capturedAppState.terminalReadiness.removeValue(forKey: terminalID) - capturedAppState.autoLaunchTerminals.remove(terminalID) - if capturedAppState.activeTerminalID[sessionID] == terminalID { - capturedAppState.activeTerminalID[sessionID] = capturedAppState.terminals[sessionID]?.first?.id - } - capturedStore.mutate { data in data.terminals.removeAll { $0.id == terminalID } } - return ["deleted": .bool(true)] - } - }, - "send": { @Sendable params in - guard let sessionIDStr = params["session_id"]?.stringValue, - let sessionID = UUID(uuidString: sessionIDStr), - let terminalIDStr = params["terminal_id"]?.stringValue, - let terminalID = UUID(uuidString: terminalIDStr), - var text = params["text"]?.stringValue else { - throw RPCError.invalidParams("session_id, terminal_id, and text required") - } - // Process escape sequences: literal \n in the text becomes a real newline - text = text.replacingOccurrences(of: "\\n", with: "\n") - text = text.replacingOccurrences(of: "\\t", with: "\t") - NSLog("crow send: text length=\(text.count), ends_with_newline=\(text.hasSuffix("\n")), ends_with_cr=\(text.hasSuffix("\r"))") - await MainActor.run { - // If the surface doesn't exist yet, pre-initialize it so the shell starts - if TerminalManager.shared.existingSurface(for: terminalID) == nil { - if let terminals = capturedAppState.terminals[sessionID], - let terminal = terminals.first(where: { $0.id == terminalID }) { - TerminalManager.shared.preInitialize( - id: terminalID, - workingDirectory: terminal.cwd, - command: terminal.command - ) - } - } - - // For managed terminals receiving a claude command, write hook config - // before sending so Claude picks up the hooks on startup. - if let terminals = capturedAppState.terminals[sessionID], - let terminal = terminals.first(where: { $0.id == terminalID }), - terminal.isManaged, - text.contains("claude") { - if let worktree = capturedAppState.primaryWorktree(for: sessionID), - let crowPath = HookConfigGenerator.findCrowBinary() { - do { - try HookConfigGenerator.writeHookConfig( - worktreePath: worktree.worktreePath, - sessionID: sessionID, - crowPath: crowPath - ) - } catch { - NSLog("[AppDelegate] Failed to write hook config for session %@: %@", - sessionID.uuidString, error.localizedDescription) - } - } - capturedAppState.terminalReadiness[terminalID] = .claudeLaunched - } - - TerminalManager.shared.send(id: terminalID, text: text) - } - return ["sent": .bool(true)] - }, - "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 { - capturedAppState.links[sessionID, default: []].append(link) - capturedStore.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 { capturedAppState.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)] - }, - "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 = capturedAppState.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 - capturedNotifManager?.handleEvent( - sessionID: sessionID, - eventName: eventName, - payload: payload, - summary: summary - ) - - return [ - "received": .bool(true), - "session_id": .string(sessionIDStr), - "event_name": .string(eventName), - ] - } - }, - ]) - + var handlers: [String: CommandRouter.Handler] = [:] + handlers.merge(sessionHandlers(appState: appState, store: store, service: sessionService)) { _, n in n } + handlers.merge(worktreeHandlers(appState: appState, store: store, devRoot: devRoot)) { _, n in n } + handlers.merge(terminalHandlers(appState: appState, store: store, devRoot: devRoot)) { _, n in n } + handlers.merge(linkHandlers(appState: appState, store: store)) { _, n in n } + handlers.merge(hookHandlers(appState: appState, notifManager: notificationManager)) { _, n in n } + + let router = CommandRouter(handlers: handlers) let server = SocketServer(router: router) do { try server.start() @@ -922,20 +425,3 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return command } } - -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 - } - } -} diff --git a/Sources/Crow/App/RPCHandlers/HookHandlers.swift b/Sources/Crow/App/RPCHandlers/HookHandlers.swift new file mode 100644 index 0000000..4175abf --- /dev/null +++ b/Sources/Crow/App/RPCHandlers/HookHandlers.swift @@ -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), + ] + } + }, + ] +} diff --git a/Sources/Crow/App/RPCHandlers/LinkHandlers.swift b/Sources/Crow/App/RPCHandlers/LinkHandlers.swift new file mode 100644 index 0000000..5237c3c --- /dev/null +++ b/Sources/Crow/App/RPCHandlers/LinkHandlers.swift @@ -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)] + }, + ] +} diff --git a/Sources/Crow/App/RPCHandlers/RPCError.swift b/Sources/Crow/App/RPCHandlers/RPCError.swift new file mode 100644 index 0000000..8c4f8c1 --- /dev/null +++ b/Sources/Crow/App/RPCHandlers/RPCError.swift @@ -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 + } + } +} diff --git a/Sources/Crow/App/RPCHandlers/SessionHandlers.swift b/Sources/Crow/App/RPCHandlers/SessionHandlers.swift new file mode 100644 index 0000000..8d6150d --- /dev/null +++ b/Sources/Crow/App/RPCHandlers/SessionHandlers.swift @@ -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)] + } + }, + ] +} diff --git a/Sources/Crow/App/RPCHandlers/TerminalHandlers.swift b/Sources/Crow/App/RPCHandlers/TerminalHandlers.swift new file mode 100644 index 0000000..6bb1b2a --- /dev/null +++ b/Sources/Crow/App/RPCHandlers/TerminalHandlers.swift @@ -0,0 +1,136 @@ +import CrowCore +import CrowIPC +import CrowPersistence +import CrowTerminal +import Foundation + +func terminalHandlers( + appState: AppState, + store: JSONStore, + devRoot: String +) -> [String: CommandRouter.Handler] { + [ + "new-terminal": { @Sendable params in + guard let idStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: idStr), + let cwd = params["cwd"]?.stringValue else { + throw RPCError.invalidParams("session_id and cwd required") + } + // Validate cwd is within devRoot to prevent path traversal + guard Validation.isPathWithinRoot(cwd, root: devRoot) else { + throw RPCError.invalidParams("Terminal cwd must be within the configured devRoot") + } + // Resolve claude binary path if command references claude + var command = params["command"]?.stringValue + if let cmd = command, cmd.contains("claude") { + command = AppDelegate.resolveClaudeInCommand(cmd) + } + let isManaged = params["managed"]?.boolValue ?? false + let defaultName = isManaged ? "Claude Code" : "Shell" + let terminal = SessionTerminal(sessionID: sessionID, name: params["name"]?.stringValue ?? defaultName, + cwd: cwd, command: command, isManaged: isManaged) + return await MainActor.run { + appState.terminals[sessionID, default: []].append(terminal) + store.mutate { $0.terminals.append(terminal) } + // Track readiness only for managed work session terminals + if isManaged && sessionID != AppState.managerSessionID { + appState.terminalReadiness[terminal.id] = .uninitialized + TerminalManager.shared.trackReadiness(for: terminal.id) + } + // Pre-initialize in offscreen window so shell starts immediately + TerminalManager.shared.preInitialize(id: terminal.id, workingDirectory: cwd, command: command) + return ["terminal_id": .string(terminal.id.uuidString), "session_id": .string(idStr)] + } + }, + "list-terminals": { @Sendable params in + guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else { + throw RPCError.invalidParams("session_id required") + } + // Global terminals are not exposed via the session CLI + if id == AppState.globalTerminalSessionID { + return ["terminals": .array([])] + } + let terms = await MainActor.run { appState.terminals(for: id) } + let items: [JSONValue] = terms.map { t in + .object(["id": .string(t.id.uuidString), "name": .string(t.name), "session_id": .string(t.sessionID.uuidString), "managed": .bool(t.isManaged)]) + } + return ["terminals": .array(items)] + }, + "close-terminal": { @Sendable params in + guard let sessionIDStr = params["session_id"]?.stringValue, + let sessionID = UUID(uuidString: sessionIDStr), + let terminalIDStr = params["terminal_id"]?.stringValue, + let terminalID = UUID(uuidString: terminalIDStr) else { + throw RPCError.invalidParams("session_id and terminal_id required") + } + return try await MainActor.run { + guard let terminals = appState.terminals[sessionID], + let terminal = terminals.first(where: { $0.id == terminalID }) else { + throw RPCError.applicationError("Terminal not found") + } + guard !terminal.isManaged else { + throw RPCError.applicationError("Cannot close managed terminal") + } + TerminalManager.shared.destroy(id: terminalID) + appState.terminals[sessionID]?.removeAll { $0.id == terminalID } + appState.terminalReadiness.removeValue(forKey: terminalID) + appState.autoLaunchTerminals.remove(terminalID) + if appState.activeTerminalID[sessionID] == terminalID { + appState.activeTerminalID[sessionID] = appState.terminals[sessionID]?.first?.id + } + store.mutate { data in data.terminals.removeAll { $0.id == terminalID } } + return ["deleted": .bool(true)] + } + }, + "send": { @Sendable params in + guard let sessionIDStr = params["session_id"]?.stringValue, + let sessionID = UUID(uuidString: sessionIDStr), + let terminalIDStr = params["terminal_id"]?.stringValue, + let terminalID = UUID(uuidString: terminalIDStr), + var text = params["text"]?.stringValue else { + throw RPCError.invalidParams("session_id, terminal_id, and text required") + } + // Process escape sequences: literal \n in the text becomes a real newline + text = text.replacingOccurrences(of: "\\n", with: "\n") + text = text.replacingOccurrences(of: "\\t", with: "\t") + NSLog("crow send: text length=\(text.count), ends_with_newline=\(text.hasSuffix("\n")), ends_with_cr=\(text.hasSuffix("\r"))") + await MainActor.run { + // If the surface doesn't exist yet, pre-initialize it so the shell starts + if TerminalManager.shared.existingSurface(for: terminalID) == nil { + if let terminals = appState.terminals[sessionID], + let terminal = terminals.first(where: { $0.id == terminalID }) { + TerminalManager.shared.preInitialize( + id: terminalID, + workingDirectory: terminal.cwd, + command: terminal.command + ) + } + } + + // For managed terminals receiving a claude command, write hook config + // before sending so Claude picks up the hooks on startup. + if let terminals = appState.terminals[sessionID], + let terminal = terminals.first(where: { $0.id == terminalID }), + terminal.isManaged, + text.contains("claude") { + if let worktree = appState.primaryWorktree(for: sessionID), + let crowPath = HookConfigGenerator.findCrowBinary() { + do { + try HookConfigGenerator.writeHookConfig( + worktreePath: worktree.worktreePath, + sessionID: sessionID, + crowPath: crowPath + ) + } catch { + NSLog("[AppDelegate] Failed to write hook config for session %@: %@", + sessionID.uuidString, error.localizedDescription) + } + } + appState.terminalReadiness[terminalID] = .claudeLaunched + } + + TerminalManager.shared.send(id: terminalID, text: text) + } + return ["sent": .bool(true)] + }, + ] +} diff --git a/Sources/Crow/App/RPCHandlers/WorktreeHandlers.swift b/Sources/Crow/App/RPCHandlers/WorktreeHandlers.swift new file mode 100644 index 0000000..ec15b62 --- /dev/null +++ b/Sources/Crow/App/RPCHandlers/WorktreeHandlers.swift @@ -0,0 +1,48 @@ +import CrowCore +import CrowIPC +import CrowPersistence +import Foundation + +func worktreeHandlers( + appState: AppState, + store: JSONStore, + devRoot: String +) -> [String: CommandRouter.Handler] { + [ + "add-worktree": { @Sendable params in + guard let idStr = params["session_id"]?.stringValue, let sessionID = UUID(uuidString: idStr), + let repo = params["repo"]?.stringValue, !repo.isEmpty, + let path = params["path"]?.stringValue, !path.isEmpty, + let branch = params["branch"]?.stringValue, !branch.isEmpty else { + throw RPCError.invalidParams("session_id, repo, path, branch required (non-empty)") + } + // Validate path is within devRoot to prevent path traversal + guard Validation.isPathWithinRoot(path, root: devRoot) else { + throw RPCError.invalidParams("Worktree path must be within the configured devRoot") + } + // repo_path is the main repo (for git commands). Defaults to path if not provided. + let repoPath = params["repo_path"]?.stringValue ?? path + guard Validation.isPathWithinRoot(repoPath, root: devRoot) else { + throw RPCError.invalidParams("repo_path must be within the configured devRoot") + } + let wt = SessionWorktree(sessionID: sessionID, repoName: repo, repoPath: repoPath, worktreePath: path, + branch: branch, isPrimary: params["primary"]?.boolValue ?? false) + return await MainActor.run { + appState.worktrees[sessionID, default: []].append(wt) + store.mutate { $0.worktrees.append(wt) } + return ["worktree_id": .string(wt.id.uuidString), "session_id": .string(idStr), "path": .string(path)] + } + }, + "list-worktrees": { @Sendable params in + guard let idStr = params["session_id"]?.stringValue, let id = UUID(uuidString: idStr) else { + throw RPCError.invalidParams("session_id required") + } + let wts = await MainActor.run { appState.worktrees(for: id) } + let items: [JSONValue] = wts.map { wt in + .object(["id": .string(wt.id.uuidString), "repo": .string(wt.repoName), "path": .string(wt.worktreePath), + "branch": .string(wt.branch), "primary": .bool(wt.isPrimary)]) + } + return ["worktrees": .array(items)] + }, + ] +}