From ec791bb319b3f9a2d511a15d6d9fd76e25eec993 Mon Sep 17 00:00:00 2001 From: hyiiiii <138483585+hyiiiii@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:35:20 +0800 Subject: [PATCH] feat(grok): add Grok CLI integration --- README.md | 5 +- README.zh-CN.md | 6 +- Sources/CodeIsland/AppState.swift | 252 ++++++++++++++++++ Sources/CodeIsland/ConfigInstaller.swift | 44 +++ Sources/CodeIsland/GrokView.swift | 128 +++++++++ Sources/CodeIsland/MascotView.swift | 2 + Sources/CodeIsland/NotchPanelView.swift | 1 + .../CodeIsland/Resources/cli-icons/grok.png | Bin 0 -> 10737 bytes Sources/CodeIsland/SettingsView.swift | 1 + Sources/CodeIslandBridge/main.swift | 60 +++++ Sources/CodeIslandCore/EventNormalizer.swift | 4 + .../GrokHookForwardingPolicy.swift | 45 ++++ Sources/CodeIslandCore/Models.swift | 9 + Sources/CodeIslandCore/SessionSnapshot.swift | 9 +- .../GrokHookForwardingPolicyTests.swift | 82 ++++++ .../JSONLTailerTests.swift | 12 + Tests/CodeIslandTests/GrokSupportTests.swift | 211 +++++++++++++++ .../CodeIslandTests/MascotRenderHarness.swift | 1 + 18 files changed, 867 insertions(+), 5 deletions(-) create mode 100644 Sources/CodeIsland/GrokView.swift create mode 100644 Sources/CodeIsland/Resources/cli-icons/grok.png create mode 100644 Sources/CodeIslandCore/GrokHookForwardingPolicy.swift create mode 100644 Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift create mode 100644 Tests/CodeIslandTests/GrokSupportTests.swift 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 0000000000000000000000000000000000000000..f2d7ccc29ee4f24a04f92440bc935f5aa9e0ff63 GIT binary patch literal 10737 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 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)