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)