diff --git a/README.md b/README.md index 7af69dc2..ff70c47f 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,12 @@ CodeIsland lives in your MacBook's notch area and shows you what your AI coding agents are doing — in real time. No more switching windows to check if Claude is waiting for approval or if Codex finished its task. -It connects to **13 AI coding tools** via Unix socket IPC, displaying session status, tool calls, permission requests, and more — all in a compact, pixel-art styled panel. +It connects to **14 AI coding tools** via Unix socket IPC, displaying session status, tool calls, permission requests, and more — all in a compact, pixel-art styled panel. ## Features - **Notch-native UI** — Expands from the MacBook notch, collapses when idle -- **13 AI tools supported** — Claude Code, Codex, Gemini CLI, Cursor, Copilot, Trae/Traecli, Qoder, Factory, CodeBuddy, OpenCode, Kimi Code CLI, Cline, Pi / Oh My Pi +- **14 AI tools supported** — Claude Code, Codex, Grok CLI, Gemini CLI, Cursor, Copilot, Trae/Traecli, Qoder, Factory, CodeBuddy, OpenCode, Kimi Code CLI, Cline, Pi / Oh My Pi - **Live status tracking** — See active sessions, tool calls, and AI responses in real time - **Permission management** — Approve/deny tool permissions directly from the panel - **Question answering** — Respond to agent questions without leaving your current app @@ -45,6 +45,7 @@ It connects to **13 AI coding tools** via Unix socket IPC, displaying session st |:---:|------|--------|------|--------| | | Claude Code | 13 | Terminal tab | Full | | | Codex | 3 | Terminal | Basic | +| | Grok CLI | 14 | Terminal | Basic | | | Gemini CLI | 6 | Terminal | Full | | | Cursor | 10 | IDE | Full | | | TraeCli | 10 | Terminal | Full | diff --git a/README.zh-CN.md b/README.zh-CN.md index 7f6370fb..95b051bf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,12 +21,12 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的工作状态。不用再频繁切窗口去看 Claude 是否在等审批、Codex 是否完成了任务。 -它通过 Unix socket IPC 连接 **12 种 AI 编码工具**,在刘海面板中展示会话状态、工具调用、权限请求等信息——全部呈现在一个紧凑的像素风面板中。 +它通过 Unix socket IPC 连接 **14 种 AI 编码工具**,在刘海面板中展示会话状态、工具调用、权限请求等信息——全部呈现在一个紧凑的像素风面板中。 ## 功能特性 - **刘海原生 UI** — 从 MacBook 刘海处展开,空闲时自动收起 -- **支持 12 种 AI 工具** — Claude Code、Codex、Gemini CLI、Cursor、Copilot、Trae/Traecli、Qoder、Factory、CodeBuddy、OpenCode、Kimi Code CLI、Cline +- **支持 14 种 AI 工具** — Claude Code、Codex、Grok CLI、Gemini CLI、Cursor、Copilot、Trae/Traecli、Qoder、Factory、CodeBuddy、OpenCode、Kimi Code CLI、Cline、Pi / Oh My Pi - **实时状态追踪** — 查看活跃会话、工具调用和 AI 回复 - **权限管理** — 直接在面板上审批/拒绝工具权限请求 - **问题回答** — 无需离开当前应用即可回答 Agent 的问题 @@ -45,6 +45,7 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的 |:---:|------|------|------|------| | | Claude Code | 13 | 终端标签页 | 完整 | | | Codex | 3 | 终端 | 基础 | +| | Grok CLI | 14 | 终端 | 基础 | | | Gemini CLI | 6 | 终端 | 完整 | | | Cursor | 10 | IDE | 完整 | | | TraeCli | 10 | 终端 | 完整 | @@ -55,6 +56,7 @@ CodeIsland 住在你 MacBook 的刘海区域,实时展示 AI 编码 Agent 的 | | Kimi Code CLI | 10 | 终端 | 完整 | | | OpenCode | All | APP/终端 | 完整 | | | Cline | 5 | VSCode | 完整 | +| | Pi / Oh My Pi | 8 | 终端 | 完整 | ## 安装 diff --git a/Sources/CodeIsland/AppState.swift b/Sources/CodeIsland/AppState.swift index d3715c7f..957f8569 100644 --- a/Sources/CodeIsland/AppState.swift +++ b/Sources/CodeIsland/AppState.swift @@ -813,6 +813,7 @@ final class AppState { case "google-antigravity": return findGoogleAntigravityPids(candidatePids: candidatePids) case "workbuddy": return findWorkBuddyPids(candidatePids: candidatePids) case "hermes": return findHermesPids(candidatePids: candidatePids) + case "grok": return findGrokPids(candidatePids: candidatePids) case "qwen": return findQwenPids(candidatePids: candidatePids) case "kimi": return findKimiPids(candidatePids: candidatePids) case "pi": return findPiPids(candidatePids: candidatePids) @@ -2092,6 +2093,8 @@ final class AppState { return readModelFromCopilotStore(cwd: session.cwd, processStart: processStart) case "opencode": return readModelFromOpenCodeStore(cwd: session.cwd, processStart: processStart) + case "grok": + return readModelFromGrokStore(cwd: session.cwd, processStart: processStart) default: return nil } @@ -2292,6 +2295,11 @@ final class AppState { } } + private nonisolated static func readModelFromGrokStore(cwd: String?, processStart: Date?) -> String? { + guard let cwd else { return nil } + return findRecentGrokSession(cwd: cwd, after: processStart)?.model + } + // MARK: - Session Discovery (FSEventStream + process scan) // MARK: - Session Persistence @@ -2434,6 +2442,9 @@ final class AppState { if ConfigInstaller.isEnabled(source: "kimi") { discovered.append(contentsOf: findActiveKimiSessions(candidatePids: candidatePids)) } + if ConfigInstaller.isEnabled(source: "grok") { + discovered.append(contentsOf: findActiveGrokSessions(candidatePids: candidatePids)) + } if ConfigInstaller.isEnabled(source: "cline") { discovered.append(contentsOf: findActiveClineSessions(candidatePids: candidatePids)) } @@ -2454,6 +2465,7 @@ final class AppState { ("opencode", "\(home)/.local/share/opencode"), ("kimi", "\(home)/.kimi-code/sessions"), ("kimi", "\(home)/.kimi/sessions"), + ("grok", "\(ConfigInstaller.grokHome())/sessions"), ] let fm = FileManager.default var roots = candidates.compactMap { source, path -> String? in @@ -3815,6 +3827,13 @@ final class AppState { ) } + private nonisolated static func findGrokPids(candidatePids: [pid_t]? = nil) -> [pid_t] { + (candidatePids ?? allProcessIds()).filter { pid in + guard let path = executablePath(for: pid) else { return false } + return CLIProcessResolver.sourceMatchesExecutablePath(path, source: "grok") + } + } + private nonisolated static func findPiPids(candidatePids: [pid_t]? = nil) -> [pid_t] { findPids( matchingPathSubstrings: [ @@ -4084,6 +4103,239 @@ final class AppState { return (nil, Array(messages.suffix(3))) } + /// Grok percent-encodes the full cwd into a single directory component, + /// including `/` as `%2F` (for example `/Users/me` -> `%2FUsers%2Fme`). + nonisolated static func grokEncodedCwd(_ cwd: String) -> String? { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return cwd.addingPercentEncoding(withAllowedCharacters: allowed) + } + + private struct GrokSessionCandidate { + let sessionId: String + let directory: String + let model: String? + let createdAt: Date? + let activityAt: Date + } + + /// Score a metadata session against a live Grok process. Grok's native + /// hooks are authoritative once a turn starts; discovery is only a recovery + /// path, so it must prefer missing a late `/new` over attaching a completed + /// one-shot session to an unrelated long-lived process in the same cwd. + nonisolated static func grokSessionProcessMatchScore( + createdAt: Date?, + activityAt: Date, + processStart: Date?, + now: Date = Date() + ) -> TimeInterval? { + guard let processStart else { + let age = now.timeIntervalSince(activityAt) + guard age >= -10, age <= 30 else { return nil } + return 1_000 + max(age, 0) + } + + guard activityAt >= processStart.addingTimeInterval(-10) else { return nil } + + if let createdAt { + let creationDelta = createdAt.timeIntervalSince(processStart) + if abs(creationDelta) <= 120 { + return abs(creationDelta) + } + + // A resumed session was created before this process. Accept it only + // while its first recovered activity is still close to launch. + if createdAt < processStart { + let activityDelta = activityAt.timeIntervalSince(processStart) + if activityDelta >= -10, activityDelta <= 120 { + return 300 + abs(activityDelta) + } + } + return nil + } + + let activityDelta = activityAt.timeIntervalSince(processStart) + guard activityDelta >= -10, activityDelta <= 120 else { return nil } + return 600 + abs(activityDelta) + } + + /// Produce a one-to-one mapping for Grok processes that share a cwd. The + /// newest viable session is assigned first and prefers its closest process + /// start. An augmenting path may move an earlier assignment to its next-best + /// process when that is required to keep another viable session visible. + nonisolated static func matchGrokSessionsToProcesses( + processes: [(pid: pid_t, startedAt: Date?)], + sessions: [(id: String, createdAt: Date?, activityAt: Date)], + now: Date = Date() + ) -> [String: pid_t] { + typealias Edge = (pid: pid_t, score: TimeInterval) + let orderedSessions = sessions.sorted { lhs, rhs in + if lhs.activityAt != rhs.activityAt { + return lhs.activityAt > rhs.activityAt + } + return lhs.id < rhs.id + } + var edgesBySession: [String: [Edge]] = [:] + + for session in orderedSessions where edgesBySession[session.id] == nil { + edgesBySession[session.id] = processes.compactMap { process in + guard let score = grokSessionProcessMatchScore( + createdAt: session.createdAt, + activityAt: session.activityAt, + processStart: process.startedAt, + now: now + ) else { return nil } + return (process.pid, score) + }.sorted { lhs, rhs in + if lhs.score != rhs.score { + return lhs.score < rhs.score + } + return lhs.pid < rhs.pid + } + } + + var sessionByPid: [pid_t: String] = [:] + + func assign(_ sessionId: String, visitedPids: inout Set) -> Bool { + guard let edges = edgesBySession[sessionId] else { return false } + + // Preserve earlier (newer) choices when this session has an unused + // viable PID of its own. + if let freeEdge = edges.first(where: { + !visitedPids.contains($0.pid) && sessionByPid[$0.pid] == nil + }) { + visitedPids.insert(freeEdge.pid) + sessionByPid[freeEdge.pid] = sessionId + return true + } + + // Otherwise find an augmenting path: move the current owner to its + // next-best PID, then claim the newly released one. + for edge in edges where visitedPids.insert(edge.pid).inserted { + guard let displacedSession = sessionByPid[edge.pid] else { + sessionByPid[edge.pid] = sessionId + return true + } + if assign(displacedSession, visitedPids: &visitedPids) { + sessionByPid[edge.pid] = sessionId + return true + } + } + return false + } + + var attemptedSessions: Set = [] + for session in orderedSessions where attemptedSessions.insert(session.id).inserted { + var visitedPids: Set = [] + _ = assign(session.id, visitedPids: &visitedPids) + } + + return Dictionary(uniqueKeysWithValues: sessionByPid.map { ($0.value, $0.key) }) + } + + private nonisolated static func grokSessionCandidates( + cwd: String, + fm: FileManager = .default + ) -> [GrokSessionCandidate] { + guard let encodedCwd = grokEncodedCwd(cwd) else { return [] } + let cwdDirectory = "\(ConfigInstaller.grokHome())/sessions/\(encodedCwd)" + guard let sessionDirectories = try? fm.contentsOfDirectory(atPath: cwdDirectory) else { return [] } + + var candidates: [GrokSessionCandidate] = [] + for directoryName in sessionDirectories { + let directory = "\(cwdDirectory)/\(directoryName)" + let summaryPath = "\(directory)/summary.json" + guard let data = fm.contents(atPath: summaryPath), + let summary = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let info = summary["info"] as? [String: Any], + let summaryCwd = info["cwd"] as? String, + summaryCwd == cwd else { continue } + + let sessionId = (info["id"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? directoryName + let createdAt = (summary["created_at"] as? String).flatMap(parseISO8601Timestamp) + let timestamps = ["last_active_at", "updated_at", "created_at"] + .compactMap { summary[$0] as? String } + .compactMap(parseISO8601Timestamp) + var activityAt = timestamps.max() ?? .distantPast + + // File mtimes catch a turn that has started writing events before + // summary.json has been refreshed. + for filename in ["summary.json", "events.jsonl", "updates.jsonl", "chat_history.jsonl"] { + let path = "\(directory)/\(filename)" + if let attrs = try? fm.attributesOfItem(atPath: path), + let modified = attrs[.modificationDate] as? Date, + modified > activityAt { + activityAt = modified + } + } + + candidates.append(GrokSessionCandidate( + sessionId: sessionId, + directory: directory, + model: (summary["current_model_id"] as? String).flatMap { $0.isEmpty ? nil : $0 }, + createdAt: createdAt, + activityAt: activityAt + )) + } + return candidates + } + + private nonisolated static func findRecentGrokSession( + cwd: String, + after processStart: Date?, + fm: FileManager = .default + ) -> GrokSessionCandidate? { + grokSessionCandidates(cwd: cwd, fm: fm) + .filter { + grokSessionProcessMatchScore( + createdAt: $0.createdAt, + activityAt: $0.activityAt, + processStart: processStart + ) != nil + } + .max { $0.activityAt < $1.activityAt } + } + + private nonisolated static func findActiveGrokSessions(candidatePids: [pid_t]? = nil) -> [DiscoveredSession] { + let grokPids = findGrokPids(candidatePids: candidatePids) + guard !grokPids.isEmpty else { return [] } + + let fm = FileManager.default + let liveProcesses = grokPids.compactMap { pid -> (pid: pid_t, cwd: String, startedAt: Date?)? in + guard let cwd = getCwd(for: pid), !cwd.isEmpty, !isSubagentWorktree(cwd) else { return nil } + return (pid, cwd, getProcessStartTime(pid)) + } + let processGroups = Dictionary(grouping: liveProcesses) { $0.cwd } + + var results: [DiscoveredSession] = [] + for (cwd, processes) in processGroups { + let candidates = grokSessionCandidates(cwd: cwd, fm: fm) + let assignments = matchGrokSessionsToProcesses( + processes: processes.map { ($0.pid, $0.startedAt) }, + sessions: candidates.map { ($0.sessionId, $0.createdAt, $0.activityAt) } + ) + + for candidate in candidates { + guard let pid = assignments[candidate.sessionId] else { continue } + let chatPath = "\(candidate.directory)/chat_history.jsonl" + let hasChat = fm.fileExists(atPath: chatPath) + let messages = hasChat ? readRecentFromTranscript(path: chatPath).1 : [] + results.append(DiscoveredSession( + sessionId: candidate.sessionId, + cwd: cwd, + tty: nil, + model: candidate.model, + pid: pid, + modifiedAt: candidate.activityAt, + recentMessages: messages, + source: "grok", + transcriptPath: hasChat ? chatPath : nil + )) + } + } + return results + } + private nonisolated static func findCopilotPids(candidatePids: [pid_t]? = nil) -> [pid_t] { findPids( matchingPathSubstrings: [], diff --git a/Sources/CodeIsland/ConfigInstaller.swift b/Sources/CodeIsland/ConfigInstaller.swift index b049a70d..9d7e5907 100644 --- a/Sources/CodeIsland/ConfigInstaller.swift +++ b/Sources/CodeIsland/ConfigInstaller.swift @@ -237,6 +237,25 @@ struct ConfigInstaller { return "~/.kimi-code/config.toml" } + // MARK: - Grok Build home resolution + + /// Resolve Grok Build's config/session root. Grok documents `GROK_HOME` + /// as the override for the default `~/.grok` directory. + static func grokHome() -> String { + let raw = (ProcessInfo.processInfo.environment["GROK_HOME"] ?? "") + .trimmingCharacters(in: .whitespaces) + guard !raw.isEmpty else { return NSHomeDirectory() + "/.grok" } + if raw == "~" { return NSHomeDirectory() } + if raw.hasPrefix("~/") { return NSHomeDirectory() + "/" + raw.dropFirst(2) } + return raw + } + + static func displayGrokPath(filename: String) -> String { + let raw = (ProcessInfo.processInfo.environment["GROK_HOME"] ?? "") + .trimmingCharacters(in: .whitespaces) + return raw.isEmpty ? "~/.grok/\(filename)" : "$GROK_HOME/\(filename)" + } + // MARK: - All supported CLIs private static let builtInCLIs: [CLIConfig] = [ @@ -454,6 +473,17 @@ struct ConfigInstaller { format: .hermes, events: defaultEvents(for: .hermes) ), + // Grok Build CLI — native global hooks are standalone JSON files under + // $GROK_HOME/hooks. Use `.nested` rather than the Claude format because + // Grok rejects `matcher` on lifecycle events such as SessionStart/Stop. + CLIConfig( + name: "Grok CLI", source: "grok", + configPath: "hooks/codeisland.json", configKey: "hooks", + format: .nested, + events: GrokHookForwardingPolicy.managedHookEvents.map { ($0, 5, false) }, + rootOverride: { ConfigInstaller.grokHome() }, + displayPathOverride: { ConfigInstaller.displayGrokPath(filename: "hooks/codeisland.json") } + ), // Qwen Code — timeout in milliseconds CLIConfig( name: "Qwen Code", source: "qwen", @@ -971,6 +1001,7 @@ struct ConfigInstaller { if source == "pi" { return FileManager.default.fileExists(atPath: piAgentDir) } if source == "omp" { return FileManager.default.fileExists(atPath: ompAgentDir) } if source == "openclaw" { return FileManager.default.fileExists(atPath: openclawDir) } + if source == "grok" { return FileManager.default.fileExists(atPath: grokHome()) } if source == "copilot" { return FileManager.default.fileExists(atPath: NSHomeDirectory() + "/.copilot") } if source == "cline" { let fm = FileManager.default @@ -1069,6 +1100,10 @@ struct ConfigInstaller { let dirExists: Bool if cli.format == .copilot { dirExists = fm.fileExists(atPath: NSHomeDirectory() + "/.copilot") + } else if cli.source == "grok" { + // The hooks directory does not exist on a fresh Grok install; + // its parent $GROK_HOME is the installation marker. + dirExists = fm.fileExists(atPath: grokHome()) } else if cli.source == "pi" { dirExists = fm.fileExists(atPath: piAgentDir) } else if cli.source == "omp" { @@ -1381,6 +1416,15 @@ struct ConfigInstaller { if !fm.fileExists(atPath: cli.dirPath) { try? fm.createDirectory(atPath: cli.dirPath, withIntermediateDirectories: true) } + } else if cli.source == "grok" { + // Grok discovers every JSON file in $GROK_HOME/hooks. A managed + // install may not have created that subdirectory yet, so gate on + // its existing root and create only the hooks child directory. + let rootDir = (cli.dirPath as NSString).deletingLastPathComponent + guard fm.fileExists(atPath: rootDir) else { return true } + if !fm.fileExists(atPath: cli.dirPath) { + try? fm.createDirectory(atPath: cli.dirPath, withIntermediateDirectories: true) + } } else if cli.format == .kiroAgent { // Kiro: check ~/.kiro exists; create agents/ subdir if needed. let kiroRoot = NSHomeDirectory() + "/.kiro" diff --git a/Sources/CodeIsland/GrokView.swift b/Sources/CodeIsland/GrokView.swift new file mode 100644 index 00000000..035e2f09 --- /dev/null +++ b/Sources/CodeIsland/GrokView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +/// Grok's February 2025 black-and-white geometric mark. +/// +/// The two paths below preserve the published SVG geometry exactly. Keep the +/// mark static and unmodified; session state is communicated by the surrounding +/// CodeIsland UI rather than by transforming the trademark. +/// Source: https://upload.wikimedia.org/wikipedia/commons/f/f7/Grok-feb-2025-logo.svg +struct GrokView: View { + let status: MascotAgentStatus + var size: CGFloat = 27 + + var body: some View { + GrokMark() + .fill(Color.white) + .frame(width: size * 0.68, height: size * 0.68) + .frame(width: size, height: size) + .accessibilityLabel("Grok") + } +} + +/// Exact geometry from the two `#mark` paths in Grok's February 2025 SVG. +/// Source view-box bounds for the mark are x: 0...33.6964, y: 0.5...32.5. +private struct GrokMark: Shape { + func path(in rect: CGRect) -> Path { + let sourceWidth: CGFloat = 33.6964 + let sourceHeight: CGFloat = 32.0 + let sourceMinY: CGFloat = 0.5 + let scale = min(rect.width / sourceWidth, rect.height / sourceHeight) + let offsetX = rect.midX - sourceWidth * scale / 2 + let offsetY = rect.midY - sourceHeight * scale / 2 + + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + CGPoint( + x: offsetX + x * scale, + y: offsetY + (y - sourceMinY) * scale + ) + } + + var path = Path() + + path.move(to: point(13.2371, 21.0407)) + path.addLine(to: point(24.3186, 12.8506)) + path.addCurve( + to: point(25.8973, 13.2294), + control1: point(24.8619, 12.4491), + control2: point(25.6384, 12.6057) + ) + path.addCurve( + to: point(23.9403, 23.1851), + control1: point(27.2597, 16.5185), + control2: point(26.651, 20.4712) + ) + path.addCurve( + to: point(14.0108, 25.1386), + control1: point(21.2297, 25.8989), + control2: point(17.4581, 26.4941) + ) + path.addLine(to: point(10.2449, 26.8843)) + path.addCurve( + to: point(26.304, 25.5601), + control1: point(15.6463, 30.5806), + control2: point(22.2053, 29.6665) + ) + path.addCurve( + to: point(29.6205, 13.8673), + control1: point(29.5551, 22.3051), + control2: point(30.562, 17.8683) + ) + path.addLine(to: point(29.629, 13.8758)) + path.addCurve( + to: point(33.449, 0.844576), + control1: point(28.2637, 7.99809), + control2: point(29.9647, 5.64871) + ) + path.addCurve( + to: point(33.6964, 0.5), + control1: point(33.5314, 0.730667), + control2: point(33.6139, 0.616757) + ) + path.addLine(to: point(29.1113, 5.09055)) + path.addLine(to: point(29.1113, 5.07631)) + path.addLine(to: point(13.2343, 21.0436)) + path.closeSubpath() + + path.move(to: point(10.9503, 23.0313)) + path.addCurve( + to: point(11.0498, 10.2763), + control1: point(7.07343, 19.3235), + control2: point(7.74185, 13.5853) + ) + path.addCurve( + to: point(21.0021, 8.2971), + control1: point(13.4959, 7.82722), + control2: point(17.5036, 6.82767) + ) + path.addLine(to: point(24.7595, 6.55998)) + path.addCurve( + to: point(22.2195, 5.17313), + control1: point(24.0826, 6.07017), + control2: point(23.215, 5.54334) + ) + path.addCurve( + to: point(8.67479, 7.90126), + control1: point(17.7198, 3.31926), + control2: point(12.3326, 4.24192) + ) + path.addCurve( + to: point(5.94992, 21.4622), + control1: point(5.15635, 11.4239), + control2: point(4.0499, 16.8403) + ) + path.addCurve( + to: point(2.69884, 29.826), + control1: point(7.36924, 24.9165), + control2: point(5.04257, 27.3598) + ) + path.addCurve( + to: point(0.36364, 32.5), + control1: point(1.86829, 30.7002), + control2: point(1.0349, 31.5745) + ) + path.addLine(to: point(10.9474, 23.0341)) + path.closeSubpath() + + return path + } +} diff --git a/Sources/CodeIsland/MascotView.swift b/Sources/CodeIsland/MascotView.swift index 1558a5df..1914d934 100644 --- a/Sources/CodeIsland/MascotView.swift +++ b/Sources/CodeIsland/MascotView.swift @@ -26,6 +26,8 @@ struct MascotView: View { switch source { case "codex": DexView(status: status, size: size) + case "grok": + GrokView(status: status, size: size) case "gemini", "google-antigravity": // Google Antigravity is Gemini-based — reuse the Gemini mascot. GeminiView(status: status, size: size) diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index a9c2983f..089cd461 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -2856,6 +2856,7 @@ private let cliIconFiles: [String: String] = [ "stepfun": "stepfun", "workbuddy": "workbuddy", "hermes": "hermes", + "grok": "grok", "qwen": "qwen", "kimi": "kimi", "pi": "pi", diff --git a/Sources/CodeIsland/Resources/cli-icons/grok.png b/Sources/CodeIsland/Resources/cli-icons/grok.png new file mode 100644 index 00000000..f2d7ccc2 Binary files /dev/null and b/Sources/CodeIsland/Resources/cli-icons/grok.png differ diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 02dfcb31..fff2b28b 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -1049,6 +1049,7 @@ private struct MascotsPage: View { private let mascotList: [(name: String, source: String, desc: String, color: Color)] = [ ("Clawd", "claude", "Claude Code", Color(red: 0.871, green: 0.533, blue: 0.427)), ("Dex", "codex", "Codex (OpenAI)", Color(red: 0.92, green: 0.92, blue: 0.93)), + ("Grok", "grok", "Grok CLI", Color.white), ("Gemini", "gemini", "Gemini CLI", Color(red: 0.278, green: 0.588, blue: 0.894)), ("CursorBot", "cursor", "Cursor", Color(red: 0.96, green: 0.31, blue: 0.0)), ("TraeBot", "trae", "Trae", Color(red: 0.96, green: 0.31, blue: 0.0)), diff --git a/Sources/CodeIslandBridge/main.swift b/Sources/CodeIslandBridge/main.swift index a5f9e4e4..20d718a9 100644 --- a/Sources/CodeIslandBridge/main.swift +++ b/Sources/CodeIslandBridge/main.swift @@ -221,6 +221,18 @@ if let idx = args.firstIndex(of: "--event"), idx + 1 < args.count { // Quick exit: skip if CODEISLAND_SKIP is set guard env["CODEISLAND_SKIP"] == nil else { exit(0) } +// Grok Build imports ~/.claude and ~/.cursor hooks for compatibility in +// addition to its own $GROK_HOME/hooks files. Once the dedicated CodeIsland +// Grok hook is installed, forwarding those imported copies would deliver every +// lifecycle event two or three times (and can mislabel it as Claude/Cursor). +// Grok injects these variables for every hook subprocess, so keep only the +// explicitly managed `--source grok` invocation in that runtime. Keep this +// decision in CodeIslandCore so the production gate is directly testable. +let isGrokRuntime = GrokHookForwardingPolicy.isGrokRuntime(environment: env) +if !GrokHookForwardingPolicy.shouldForward(source: sourceTag, environment: env) { + exit(0) +} + // Quick exit: socket doesn't exist or isn't a socket var statBuf = stat() guard stat(socketPath, &statBuf) == 0, (statBuf.st_mode & S_IFMT) == S_IFSOCK else { exit(0) } @@ -239,6 +251,25 @@ guard !input.isEmpty, exit(0) } +// Grok normally includes these camelCase fields on stdin. The environment +// fallbacks cover hook variants and make the bridge resilient to truncated +// passive payloads without inventing a PID-based session identifier. +if isGrokRuntime { + if json["hookEventName"] == nil, + let event = nonEmptyString(env["GROK_HOOK_EVENT"]) { + json["hookEventName"] = event + } + if json["sessionId"] == nil, + let sessionId = nonEmptyString(env["GROK_SESSION_ID"]) { + json["sessionId"] = sessionId + } + if json["cwd"] == nil, + let workspaceRoot = nonEmptyString(json["workspaceRoot"]) + ?? nonEmptyString(env["GROK_WORKSPACE_ROOT"]) { + json["cwd"] = workspaceRoot + } +} + // Generic compatibility: accept common camelCase aliases from third-party forks if json["hook_event_name"] == nil { if let event = nonEmptyString(json["hookEventName"]) { @@ -274,6 +305,35 @@ if json["transcript_path"] == nil, let tp = nonEmptyString(json["transcriptPath" json["transcript_path"] = tp } +// Grok's hook payload does not carry a transcript path. Its documented session +// layout is deterministic, and chat_history.jsonl uses the user/assistant row +// shapes already understood by CodeIsland's incremental tailer. +if isGrokRuntime, + json["transcript_path"] == nil, + let sessionId = nonEmptyString(json["session_id"]), + let cwd = nonEmptyString(json["cwd"]) { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + if let encodedCwd = cwd.addingPercentEncoding(withAllowedCharacters: allowed) { + let rawHome = nonEmptyString(env["GROK_HOME"]) + let grokHome: String + if let rawHome { + if rawHome == "~" { + grokHome = FileManager.default.homeDirectoryForCurrentUser.path + } else if rawHome.hasPrefix("~/") { + grokHome = FileManager.default.homeDirectoryForCurrentUser.path + + "/" + rawHome.dropFirst(2) + } else { + grokHome = rawHome + } + } else { + grokHome = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".grok").path + } + json["transcript_path"] = "\(grokHome)/sessions/\(encodedCwd)/\(sessionId)/chat_history.jsonl" + } +} + // Copilot CLI adaptation: its stdin JSON lacks session_id and hook_event_name. // Normalize Copilot's camelCase payload and pass through sessionId when present. if sourceTag == "copilot" { diff --git a/Sources/CodeIslandCore/EventNormalizer.swift b/Sources/CodeIslandCore/EventNormalizer.swift index f16b067a..5273ea9c 100644 --- a/Sources/CodeIslandCore/EventNormalizer.swift +++ b/Sources/CodeIslandCore/EventNormalizer.swift @@ -38,6 +38,10 @@ public enum EventNormalizer { case "post_tool_use": return "PostToolUse" case "post_tool_use_failure": return "PostToolUseFailure" case "permission_request": return "PermissionRequest" + case "permission_denied": return "PermissionDenied" + // Grok emits StopFailure when a turn ends on an API error. It is still + // terminal for Island state, so fold both wire spellings onto Stop. + case "stop_failure", "StopFailure": return "Stop" case "subagent_start": return "SubagentStart" case "subagent_stop": return "SubagentStop" case "pre_compact": return "PreCompact" diff --git a/Sources/CodeIslandCore/GrokHookForwardingPolicy.swift b/Sources/CodeIslandCore/GrokHookForwardingPolicy.swift new file mode 100644 index 00000000..c2d16eb8 --- /dev/null +++ b/Sources/CodeIslandCore/GrokHookForwardingPolicy.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Keeps CodeIsland's dedicated Grok hook while suppressing duplicate hooks +/// imported by Grok from Claude Code and Cursor configurations. +public enum GrokHookForwardingPolicy { + /// Hook events installed in `$GROK_HOME/hooks/codeisland.json`. + public static let managedHookEvents = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PermissionDenied", + "Stop", + "StopFailure", + "Notification", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + "SessionEnd", + ] + + /// Grok sets at least one of these variables for hook subprocesses. + /// Whitespace-only values are treated as absent. + public static func isGrokRuntime(environment: [String: String]) -> Bool { + isNonEmpty(environment["GROK_SESSION_ID"]) + || isNonEmpty(environment["GROK_HOOK_EVENT"]) + } + + /// Outside Grok, all existing hook sources keep their original behavior. + /// Inside Grok, only CodeIsland's explicitly managed `--source grok` hook + /// is forwarded; imported Claude/Cursor hooks are duplicate deliveries. + public static func shouldForward( + source: String?, + environment: [String: String] + ) -> Bool { + !isGrokRuntime(environment: environment) || source == "grok" + } + + private static func isNonEmpty(_ value: String?) -> Bool { + guard let value else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Sources/CodeIslandCore/Models.swift b/Sources/CodeIslandCore/Models.swift index b0a46241..a9d4c324 100644 --- a/Sources/CodeIslandCore/Models.swift +++ b/Sources/CodeIslandCore/Models.swift @@ -22,6 +22,15 @@ public enum CLIProcessResolver { || lowercasedPath.contains("/qwen-code ") case "gemini": return lowercasedPath.hasSuffix("/gemini") || lowercasedPath.contains("/gemini ") + case "grok": + // Managed Grok installs execute the versioned binary directly from + // $GROK_HOME/downloads (e.g. grok-0.2.106-macos-aarch64), while + // package-manager installs may expose a plain `.../bin/grok`. + let basename = (lowercasedPath as NSString).lastPathComponent + return basename == "grok" + || (basename.hasPrefix("grok-") + && (lowercasedPath.contains("/.grok/downloads/") + || lowercasedPath.contains("/grok/downloads/"))) case "cursor-cli": // Cursor's CLI agent installs to ~/.local/share/cursor-agent/versions//cursor-agent // and is also referenced by /cursor-agent/index.js when invoked via Node. diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift index 789b2f23..4843e37c 100644 --- a/Sources/CodeIslandCore/SessionSnapshot.swift +++ b/Sources/CodeIslandCore/SessionSnapshot.swift @@ -31,6 +31,7 @@ public struct SessionSnapshot: Sendable { "google-antigravity", "workbuddy", "hermes", + "grok", "openclaw", "qwen", "kimi", @@ -171,6 +172,10 @@ public struct SessionSnapshot: Sendable { "hermes-agents": "hermes", "hermes agent": "hermes", "hermes agents": "hermes", + "grok-cli": "grok", + "grokcli": "grok", + "grok-build": "grok", + "grok build": "grok", // OpenClaw (openclaw.ai) — personal-assistant Gateway daemon, // formerly branded Clawdbot. Keep the old name as an alias so // events from a not-yet-renamed install land on the same source. @@ -222,6 +227,7 @@ public struct SessionSnapshot: Sendable { if canonical.hasPrefix("antigravity") { return "antigravity" } if canonical.hasPrefix("workbuddy") { return "workbuddy" } if canonical.hasPrefix("hermes") { return "hermes" } + if canonical.hasPrefix("grok") { return "grok" } if canonical.hasPrefix("openclaw") { return "openclaw" } if canonical.hasPrefix("qwen") { return "qwen" } if canonical.hasPrefix("kiro") { return "kiro" } @@ -306,7 +312,7 @@ public struct SessionSnapshot: Sendable { ".claude", ".cursor", ".codex", ".gemini", ".qoder", ".qoderwork", ".trae", ".trae-cn", ".kiro", ".copilot", ".factory", ".codebuddy", ".codybuddycn", ".stepfun", ".workbuddy", ".hermes", ".kimi", ".kimi-code", - ".pi", ".omp", ".qwen", ".zcode", ".openclaw", ".codeisland", + ".grok", ".pi", ".omp", ".qwen", ".zcode", ".openclaw", ".codeisland", ] /// Resolve a human project folder label from a cwd path. @@ -532,6 +538,7 @@ public struct SessionSnapshot: Sendable { case "google-antigravity": return "Google Antigravity" case "workbuddy": return "WorkBuddy" case "hermes": return "Hermes" + case "grok": return "Grok CLI" case "openclaw": return "OpenClaw" case "qwen": return "Qwen Code" case "kimi": return "Kimi Code CLI" diff --git a/Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift b/Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift new file mode 100644 index 00000000..a2f27989 --- /dev/null +++ b/Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import CodeIslandCore + +final class GrokHookForwardingPolicyTests: XCTestCase { + func testGrokRuntimeAcceptsEitherSignalAndRejectsBlankValues() { + XCTAssertTrue(GrokHookForwardingPolicy.isGrokRuntime(environment: [ + "GROK_SESSION_ID": "session-123", + ])) + XCTAssertFalse(GrokHookForwardingPolicy.shouldForward( + source: "cursor", + environment: ["GROK_SESSION_ID": "session-123"] + )) + XCTAssertTrue(GrokHookForwardingPolicy.isGrokRuntime(environment: [ + "GROK_HOOK_EVENT": "SessionStart", + ])) + XCTAssertFalse(GrokHookForwardingPolicy.shouldForward( + source: nil, + environment: ["GROK_HOOK_EVENT": "SessionStart"] + )) + XCTAssertTrue(GrokHookForwardingPolicy.isGrokRuntime(environment: [ + "GROK_SESSION_ID": " \n\t ", + "GROK_HOOK_EVENT": "Stop", + ])) + + XCTAssertFalse(GrokHookForwardingPolicy.isGrokRuntime(environment: [:])) + XCTAssertFalse(GrokHookForwardingPolicy.isGrokRuntime(environment: [ + "GROK_SESSION_ID": " \n\t ", + "GROK_HOOK_EVENT": " ", + ])) + } + + func testImportedHooksDeliverEachManagedGrokEventExactlyOnce() { + let invocations: [(label: String, source: String?)] = [ + ("managed-grok", "grok"), + ("imported-claude-untagged", nil), + ("imported-claude-tagged", "claude"), + ("imported-cursor", "cursor"), + ] + + for event in GrokHookForwardingPolicy.managedHookEvents { + let environment = [ + "GROK_SESSION_ID": "session-123", + "GROK_HOOK_EVENT": event, + ] + let forwarded = invocations.filter { + GrokHookForwardingPolicy.shouldForward( + source: $0.source, + environment: environment + ) + } + + XCTAssertEqual( + forwarded.map { $0.label }, + ["managed-grok"], + "\(event) must be forwarded exactly once" + ) + } + } + + func testNonGrokRuntimeDoesNotSuppressExistingSources() { + let sources: [String?] = [nil, "claude", "cursor"] + let environments = [ + [String: String](), + [ + "GROK_SESSION_ID": " \n\t ", + "GROK_HOOK_EVENT": " ", + ], + ] + + for environment in environments { + for source in sources { + XCTAssertTrue( + GrokHookForwardingPolicy.shouldForward( + source: source, + environment: environment + ), + "Non-Grok source \(source ?? "nil") must not be suppressed" + ) + } + } + } +} diff --git a/Tests/CodeIslandCoreTests/JSONLTailerTests.swift b/Tests/CodeIslandCoreTests/JSONLTailerTests.swift index 08e17693..150c81a6 100644 --- a/Tests/CodeIslandCoreTests/JSONLTailerTests.swift +++ b/Tests/CodeIslandCoreTests/JSONLTailerTests.swift @@ -141,6 +141,18 @@ final class JSONLTailerTests: XCTestCase { XCTAssertFalse(result.delta.isEmpty) } + func testScanLinesExtractsGrokChatHistoryRows() { + let lines = [ + #"{"type":"user","content":[{"type":"text","text":"build it"}]}"#, + #"{"type":"assistant","content":"done","model_id":"grok-code"}"#, + ].joined(separator: "\n") + "\n" + + let result = JSONLTailer.scanLines(Data(lines.utf8)) + + XCTAssertEqual(result.delta.lastUserPrompt, "build it") + XCTAssertEqual(result.delta.lastAssistantMessage, "done") + } + // MARK: - extractText func testExtractTextFromPlainString() { diff --git a/Tests/CodeIslandTests/GrokSupportTests.swift b/Tests/CodeIslandTests/GrokSupportTests.swift new file mode 100644 index 00000000..49640100 --- /dev/null +++ b/Tests/CodeIslandTests/GrokSupportTests.swift @@ -0,0 +1,211 @@ +import XCTest +@testable import CodeIsland +@testable import CodeIslandCore + +final class GrokSupportTests: XCTestCase { + func testSourceNormalizationAndLabel() { + XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("grok"), "grok") + XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("Grok CLI"), "grok") + XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("grok-build"), "grok") + + var snapshot = SessionSnapshot() + snapshot.source = "grok" + XCTAssertEqual(snapshot.sourceLabel, "Grok CLI") + } + + func testGrokExecutableResolverAcceptsManagedAndPathInstalls() { + XCTAssertTrue(CLIProcessResolver.sourceMatchesExecutablePath( + "/Users/test/.grok/downloads/grok-0.2.106-macos-aarch64", + source: "grok" + )) + XCTAssertTrue(CLIProcessResolver.sourceMatchesExecutablePath( + "/opt/homebrew/bin/grok", + source: "grok-cli" + )) + XCTAssertFalse(CLIProcessResolver.sourceMatchesExecutablePath( + "/Applications/Browser.app/Contents/MacOS/grok-helper", + source: "grok" + )) + } + + func testGrokEventAliasesReachTerminalStates() { + XCTAssertEqual(EventNormalizer.normalize("permission_denied"), "PermissionDenied") + XCTAssertEqual(EventNormalizer.normalize("stop_failure"), "Stop") + XCTAssertEqual(EventNormalizer.normalize("StopFailure"), "Stop") + } + + func testGrokCLIUsesEveryManagedHookEvent() throws { + let cli = try XCTUnwrap(ConfigInstaller.allCLIs.first { $0.source == "grok" }) + XCTAssertEqual(cli.events.map(\.0), GrokHookForwardingPolicy.managedHookEvents) + } + + func testGrokEncodedCwdEscapesSlashesAndSpaces() { + XCTAssertEqual( + AppState.grokEncodedCwd("/Users/test/My Project"), + "%2FUsers%2Ftest%2FMy%20Project" + ) + } + + func testGrokProcessMatchingRejectsLateSessionFromOlderProcess() { + let now = Date(timeIntervalSince1970: 10_000) + let oldProcessStart = now.addingTimeInterval(-35 * 60) + let newProcessStart = now.addingTimeInterval(-5) + let sessionCreatedAt = now.addingTimeInterval(-4) + + XCTAssertNil(AppState.grokSessionProcessMatchScore( + createdAt: sessionCreatedAt, + activityAt: now, + processStart: oldProcessStart, + now: now + )) + XCTAssertNotNil(AppState.grokSessionProcessMatchScore( + createdAt: sessionCreatedAt, + activityAt: now, + processStart: newProcessStart, + now: now + )) + } + + func testGrokProcessMatchingIsOneToOneForParallelSameCwdSessions() { + let base = Date(timeIntervalSince1970: 20_000) + let mapping = AppState.matchGrokSessionsToProcesses( + processes: [ + (pid: 101, startedAt: base), + (pid: 202, startedAt: base.addingTimeInterval(30)), + ], + sessions: [ + ( + id: "first", + createdAt: base.addingTimeInterval(2), + activityAt: base.addingTimeInterval(40) + ), + ( + id: "second", + createdAt: base.addingTimeInterval(32), + activityAt: base.addingTimeInterval(50) + ), + ], + now: base.addingTimeInterval(60) + ) + + XCTAssertEqual(mapping["first"], 101) + XCTAssertEqual(mapping["second"], 202) + XCTAssertEqual(Set(mapping.values).count, mapping.count) + } + + func testGrokProcessMatchingUsesAugmentingPathToKeepEveryViableSession() { + let base = Date(timeIntervalSince1970: 30_000) + let mapping = AppState.matchGrokSessionsToProcesses( + processes: [ + (pid: 101, startedAt: base), + (pid: 202, startedAt: base.addingTimeInterval(100)), + ], + sessions: [ + ( + id: "newer-resumed", + createdAt: base.addingTimeInterval(1), + activityAt: base.addingTimeInterval(110) + ), + ( + id: "older-one-shot", + createdAt: base, + activityAt: base.addingTimeInterval(50) + ), + ], + now: base.addingTimeInterval(110) + ) + + XCTAssertEqual(mapping["newer-resumed"], 202) + XCTAssertEqual(mapping["older-one-shot"], 101) + XCTAssertEqual(mapping.count, 2) + } + + func testGrokProcessMatchingBreaksEqualScoresDeterministically() { + let base = Date(timeIntervalSince1970: 40_000) + let processes: [(pid: pid_t, startedAt: Date?)] = [ + (pid: 202, startedAt: base), + (pid: 101, startedAt: base), + ] + let sessions: [(id: String, createdAt: Date?, activityAt: Date)] = [ + (id: "beta", createdAt: base, activityAt: base.addingTimeInterval(10)), + (id: "alpha", createdAt: base, activityAt: base.addingTimeInterval(10)), + ] + + let first = AppState.matchGrokSessionsToProcesses( + processes: processes, + sessions: sessions, + now: base.addingTimeInterval(10) + ) + let second = AppState.matchGrokSessionsToProcesses( + processes: Array(processes.reversed()), + sessions: Array(sessions.reversed()), + now: base.addingTimeInterval(10) + ) + + XCTAssertEqual(first, ["alpha": 101, "beta": 202]) + XCTAssertEqual(second, first) + } + + func testGrokNativeHookInstallIsNestedMatcherFreeAndIdempotent() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-hooks-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let cli = grokCLI(root: root) + XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: .default)) + XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: .default)) + + let data = try Data(contentsOf: root.appendingPathComponent("hooks/codeisland.json")) + let rootJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let hooks = try XCTUnwrap(rootJSON["hooks"] as? [String: Any]) + + for event in cli.events.map(\.0) { + let entries = try XCTUnwrap(hooks[event] as? [[String: Any]]) + XCTAssertEqual(entries.count, 1) + XCTAssertNil(entries[0]["matcher"], "Grok rejects matcher on lifecycle hooks") + let commands = try XCTUnwrap(entries[0]["hooks"] as? [[String: Any]]) + XCTAssertEqual(commands.count, 1) + XCTAssertTrue((commands[0]["command"] as? String)?.hasSuffix("--source grok") == true) + } + } + + func testGrokHookUninstallPreservesUserEntries() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-hooks-uninstall-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let cli = grokCLI(root: root) + XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: .default)) + let file = root.appendingPathComponent("hooks/codeisland.json") + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]) + var hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) + hooks["Stop"] = (hooks["Stop"] as? [[String: Any]] ?? []) + [ + ["hooks": [["type": "command", "command": "/usr/bin/true"]]] + ] + json["hooks"] = hooks + try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]).write(to: file) + + ConfigInstaller.uninstallHooks(cli: cli, fm: .default) + + let cleaned = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]) + let cleanedHooks = try XCTUnwrap(cleaned["hooks"] as? [String: Any]) + let stopEntries = try XCTUnwrap(cleanedHooks["Stop"] as? [[String: Any]]) + XCTAssertEqual(stopEntries.count, 1) + let userCommands = try XCTUnwrap(stopEntries[0]["hooks"] as? [[String: Any]]) + XCTAssertEqual(userCommands[0]["command"] as? String, "/usr/bin/true") + } + + private func grokCLI(root: URL) -> CLIConfig { + CLIConfig( + name: "Grok CLI", + source: "grok", + configPath: "hooks/codeisland.json", + configKey: "hooks", + format: .nested, + events: GrokHookForwardingPolicy.managedHookEvents.map { ($0, 5, false) }, + rootOverride: { root.path } + ) + } +} diff --git a/Tests/CodeIslandTests/MascotRenderHarness.swift b/Tests/CodeIslandTests/MascotRenderHarness.swift index 2eaaec5e..8c1ff19c 100644 --- a/Tests/CodeIslandTests/MascotRenderHarness.swift +++ b/Tests/CodeIslandTests/MascotRenderHarness.swift @@ -129,6 +129,7 @@ private struct MascotContactSheet: View { static func routedMascot(source: String, status: MascotAgentStatus, size: CGFloat) -> some View { switch source { case "codex": DexView(status: status, size: size) + case "grok": GrokView(status: status, size: size) case "gemini": GeminiView(status: status, size: size) case "cursor": CursorView(status: status, size: size) case "trae": TraeView(status: status, size: size)