diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index e115784e..bb1416cf 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -17,11 +17,14 @@ running and what state it's in. That signal drives the ## Agents it recognizes Claude (Claude Code), Codex, Gemini, Cursor, Cline, OpenCode, GitHub Copilot, -Kimi, Droid, Amp, Pi (`pi`), Oh My Pi (`omp`, `oh-my-pi`), and Qwen Code (`qwen`). +Kimi, Droid, Amp, Pi (`pi`), Oh My Pi (`omp`, `oh-my-pi`), Qwen Code (`qwen`), +and Grok Build (`grok`). Detection covers common wrappers (node, python, bun, bash, etc.) so agents launched indirectly are still found. Oh My Pi reuses Pi-derived screen heuristics but uses its own command -icon where Prowl shows detected command icons, including terminal tabs. +icon where Prowl shows detected command icons, including terminal tabs. Grok Build +also ships an `agent` symlink; Prowl only treats that name as Grok when the path +points at a `~/.grok/` install (so Cursor's own `agent` entrypoint stays Cursor). ## How detection works (two stages) diff --git a/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Contents.json b/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Contents.json new file mode 100644 index 00000000..78500572 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Grok.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg b/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg new file mode 100644 index 00000000..7b66b327 --- /dev/null +++ b/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg @@ -0,0 +1 @@ + diff --git a/supacode/Domain/AgentDetection/DetectedAgent.swift b/supacode/Domain/AgentDetection/DetectedAgent.swift index e40c2845..ae8f59cc 100644 --- a/supacode/Domain/AgentDetection/DetectedAgent.swift +++ b/supacode/Domain/AgentDetection/DetectedAgent.swift @@ -14,6 +14,7 @@ enum DetectedAgent: String, CaseIterable, Equatable, Identifiable, Sendable { case droid case amp case qwen + case grok var id: String { rawValue } diff --git a/supacode/Features/Terminal/Models/CommandIconMap.swift b/supacode/Features/Terminal/Models/CommandIconMap.swift index f95472ee..c6d1601c 100644 --- a/supacode/Features/Terminal/Models/CommandIconMap.swift +++ b/supacode/Features/Terminal/Models/CommandIconMap.swift @@ -45,6 +45,7 @@ enum CommandIconMap { "kimi": TabIconSource(systemSymbol: "sparkle", assetName: "Kimi"), "opencode": TabIconSource(systemSymbol: "sparkle", assetName: "OpenCode"), "qwen": TabIconSource(systemSymbol: "sparkle", assetName: "Qwen"), + "grok": TabIconSource(systemSymbol: "sparkle", assetName: "Grok"), "omp": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), "oh-my-pi": TabIconSource(systemSymbol: "sparkle", assetName: "OMP"), "pi": TabIconSource(systemSymbol: "sparkle", assetName: "Pi"), diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index dbaac83d..c9827a42 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -1,7 +1,8 @@ import Foundation func identifyAgent(processName: String) -> DetectedAgent? { - switch processName.lowercased() { + let lower = processName.lowercased() + switch lower { case "pi", "omp", "oh-my-pi": return .pi case "claude", "claude-code": @@ -26,7 +27,13 @@ func identifyAgent(processName: String) -> DetectedAgent? { return .amp case "qwen": return .qwen + case "grok": + return .grok default: + // Versioned install binary, e.g. `grok-0.2.101-macos-aarch64`. + if lower.hasPrefix("grok-") { + return .grok + } return nil } } @@ -74,23 +81,41 @@ private func agentCandidates(for process: ForegroundProcess) -> [(name: String, } private func identifyAgent(candidate: (name: String, score: Int), process: ForegroundProcess) -> DetectedAgent? { - if candidate.name == "agent", isCursorAgentAlias(process) { - return .cursor + if candidate.name == "agent" { + // Cursor and Grok Build both ship an `agent` entrypoint. Disambiguate from + // path/cmdline evidence only — bare `agent` stays unknown. + if isCursorAgentAlias(process) { + return .cursor + } + if isGrokAgentAlias(process) { + return .grok + } + return nil } return identifyAgent(processName: candidate.name) } private func isCursorAgentAlias(_ process: ForegroundProcess) -> Bool { - let haystack = [ + let haystack = agentAliasHaystack(process) + return haystack.contains("cursor-agent") + || haystack.contains("cursor.app") +} + +private func isGrokAgentAlias(_ process: ForegroundProcess) -> Bool { + let haystack = agentAliasHaystack(process) + return haystack.contains("/.grok/") + || haystack.contains("grok-") + || haystack.split(whereSeparator: \.isWhitespace).contains("grok") +} + +private func agentAliasHaystack(_ process: ForegroundProcess) -> String { + [ process.argv0, process.cmdline, ] .compactMap(\.self) .joined(separator: " ") .lowercased() - - return haystack.contains("cursor-agent") - || haystack.contains("cursor.app") } private struct AgentCandidate { diff --git a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift index e1e2d996..b59d9910 100644 --- a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift +++ b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift @@ -88,3 +88,88 @@ nonisolated enum QwenRuntimeStatus { return nil } } + +/// Grok Build writes `~/.grok/active_sessions.json` with one entry per live +/// interactive session: `{ session_id, pid, cwd, opened_at }`. The pid map is +/// exact while the process is alive; quit/crash eventually drops the row, but +/// a reused pid can still carry a stale claim until Grok rewrites the file — +/// reject entries whose `opened_at` predates the process start. +nonisolated enum GrokActiveSessions { + static func session( + home: URL, + pid: pid_t, + processStartedAt: Date, + fileManager: FileManager = .default + ) -> AgentSession? { + let url = home.appending(path: ".grok/active_sessions.json") + guard let data = try? Data(contentsOf: url), + let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return nil } + + for row in rows { + guard let rowPID = row["pid"] as? Int, rowPID == Int(pid), + let id = row["session_id"] as? String, !id.isEmpty + else { continue } + if let openedAtRaw = row["opened_at"] as? String, + let openedAt = parseOpenedAt(openedAtRaw), + openedAt < processStartedAt.addingTimeInterval(-2) + { + continue + } + let cwd = row["cwd"] as? String + let transcript = transcriptURL(home: home, sessionID: id, cwd: cwd, fileManager: fileManager) + return AgentSession( + id: id, + transcriptPath: transcript, + source: .processLog, + confidence: .exact + ) + } + return nil + } + + private static func parseOpenedAt(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { return date } + let basic = ISO8601DateFormatter() + basic.formatOptions = [.withInternetDateTime] + return basic.date(from: value) + } + + private static func transcriptURL( + home: URL, + sessionID: String, + cwd: String?, + fileManager: FileManager + ) -> URL? { + let sessionsRoot = home.appending(path: ".grok/sessions") + let candidates: [URL] + if let cwd { + let encoded = percentEncodedPath(cwd) + candidates = [ + sessionsRoot.appending(path: "\(encoded)/\(sessionID)/events.jsonl"), + sessionsRoot.appending(path: "\(encoded)/\(sessionID)/chat_history.jsonl"), + ] + } else { + candidates = [] + } + for candidate in candidates where fileManager.fileExists(atPath: candidate.path) { + return candidate + } + // Fall back to a shallow scan when cwd is missing or encoding diverged. + let projectDirs = (try? fileManager.contentsOfDirectory(at: sessionsRoot, includingPropertiesForKeys: nil)) ?? [] + for project in projectDirs { + let events = project.appending(path: "\(sessionID)/events.jsonl") + if fileManager.fileExists(atPath: events.path) { return events } + let chat = project.appending(path: "\(sessionID)/chat_history.jsonl") + if fileManager.fileExists(atPath: chat.path) { return chat } + } + return nil + } + + private static func percentEncodedPath(_ path: String) -> String { + let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + return path.addingPercentEncoding(withAllowedCharacters: unreserved) ?? path + } +} diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index e03666fc..250cbd8b 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -49,6 +49,7 @@ nonisolated struct AgentSessionProfile: Sendable { case .opencode: .opencode case .amp: .amp case .qwen: .qwen + case .grok: .grok } } } @@ -255,6 +256,49 @@ nonisolated extension AgentSessionProfile { ) } ) + + /// Grok Build ≥ 0.2: `~/.grok/sessions///` + /// with `events.jsonl` / `chat_history.jsonl` held open during the turn, plus + /// an exact pid map in `~/.grok/active_sessions.json`. Cwd encoding is + /// full-path percent-encoding (`/Users/me/App` → `%2FUsers%2Fme%2FApp`). + /// Verified against Grok Build 0.2.101. + fileprivate static let grok = AgentSessionProfile( + parsePath: { path in + let url = URL(fileURLWithPath: path) + guard path.contains("/.grok/sessions/") else { return nil } + let components = url.pathComponents + guard let sessionsIndex = components.firstIndex(of: "sessions"), + components.count > sessionsIndex + 2 + else { return nil } + let id = components[sessionsIndex + 2] + guard uuid(in: id) != nil else { return nil } + // Walk up from the open path to the session root + // (…/sessions///). Nested paths like `terminal/.log` + // still resolve; transcript prefers the opened file when it is a + // known session transcript, otherwise events.jsonl under the root. + var dir = url.deletingLastPathComponent() + while dir.lastPathComponent != id, dir.pathComponents.count > sessionsIndex + 2 { + dir = dir.deletingLastPathComponent() + } + guard dir.lastPathComponent == id else { return nil } + let name = url.lastPathComponent + let transcript: URL = + (name == "events.jsonl" || name == "chat_history.jsonl") + ? url + : dir.appending(path: "events.jsonl") + return AgentSession(id: id, transcriptPath: transcript, source: .recentFile) + }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [home.appending(path: ".grok/sessions")] } + return [home.appending(path: ".grok/sessions/\(percentEncodedPath(cwd.path))")] + }, + fallbackRoots: { home, _ in + [home.appending(path: ".grok/sessions")] + }, + pidKeyedSession: { home, pid, processStartedAt in + GrokActiveSessions.session(home: home, pid: pid, processStartedAt: processStartedAt) + } + ) } // MARK: - Path parsing helpers @@ -329,6 +373,14 @@ nonisolated extension AgentSessionProfile { SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() } + /// Grok session directories encode the absolute cwd as a single path + /// component: every character outside RFC 3986 unreserved is percent-encoded + /// (`/Users/me/My App` → `%2FUsers%2Fme%2FMy%20App`). + fileprivate static func percentEncodedPath(_ path: String) -> String { + let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + return path.addingPercentEncoding(withAllowedCharacters: unreserved) ?? path + } + /// Day directories `root/YYYY/MM/DD` covering one day before the process /// start through today. Long-lived processes fall back to the full root /// instead of enumerating an unbounded directory list. diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 8a3c9f25..4db30d3f 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -30,6 +30,8 @@ extension DetectedAgent { return detectAmp(screen) case .qwen: return detectQwen(screen) + case .grok: + return detectGrok(screen) } } } @@ -516,6 +518,72 @@ nonisolated private func detectQwen(_ content: String) -> AgentRawState { return .idle } +// Grok Build (verified 0.2.101): cancel mid-turn is Ctrl+C (Esc is a no-op), +// permission chrome offers Allow once / Always allow… / Reject, and status +// flags include "Loading" / "Awaiting input". Multi-token matches only — +// single words like "approve" or "loading" appear in transcript text. +nonisolated private func detectGrok(_ content: String) -> AgentRawState { + if hasGrokPermissionPrompt(content) || hasGrokQuestionPrompt(content) { + return .blocked + } + if hasGrokWorkingSignal(content) { + return .working + } + return .idle +} + +nonisolated private func hasGrokPermissionPrompt(_ content: String) -> Bool { + let lower = content.lowercased() + let hasAllowOnce = lower.contains("allow once") + let hasAlwaysAllow = + lower.contains("always allow this command") + || lower.contains("always allow on all sessions") + || lower.contains("always allow this exact command") + let hasReject = lower.contains("reject") + if hasAllowOnce && (hasAlwaysAllow || hasReject) { + return true + } + if hasAlwaysAllow && hasReject { + return true + } + if lower.contains("yes, and always allow this exact command") + || lower.contains("yes, allow all edits") + { + return true + } + return false +} + +nonisolated private func hasGrokQuestionPrompt(_ content: String) -> Bool { + let lower = content.lowercased() + return lower.contains("pending: question") + || lower.contains("pending: other (type your own answer") + || (lower.contains("awaiting your input") + && (lower.contains("?") || lower.contains("select") || lower.contains("enter"))) +} + +nonisolated private func hasGrokWorkingSignal(_ content: String) -> Bool { + let lower = content.lowercased() + if lower.contains("tool calls in flight") + || lower.contains("working tools") + || lower.contains("still running:") + { + return true + } + // Status flag rendered while a turn is in progress (distinct from the + // idle "Awaiting input" / "Awaiting your input" flags). + if content.split(separator: "\n", omittingEmptySubsequences: false).contains(where: { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed == "Loading" || trimmed.hasPrefix("Loading…") || trimmed.hasPrefix("Loading...") + }) { + return true + } + if hasBrailleSpinner(content) || hasSpinnerActivity(content) { + return true + } + return false +} + nonisolated private func hasBrailleSpinner(_ content: String) -> Bool { content.split(separator: "\n", omittingEmptySubsequences: false).contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index ba0b7f25..95eea66f 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -24,6 +24,61 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "amp") == .amp) #expect(identifyAgent(processName: "amp-local") == .amp) #expect(identifyAgent(processName: "qwen") == .qwen) + #expect(identifyAgent(processName: "grok") == .grok) + #expect(identifyAgent(processName: "grok-0.2.101-macos-aarch64") == .grok) + } + + @Test func identifiesGrokAgentAliasCommandLines() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "agent", + argv0: "/Users/me/.grok/bin/agent", + cmdline: "/Users/me/.grok/bin/agent --always-approve" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .grok) + #expect(result.name == "agent") + } + + @Test func identifiesDirectGrokProcess() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "grok", + argv0: "grok", + cmdline: "grok --always-approve" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .grok) + #expect(result.name == "grok") + } + + @Test func identifiesVersionedGrokBinaryPath() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "grok-0.2.101-macos-aarch64", + argv0: "/Users/me/.grok/downloads/grok-0.2.101-macos-aarch64", + cmdline: "/Users/me/.grok/downloads/grok-0.2.101-macos-aarch64" + ) + ] + ) + + let result = try #require(identifyAgentInJob(job)) + #expect(result.agent == .grok) } @Test func identifiesOhMyPiCommandNames() throws { diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 29ee4a81..33fde12a 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -421,6 +421,118 @@ struct AgentSessionProfileTests { #expect(qwen.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.qwen/projects"]) } + @Test func grokActiveSessionsYieldsPidMatchedSession() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-grok-\(UUID().uuidString)", directoryHint: .isDirectory) + let id = "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" + let cwd = "/Users/me/App" + let encoded = "%2FUsers%2Fme%2FApp" + let sessionDir = root.appending(path: ".grok/sessions/\(encoded)/\(id)") + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try "{}".write(to: sessionDir.appending(path: "events.jsonl"), atomically: true, encoding: .utf8) + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) + let payload = """ + [ + { + "session_id": "\(id)", + "pid": 4242, + "cwd": "\(cwd)", + "opened_at": "2026-07-14T02:39:21.306116Z" + } + ] + """ + try payload.write( + to: root.appending(path: ".grok/active_sessions.json"), + atomically: true, + encoding: .utf8 + ) + + let session = GrokActiveSessions.session( + home: root, + pid: 4242, + processStartedAt: processStartedAt + ) + #expect(session?.id == id) + #expect(session?.source == .processLog) + #expect(session?.confidence == .exact) + #expect( + session?.transcriptPath?.resolvingSymlinksInPath() + == sessionDir.appending(path: "events.jsonl").resolvingSymlinksInPath() + ) + #expect(GrokActiveSessions.session(home: root, pid: 9999, processStartedAt: processStartedAt) == nil) + } + + @Test func grokActiveSessionsRejectsStaleClaimsFromReusedPids() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-grok-stale-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: root.appending(path: ".grok"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) + let payload = """ + [ + { + "session_id": "11111111-2222-3333-4444-555555555555", + "pid": 4242, + "cwd": "/Users/me/App", + "opened_at": "2026-07-01T00:00:00.000000Z" + } + ] + """ + try payload.write( + to: root.appending(path: ".grok/active_sessions.json"), + atomically: true, + encoding: .utf8 + ) + #expect(GrokActiveSessions.session(home: root, pid: 4242, processStartedAt: processStartedAt) == nil) + } + + @Test func grokRootsUsePercentEncodedWorkingDirectory() { + let cwd = URL(fileURLWithPath: "/Users/me/My App", isDirectory: true) + let grok = AgentSessionProfile.profile(for: .grok) + #expect( + grok.candidateRoots(home, cwd, now, now).map(\.path) + == ["/Users/me/.grok/sessions/%2FUsers%2Fme%2FMy%20App"] + ) + #expect(grok.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.grok/sessions"]) + } + + @Test func grokParsePathResolvesSessionDirectoryFromNestedFiles() { + let profile = AgentSessionProfile.profile(for: .grok) + let id = "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" + let events = + "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/\(id)/events.jsonl" + let nested = + "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/\(id)/terminal/call-1.log" + // Files may not exist on disk; parsePath still extracts the session id. + // Create a temp tree so transcript resolution can succeed for events. + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-grok-parse-\(UUID().uuidString)", directoryHint: .isDirectory) + let sessionDir = root.appending(path: ".grok/sessions/%2FUsers%2Fme%2FApp/\(id)") + try? FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try? "{}".write(to: sessionDir.appending(path: "events.jsonl"), atomically: true, encoding: .utf8) + + let parsedEvents = profile.parsePath(sessionDir.appending(path: "events.jsonl").path) + #expect(parsedEvents?.id == id) + + try? FileManager.default.createDirectory( + at: sessionDir.appending(path: "terminal"), + withIntermediateDirectories: true + ) + let nestedPath = sessionDir.appending(path: "terminal/call-1.log").path + FileManager.default.createFile(atPath: nestedPath, contents: Data()) + let parsedNested = profile.parsePath(nestedPath) + #expect(parsedNested?.id == id) + + #expect(profile.parsePath("/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/not-a-uuid/events.jsonl") == nil) + #expect(profile.parsePath(events) != nil || parsedEvents != nil) + _ = nested + } + @Test func geminiParsePathRequiresSessionPrefix() { let profile = AgentSessionProfile.profile(for: .gemini) #expect(profile.parsePath("/Users/me/.gemini/tmp/prowl/chats/session-2026-07-11T05-41-2fab0218.jsonl") != nil) diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift index 4cfd69d9..0a1ed9fc 100644 --- a/supacodeTests/AgentSessionResolverTests.swift +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -58,6 +58,13 @@ struct AgentSessionResolverTests { "02d412eb-2402-44d0-be90-faeb3f27fab6" ) ), + ( + .grok, + ( + "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/019f5e7e-4269-7e33-9eaf-d535ff8ebafb/events.jsonl", + "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" + ) + ), ] for (agent, expected) in fixtures { diff --git a/supacodeTests/CommandIconMapTests.swift b/supacodeTests/CommandIconMapTests.swift index 26647558..0ea6674f 100644 --- a/supacodeTests/CommandIconMapTests.swift +++ b/supacodeTests/CommandIconMapTests.swift @@ -78,6 +78,7 @@ struct CommandIconMapTests { #expect(CommandIconMap.iconForFirstToken("cline")?.assetName == "Cline") #expect(CommandIconMap.iconForFirstToken("droid")?.assetName == "Droid") #expect(CommandIconMap.iconForFirstToken("qwen")?.assetName == "Qwen") + #expect(CommandIconMap.iconForFirstToken("grok")?.assetName == "Grok") // Aider has no bundled brand asset — sparkle fallback only. #expect(CommandIconMap.iconForFirstToken("aider")?.systemSymbol == "sparkle") #expect(CommandIconMap.iconForFirstToken("aider")?.assetName == nil) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 1e2c4e6a..6803f565 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -540,4 +540,60 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.qwen.detectState(in: "done") == .idle) #expect(DetectedAgent.qwen.detectState(in: "> Type your message") == .idle) } + + @Test func grokDetection() { + // Permission chrome (labels verified against Grok Build 0.2.101 binary). + #expect( + DetectedAgent.grok.detectState( + in: """ + Allow once + Always allow this command + Always allow on all sessions + Reject + """ + ) == .blocked + ) + #expect( + DetectedAgent.grok.detectState( + in: """ + Yes, and always allow this exact command + Yes, allow all edits + """ + ) == .blocked + ) + // Incomplete permission chrome must not trip blocked. + #expect(DetectedAgent.grok.detectState(in: "Allow once") == .idle) + #expect(DetectedAgent.grok.detectState(in: "please approve the design") == .idle) + + #expect( + DetectedAgent.grok.detectState( + in: """ + Pending: question + Which approach should we take? + """ + ) == .blocked + ) + #expect( + DetectedAgent.grok.detectState( + in: """ + Awaiting your input + Pick an option? + ↑↓ select + """ + ) == .blocked + ) + + #expect(DetectedAgent.grok.detectState(in: "Loading") == .working) + #expect(DetectedAgent.grok.detectState(in: "Loading… streaming response") == .working) + #expect(DetectedAgent.grok.detectState(in: "deferred: tool calls in flight") == .working) + #expect(DetectedAgent.grok.detectState(in: "Working tools") == .working) + #expect(DetectedAgent.grok.detectState(in: "These tasks are still running:") == .working) + #expect(DetectedAgent.grok.detectState(in: "⠋ Reading AgentClassifier.swift") == .working) + #expect(DetectedAgent.grok.detectState(in: "✱ Searching… codebase") == .working) + + #expect(DetectedAgent.grok.detectState(in: "Awaiting input") == .idle) + #expect(DetectedAgent.grok.detectState(in: "Awaiting your input") == .idle) + #expect(DetectedAgent.grok.detectState(in: "Type a message") == .idle) + #expect(DetectedAgent.grok.detectState(in: "done") == .idle) + } } -- 2.51.2 From 3c5c60d62c43fb4f1320e498fd6f5504306eb12a Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 14 Jul 2026 11:19:23 +0800 Subject: [PATCH 2/6] Use the Lobe Icons Grok mark for the tab icon The placeholder circle-X was not the brand mark; swap in the monochrome Grok SVG used by the rest of the Lobe-sourced agent icons. --- supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg | 2 +- supacode/Assets.xcassets/CommandIcons/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg b/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg index 7b66b327..efb1a618 100644 --- a/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg +++ b/supacode/Assets.xcassets/CommandIcons/Grok.imageset/Grok.svg @@ -1 +1 @@ - +Grok \ No newline at end of file diff --git a/supacode/Assets.xcassets/CommandIcons/README.md b/supacode/Assets.xcassets/CommandIcons/README.md index b59a6aea..a9ba1540 100644 --- a/supacode/Assets.xcassets/CommandIcons/README.md +++ b/supacode/Assets.xcassets/CommandIcons/README.md @@ -12,7 +12,7 @@ without per-mode variants. | Source | License | Imagesets | | ------ | ------- | --------- | | [Simple Icons](https://simpleicons.org/) | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | AWS, Azure, Bun, Curl, Deno, Docker, Git, GitHub, Go, GoogleCloud, Gradle, Homebrew, Kubernetes, MySQL, Neovim, Node, Npm, Pnpm, Podman, PostgreSQL, Python, Rust, SQLite, Swift, Terraform, Tmux, TypeScript, Vim, VSCode, Xcode, Yarn, Gemini | -| [Lobe Icons](https://github.com/lobehub/lobe-icons) | [MIT](https://github.com/lobehub/lobe-icons/blob/master/LICENSE) | Amp, ClaudeCode, Codex, GitHubCopilot, Kimi, OpenCode | +| [Lobe Icons](https://github.com/lobehub/lobe-icons) | [MIT](https://github.com/lobehub/lobe-icons/blob/master/LICENSE) | Amp, ClaudeCode, Codex, GitHubCopilot, Grok, Kimi, OpenCode | `ClaudeCode` is sourced from the Lobe Icons `claude.svg` mark and re-authored as a single `fill-rule="evenodd"` path so the `>_` glyph -- 2.51.2 From d86f5626109e77d096ec09032b7d7e57c8efa5c3 Mon Sep 17 00:00:00 2001 From: sean Date: Tue, 14 Jul 2026 11:50:04 +0800 Subject: [PATCH 3/6] Harden Grok detection against false positives from review Tighten process matching so model-id tokens and unrelated agent CLIs are not classified as Grok, require parseable opened_at for exact session claims, and anchor session path parsing on .grok/sessions. --- .../AgentDetection/AgentClassifier.swift | 39 ++++++++++++++--- .../AgentDetection/AgentPidArtifacts.swift | 13 +++--- .../AgentDetection/AgentSessionProfile.swift | 15 +++++-- supacodeTests/AgentClassifierTests.swift | 40 +++++++++++++++++- supacodeTests/AgentSessionProfileTests.swift | 42 +++++++++++++++---- supacodeTests/AgentSessionResolverTests.swift | 8 ++++ 6 files changed, 132 insertions(+), 25 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index c9827a42..900d7bf3 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -30,14 +30,26 @@ func identifyAgent(processName: String) -> DetectedAgent? { case "grok": return .grok default: - // Versioned install binary, e.g. `grok-0.2.101-macos-aarch64`. - if lower.hasPrefix("grok-") { + // Versioned install binary only, e.g. `grok-0.2.101-macos-aarch64`. + // Must not match model-id tokens like `grok-4` / `grok-4.5` that show up + // as argv fragments of other agents (score-40 wrapped-runtime candidates). + if isGrokVersionedBinaryName(lower) { return .grok } return nil } } +/// Install packages are named `grok---` (verified +/// against `~/.grok/downloads/grok-0.2.101-macos-aarch64`). Model ids +/// (`grok-4`, `grok-4.5`) never include a platform segment. +private func isGrokVersionedBinaryName(_ lower: String) -> Bool { + guard lower.hasPrefix("grok-") else { return false } + return lower.contains("-macos-") + || lower.contains("-linux-") + || lower.contains("-windows-") +} + struct IdentifiedAgentProcess: Equatable, Sendable { let agent: DetectedAgent let name: String @@ -102,10 +114,25 @@ private func isCursorAgentAlias(_ process: ForegroundProcess) -> Bool { } private func isGrokAgentAlias(_ process: ForegroundProcess) -> Bool { - let haystack = agentAliasHaystack(process) - return haystack.contains("/.grok/") - || haystack.contains("grok-") - || haystack.split(whereSeparator: \.isWhitespace).contains("grok") + // Production `argv0` is basename-only (`ProcessDetection` strips the path); + // the full executable path is the first whitespace token of `cmdline`. + // Only inspect those two executable locations — never later argv tokens + // (e.g. `agent --model grok-4` must stay unknown). + let executablePaths = [ + process.argv0, + process.cmdline?.split(whereSeparator: \.isWhitespace).first.map(String.init), + ] + .compactMap { $0?.lowercased() } + + for path in executablePaths { + if path.contains("/.grok/") { + return true + } + if let basename = ProcessDetection.basename(path), isGrokVersionedBinaryName(basename) { + return true + } + } + return false } private func agentAliasHaystack(_ process: ForegroundProcess) -> String { diff --git a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift index b59d9910..2e8b385c 100644 --- a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift +++ b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift @@ -108,14 +108,13 @@ nonisolated enum GrokActiveSessions { for row in rows { guard let rowPID = row["pid"] as? Int, rowPID == Int(pid), - let id = row["session_id"] as? String, !id.isEmpty - else { continue } - if let openedAtRaw = row["opened_at"] as? String, + let id = row["session_id"] as? String, !id.isEmpty, + // Require a parseable opened_at: a missing/unreadable timestamp cannot + // prove the claim belongs to this process (pid reuse → stale exact). + let openedAtRaw = row["opened_at"] as? String, let openedAt = parseOpenedAt(openedAtRaw), - openedAt < processStartedAt.addingTimeInterval(-2) - { - continue - } + openedAt >= processStartedAt.addingTimeInterval(-2) + else { continue } let cwd = row["cwd"] as? String let transcript = transcriptURL(home: home, sessionID: id, cwd: cwd, fileManager: fileManager) return AgentSession( diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 250cbd8b..78592e8f 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -267,17 +267,24 @@ nonisolated extension AgentSessionProfile { let url = URL(fileURLWithPath: path) guard path.contains("/.grok/sessions/") else { return nil } let components = url.pathComponents - guard let sessionsIndex = components.firstIndex(of: "sessions"), - components.count > sessionsIndex + 2 + // Anchor on a `.grok` component whose next component is `sessions` + // (not the first bare `sessions`, and not an earlier unrelated `.grok`). + guard let grokIndex = components.indices.first(where: { index in + components[index] == ".grok" + && components.indices.contains(index + 1) + && components[index + 1] == "sessions" + }), + components.count > grokIndex + 3 else { return nil } - let id = components[sessionsIndex + 2] + // Layout: …/.grok/sessions///… + let id = components[grokIndex + 3] guard uuid(in: id) != nil else { return nil } // Walk up from the open path to the session root // (…/sessions///). Nested paths like `terminal/.log` // still resolve; transcript prefers the opened file when it is a // known session transcript, otherwise events.jsonl under the root. var dir = url.deletingLastPathComponent() - while dir.lastPathComponent != id, dir.pathComponents.count > sessionsIndex + 2 { + while dir.lastPathComponent != id, dir.pathComponents.count > grokIndex + 3 { dir = dir.deletingLastPathComponent() } guard dir.lastPathComponent == id else { return nil } diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index 95eea66f..af7b8dd7 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -26,16 +26,20 @@ struct AgentClassifierTests { #expect(identifyAgent(processName: "qwen") == .qwen) #expect(identifyAgent(processName: "grok") == .grok) #expect(identifyAgent(processName: "grok-0.2.101-macos-aarch64") == .grok) + // Model ids must not be treated as the install binary. + #expect(identifyAgent(processName: "grok-4") == nil) + #expect(identifyAgent(processName: "grok-4.5") == nil) } @Test func identifiesGrokAgentAliasCommandLines() throws { + // Production argv0 is basename-only; full path is cmdline's first token. let job = ForegroundJob( processGroupID: 42, processes: [ ForegroundProcess( pid: 100, name: "agent", - argv0: "/Users/me/.grok/bin/agent", + argv0: "agent", cmdline: "/Users/me/.grok/bin/agent --always-approve" ) ] @@ -46,6 +50,40 @@ struct AgentClassifierTests { #expect(result.name == "agent") } + @Test func ignoresAgentProcessWithGrokModelArgument() { + // Unrelated `agent` CLIs that merely take a grok model id must stay unknown. + // Production-shaped: basename argv0, no `/.grok/` in the executable path. + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "agent", + argv0: "agent", + cmdline: "/usr/local/bin/agent --model grok-4" + ) + ] + ) + #expect(identifyAgentInJob(job) == nil) + } + + @Test func ignoresWrappedRuntimeWithGrokModelToken() { + // Wrapped-runtime cmdline tokens are score-40 candidates; model ids like + // `grok-4` must not flip the job to Grok. + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "node", + argv0: "node", + cmdline: "node /tmp/app.js --model grok-4.5" + ) + ] + ) + #expect(identifyAgentInJob(job) == nil) + } + @Test func identifiesDirectGrokProcess() throws { let job = ForegroundJob( processGroupID: 42, diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 33fde12a..3caee428 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -490,6 +490,27 @@ struct AgentSessionProfileTests { #expect(GrokActiveSessions.session(home: root, pid: 4242, processStartedAt: processStartedAt) == nil) } + @Test func grokActiveSessionsRejectsMissingOrUnparseableOpenedAt() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-grok-opened-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: root.appending(path: ".grok"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) + + try """ + [{"session_id":"11111111-2222-3333-4444-555555555555","pid":4242,"cwd":"/Users/me/App"}] + """.write(to: root.appending(path: ".grok/active_sessions.json"), atomically: true, encoding: .utf8) + #expect(GrokActiveSessions.session(home: root, pid: 4242, processStartedAt: processStartedAt) == nil) + + try """ + [{"session_id":"11111111-2222-3333-4444-555555555555","pid":4242,"cwd":"/Users/me/App","opened_at":"not-a-date"}] + """.write(to: root.appending(path: ".grok/active_sessions.json"), atomically: true, encoding: .utf8) + #expect(GrokActiveSessions.session(home: root, pid: 4242, processStartedAt: processStartedAt) == nil) + } + @Test func grokRootsUsePercentEncodedWorkingDirectory() { let cwd = URL(fileURLWithPath: "/Users/me/My App", isDirectory: true) let grok = AgentSessionProfile.profile(for: .grok) @@ -503,11 +524,6 @@ struct AgentSessionProfileTests { @Test func grokParsePathResolvesSessionDirectoryFromNestedFiles() { let profile = AgentSessionProfile.profile(for: .grok) let id = "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" - let events = - "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/\(id)/events.jsonl" - let nested = - "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/\(id)/terminal/call-1.log" - // Files may not exist on disk; parsePath still extracts the session id. // Create a temp tree so transcript resolution can succeed for events. let root = FileManager.default.temporaryDirectory .appending(path: "prowl-grok-parse-\(UUID().uuidString)", directoryHint: .isDirectory) @@ -529,8 +545,20 @@ struct AgentSessionProfileTests { #expect(parsedNested?.id == id) #expect(profile.parsePath("/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/not-a-uuid/events.jsonl") == nil) - #expect(profile.parsePath(events) != nil || parsedEvents != nil) - _ = nested + } + + @Test func grokParsePathAnchorsOnDotGrokSessionsNotEarlierSessionsComponent() { + let profile = AgentSessionProfile.profile(for: .grok) + let id = "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" + // An earlier `sessions` path component must not steal the index. + let path = + "/Volumes/sessions/home/.grok/sessions/%2FUsers%2Fme%2FApp/\(id)/events.jsonl" + #expect(profile.parsePath(path)?.id == id) + + // An earlier unrelated `.grok` component must also be skipped. + let earlierDotGrok = + "/Volumes/.grok/home/.grok/sessions/%2FUsers%2Fme%2FApp/\(id)/events.jsonl" + #expect(profile.parsePath(earlierDotGrok)?.id == id) } @Test func geminiParsePathRequiresSessionPrefix() { diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift index 0a1ed9fc..04a6a857 100644 --- a/supacodeTests/AgentSessionResolverTests.swift +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -65,6 +65,14 @@ struct AgentSessionResolverTests { "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" ) ), + ( + .grok, + ( + // Earlier `sessions` path component must not be used as the marker. + "/Volumes/sessions/home/.grok/sessions/%2FUsers%2Fme%2FApp/019f5e7e-4269-7e33-9eaf-d535ff8ebafb/events.jsonl", + "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" + ) + ), ] for (agent, expected) in fixtures { -- 2.51.2 From 2a3b34c11246f07bed23b47a182d476c39096974 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 15 Jul 2026 22:36:40 +0900 Subject: [PATCH 4/6] Resolve agent-alias icons through the detected agent The shared `agent` entrypoint name maps to the Cursor icon in CommandIconMap, so a Grok Build session launched via ~/.grok/bin/agent showed the Cursor icon in Active Agents. IdentifiedAgentProcess now exposes an iconLookupToken that resolves alias names through the detected agent (Cursor keeps the same asset either way). Also reject bare `grok` cmdline tokens on wrapped runtimes: Grok Build is a direct Mach-O executable, so a score-40 token is a model argument (`node app.js --model grok`), not the agent. --- ...WorktreeTerminalState+AgentDetection.swift | 2 +- .../AgentDetection/AgentClassifier.swift | 13 +++++++ ...ctiveAgentEntryWorkingDirectoryTests.swift | 32 +++++++++++++++++ supacodeTests/AgentClassifierTests.swift | 34 +++++++++++-------- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 6bdd4510..102e0d14 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -120,7 +120,7 @@ extension WorktreeTerminalState { } else { seen = previous.seen } - let iconLookupToken = identified?.name ?? previous.iconLookupToken ?? agent.iconLookupToken + let iconLookupToken = identified?.iconLookupToken ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) let (session, sessionMissStreak) = await resolveRetainedSession( identified: identified, diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 900d7bf3..59e18020 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -54,6 +54,13 @@ struct IdentifiedAgentProcess: Equatable, Sendable { let agent: DetectedAgent let name: String let process: ForegroundProcess + + /// Icon token for `CommandIconMap`. The shared `agent` entrypoint name maps + /// to the Cursor icon there, so alias-identified agents resolve through the + /// detected agent instead of the raw process name. + var iconLookupToken: String { + name == "agent" ? agent.iconLookupToken : name + } } func identifyAgentInJob(_ job: ForegroundJob) -> IdentifiedAgentProcess? { @@ -104,6 +111,12 @@ private func identifyAgent(candidate: (name: String, score: Int), process: Foreg } return nil } + // Grok Build is a direct Mach-O executable, never a wrapped-runtime script. + // A bare `grok` cmdline token is a model argument (`node app.js --model + // grok`), not the agent — only argv0/name evidence may identify it. + if candidate.name == "grok", candidate.score == 40 { + return nil + } return identifyAgent(processName: candidate.name) } diff --git a/supacodeTests/ActiveAgentEntryWorkingDirectoryTests.swift b/supacodeTests/ActiveAgentEntryWorkingDirectoryTests.swift index fc6bc669..91646c8d 100644 --- a/supacodeTests/ActiveAgentEntryWorkingDirectoryTests.swift +++ b/supacodeTests/ActiveAgentEntryWorkingDirectoryTests.swift @@ -7,6 +7,38 @@ import Testing @MainActor struct ActiveAgentEntryWorkingDirectoryTests { + @Test func grokAliasUsesGrokIcon() throws { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess( + pid: 100, + name: "agent", + argv0: "agent", + cmdline: "/Users/me/.grok/bin/agent --always-approve" + ) + ] + ) + let identified = try #require(identifyAgentInJob(job)) + let fixture = makeState(launchDirectory: URL(fileURLWithPath: "/tmp/repo/worktree", isDirectory: true)) + + let entry = try #require( + fixture.state.activeAgentEntry( + surfaceID: fixture.surface.id, + tabId: fixture.tabId, + state: PaneAgentState( + detectedAgent: identified.agent, + iconLookupToken: identified.iconLookupToken, + state: .working + ) + ) + ) + + #expect(identified.name == "agent") + #expect(entry.iconLookupToken == "grok") + #expect(entry.iconSource?.assetName == "Grok") + } + @Test func activeAgentEntryUsesCachedPWD() throws { let launchDirectory = URL(fileURLWithPath: "/tmp/repo/worktree", isDirectory: true) let reportedDirectory = URL(fileURLWithPath: "/tmp/repo/worktree/Sources", isDirectory: true) diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index af7b8dd7..b960b0e2 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -48,6 +48,9 @@ struct AgentClassifierTests { let result = try #require(identifyAgentInJob(job)) #expect(result.agent == .grok) #expect(result.name == "agent") + // The shared `agent` name maps to the Cursor icon in CommandIconMap; + // the icon token must resolve through the detected agent instead. + #expect(result.iconLookupToken == "grok") } @Test func ignoresAgentProcessWithGrokModelArgument() { @@ -68,20 +71,20 @@ struct AgentClassifierTests { } @Test func ignoresWrappedRuntimeWithGrokModelToken() { - // Wrapped-runtime cmdline tokens are score-40 candidates; model ids like - // `grok-4` must not flip the job to Grok. - let job = ForegroundJob( - processGroupID: 42, - processes: [ - ForegroundProcess( - pid: 100, - name: "node", - argv0: "node", - cmdline: "node /tmp/app.js --model grok-4.5" - ) - ] - ) - #expect(identifyAgentInJob(job) == nil) + // Wrapped-runtime cmdline tokens are score-40 candidates; model ids — + // bare `grok` included — must not flip the job to Grok. + for cmdline in [ + "node /tmp/app.js --model grok-4.5", + "node /tmp/app.js --model grok", + ] { + let job = ForegroundJob( + processGroupID: 42, + processes: [ + ForegroundProcess(pid: 100, name: "node", argv0: "node", cmdline: cmdline) + ] + ) + #expect(identifyAgentInJob(job) == nil) + } } @Test func identifiesDirectGrokProcess() throws { @@ -160,6 +163,9 @@ struct AgentClassifierTests { let result = try #require(identifyAgentInJob(job)) #expect(result.agent == .cursor) #expect(result.name == "agent") + // Same icon either way ("agent" and "cursor" both map to the Cursor + // asset); the token just resolves through the detected agent now. + #expect(result.iconLookupToken == "cursor") } @Test func ignoresGenericAgentProcessWithoutCursorContext() { -- 2.51.2 From 074f187d7f09a73c97abfc54501fde12c8c66baa Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 15 Jul 2026 22:36:51 +0900 Subject: [PATCH 5/6] Match Grok approval and ask-user dialogs observed on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live captures from Grok Build 0.2.101 show the bash approval dialog renders "Yes, and don't ask again for anything (always-approve mode)" / "Yes, proceed" / "No, reject (type to add feedback)" rows and the ask-user dialog renders "Waiting on answers for …" with a "Type your answer here" row — none of the previously anchored phrases appear, so both states read as idle. Detect an approval dialog by requiring a yes-row/reject-row pair (or the dialog-only "Ctrl+o:always-approve" footer) and add the ask-user anchors, keeping the existing phrases for the remaining dialog variants. Fixtures are verbatim screen captures. --- .../AgentDetection/ScreenHeuristics.swift | 35 +++++++-- supacodeTests/ScreenHeuristicsTests.swift | 76 +++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift index 4db30d3f..b10e6b04 100644 --- a/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift +++ b/supacode/Infrastructure/AgentDetection/ScreenHeuristics.swift @@ -518,10 +518,13 @@ nonisolated private func detectQwen(_ content: String) -> AgentRawState { return .idle } -// Grok Build (verified 0.2.101): cancel mid-turn is Ctrl+C (Esc is a no-op), -// permission chrome offers Allow once / Always allow… / Reject, and status -// flags include "Loading" / "Awaiting input". Multi-token matches only — -// single words like "approve" or "loading" appear in transcript text. +// Grok Build (verified 0.2.101): cancel mid-turn is Ctrl+C (Esc is a no-op). +// Approval dialogs pair numbered yes-rows ("Yes, proceed", "Yes, allow all +// edits during this session") with a reject row ("No, reject (type to add +// feedback)") above a "Ctrl+o:always-approve" footer; ask-user dialogs render +// "Waiting on answers for …" with a "Type your answer here" row. Working +// turns show a braille spinner line ("⠧ Thinking… 0.2s"). Multi-token matches +// only — single words like "approve" or "loading" appear in transcript text. nonisolated private func detectGrok(_ content: String) -> AgentRawState { if hasGrokPermissionPrompt(content) || hasGrokQuestionPrompt(content) { return .blocked @@ -534,6 +537,24 @@ nonisolated private func detectGrok(_ content: String) -> AgentRawState { nonisolated private func hasGrokPermissionPrompt(_ content: String) -> Bool { let lower = content.lowercased() + // Tool approval dialog (verified on-screen, 0.2.101): a yes-row and a + // reject-row are always rendered together. Requiring the pair keeps + // transcript prose containing one of the phrases from matching. + let hasApprovalYesRow = + lower.contains("yes, proceed") + || lower.contains("yes, allow") + || lower.contains("yes, always allow") + || lower.contains("(always-approve mode)") + let hasApprovalNoRow = + lower.contains("no, reject") + || lower.contains("no, and tell grok") + if hasApprovalYesRow && hasApprovalNoRow { + return true + } + // Footer rendered only while an approval dialog is pending. + if lower.contains("ctrl+o:always-approve") { + return true + } let hasAllowOnce = lower.contains("allow once") let hasAlwaysAllow = lower.contains("always allow this command") @@ -556,7 +577,11 @@ nonisolated private func hasGrokPermissionPrompt(_ content: String) -> Bool { nonisolated private func hasGrokQuestionPrompt(_ content: String) -> Bool { let lower = content.lowercased() - return lower.contains("pending: question") + // Ask-user dialog (verified on-screen, 0.2.101): "◆ Waiting on answers for + // " header plus a free-form "z (○) Type your answer here" row. + return lower.contains("waiting on answers for") + || lower.contains("type your answer here") + || lower.contains("pending: question") || lower.contains("pending: other (type your own answer") || (lower.contains("awaiting your input") && (lower.contains("?") || lower.contains("select") || lower.contains("enter"))) diff --git a/supacodeTests/ScreenHeuristicsTests.swift b/supacodeTests/ScreenHeuristicsTests.swift index 6803f565..f4a26630 100644 --- a/supacodeTests/ScreenHeuristicsTests.swift +++ b/supacodeTests/ScreenHeuristicsTests.swift @@ -596,4 +596,80 @@ struct ScreenHeuristicsTests { #expect(DetectedAgent.grok.detectState(in: "Type a message") == .idle) #expect(DetectedAgent.grok.detectState(in: "done") == .idle) } + + // Fixtures below are verbatim screen captures from Grok Build 0.2.101 + // running inside a Prowl pane. + @Test func grokDetectionMatchesCapturedDialogChrome() { + // Bash tool approval — no "Allow once" / "Always allow …" rows here. + #expect( + DetectedAgent.grok.detectState( + in: """ + ◆ Sleep 12s then print DONE_MARKER… 51s 52s ⇣21.1k [↓][stop] + ┃ Sleep 12s then print DONE_MARKER + ┃ sleep 12 && echo DONE_MARKER + ┃ + ┃ 1 (●) Yes, and don't ask again for anything (always-approve mode) + ┃ 2 (○) Yes, proceed + ┃ 3 (○) No, reject (type to add feedback) + ┃ + 1/3:select │ Ctrl+o:always-approve │ Ctrl+c:cancel + """ + ) == .blocked + ) + // File-edit approval. + #expect( + DetectedAgent.grok.detectState( + in: """ + ┃ Allow Edit to /tmp/grok_probe.txt? + ┃ + ┃ 1 (○) Yes, and don't ask again for anything (always-approve mode) + ┃ 2 (○) Yes, allow all edits during this session + ┃ 3 (○) Yes + ┃ 4 (●) No, reject (type to add feedback) + ┃ + 1/4:select │ Ctrl+o:always-approve │ Ctrl+c:cancel + """ + ) == .blocked + ) + // Ask-user question dialog. + #expect( + DetectedAgent.grok.detectState( + in: """ + ◆ Waiting on answers for When working in this repo, which response style do you prefer? + ┃ When working in this repo, which response style do you prefer? + ┃ + ┃ 1 (○) Concise Short answers, minimal explanation unless needed + ┃ 2 (○) Balanced (Recommended) Brief conclusion plus key reasoning when useful + ┃ z (○) Type your answer here + ┃ + ┃ ↑/↓ navigate · y copy Enter:submit + Esc:unselect │ Tab:scrollback │ Shift+x:dismiss + """ + ) == .blocked + ) + // Streaming / tool-execution status lines use a braille spinner. + #expect(DetectedAgent.grok.detectState(in: "⠧ Waiting for response… 0.0s 0.0s ⇣20.9k [stop]") == .working) + #expect(DetectedAgent.grok.detectState(in: "⠦ Thinking… 0.2s 1.0s ⇣21.0k [stop]") == .working) + #expect( + DetectedAgent.grok.detectState( + in: "⠙ Sleep 12s then print DONE_MARKER… 20s 21s ⇣21.3k [↓][stop]" + ) == .working + ) + // Idle prompt: input frame, model label, shortcut footer. + #expect( + DetectedAgent.grok.detectState( + in: """ + ╭──────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────── Grok 4.5 (high) ─╯ + Shift+Tab:mode │ Ctrl+x:shortcuts + """ + ) == .idle + ) + // Completed-turn summary in scrollback stays idle. + #expect(DetectedAgent.grok.detectState(in: "Worked for 3.6s. stop [hooks: 1]") == .idle) + // A lone yes-row in transcript prose must not read as an approval dialog. + #expect(DetectedAgent.grok.detectState(in: "The user said yes, proceed with the plan.") == .idle) + #expect(DetectedAgent.grok.detectState(in: "I chose to reject the first approach.") == .idle) + } } -- 2.51.2 From d74bbe37dc7c78e64736550bd3e1480d2fbe5c0b Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 15 Jul 2026 22:36:59 +0900 Subject: [PATCH 6/6] Prefer chat_history.jsonl as the Grok session transcript chat_history.jsonl holds the conversation (system prompt and messages) while events.jsonl only logs MCP/infrastructure events, so transcript resolution now prefers the conversation log and falls back to events.jsonl when it is absent. --- .../AgentDetection/AgentPidArtifacts.swift | 9 ++++--- .../AgentDetection/AgentSessionProfile.swift | 21 ++++++++------- supacodeTests/AgentSessionProfileTests.swift | 26 ++++++++++++++++--- supacodeTests/AgentSessionResolverTests.swift | 5 ++-- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift index 2e8b385c..0355efa7 100644 --- a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift +++ b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift @@ -145,10 +145,13 @@ nonisolated enum GrokActiveSessions { let sessionsRoot = home.appending(path: ".grok/sessions") let candidates: [URL] if let cwd { + // Prefer chat_history.jsonl: it is the conversation transcript + // (messages incl. system prompt); events.jsonl only logs + // MCP/infrastructure events. let encoded = percentEncodedPath(cwd) candidates = [ - sessionsRoot.appending(path: "\(encoded)/\(sessionID)/events.jsonl"), sessionsRoot.appending(path: "\(encoded)/\(sessionID)/chat_history.jsonl"), + sessionsRoot.appending(path: "\(encoded)/\(sessionID)/events.jsonl"), ] } else { candidates = [] @@ -159,10 +162,10 @@ nonisolated enum GrokActiveSessions { // Fall back to a shallow scan when cwd is missing or encoding diverged. let projectDirs = (try? fileManager.contentsOfDirectory(at: sessionsRoot, includingPropertiesForKeys: nil)) ?? [] for project in projectDirs { - let events = project.appending(path: "\(sessionID)/events.jsonl") - if fileManager.fileExists(atPath: events.path) { return events } let chat = project.appending(path: "\(sessionID)/chat_history.jsonl") if fileManager.fileExists(atPath: chat.path) { return chat } + let events = project.appending(path: "\(sessionID)/events.jsonl") + if fileManager.fileExists(atPath: events.path) { return events } } return nil } diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 78592e8f..104d0211 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -269,11 +269,12 @@ nonisolated extension AgentSessionProfile { let components = url.pathComponents // Anchor on a `.grok` component whose next component is `sessions` // (not the first bare `sessions`, and not an earlier unrelated `.grok`). - guard let grokIndex = components.indices.first(where: { index in - components[index] == ".grok" - && components.indices.contains(index + 1) - && components[index + 1] == "sessions" - }), + guard + let grokIndex = components.indices.first(where: { index in + components[index] == ".grok" + && components.indices.contains(index + 1) + && components[index + 1] == "sessions" + }), components.count > grokIndex + 3 else { return nil } // Layout: …/.grok/sessions///… @@ -281,18 +282,18 @@ nonisolated extension AgentSessionProfile { guard uuid(in: id) != nil else { return nil } // Walk up from the open path to the session root // (…/sessions///). Nested paths like `terminal/.log` - // still resolve; transcript prefers the opened file when it is a - // known session transcript, otherwise events.jsonl under the root. + // still resolve. The transcript is always canonicalized to + // chat_history.jsonl (the conversation log): the process also holds + // events.jsonl open, which only logs MCP/infrastructure events. var dir = url.deletingLastPathComponent() while dir.lastPathComponent != id, dir.pathComponents.count > grokIndex + 3 { dir = dir.deletingLastPathComponent() } guard dir.lastPathComponent == id else { return nil } - let name = url.lastPathComponent let transcript: URL = - (name == "events.jsonl" || name == "chat_history.jsonl") + url.lastPathComponent == "chat_history.jsonl" ? url - : dir.appending(path: "events.jsonl") + : dir.appending(path: "chat_history.jsonl") return AgentSession(id: id, transcriptPath: transcript, source: .recentFile) }, candidateRoots: { home, cwd, _, _ in diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 3caee428..76dca0b6 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -456,11 +456,24 @@ struct AgentSessionProfileTests { #expect(session?.id == id) #expect(session?.source == .processLog) #expect(session?.confidence == .exact) + // Only events.jsonl exists — the transcript falls back to it. #expect( session?.transcriptPath?.resolvingSymlinksInPath() == sessionDir.appending(path: "events.jsonl").resolvingSymlinksInPath() ) #expect(GrokActiveSessions.session(home: root, pid: 9999, processStartedAt: processStartedAt) == nil) + + // chat_history.jsonl is the conversation log; prefer it once present. + try "{}".write(to: sessionDir.appending(path: "chat_history.jsonl"), atomically: true, encoding: .utf8) + let refreshed = GrokActiveSessions.session( + home: root, + pid: 4242, + processStartedAt: processStartedAt + ) + #expect( + refreshed?.transcriptPath?.resolvingSymlinksInPath() + == sessionDir.appending(path: "chat_history.jsonl").resolvingSymlinksInPath() + ) } @Test func grokActiveSessionsRejectsStaleClaimsFromReusedPids() throws { @@ -501,13 +514,13 @@ struct AgentSessionProfileTests { let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) try """ - [{"session_id":"11111111-2222-3333-4444-555555555555","pid":4242,"cwd":"/Users/me/App"}] - """.write(to: root.appending(path: ".grok/active_sessions.json"), atomically: true, encoding: .utf8) + [{"session_id":"11111111-2222-3333-4444-555555555555","pid":4242,"cwd":"/Users/me/App"}] + """.write(to: root.appending(path: ".grok/active_sessions.json"), atomically: true, encoding: .utf8) #expect(GrokActiveSessions.session(home: root, pid: 4242, processStartedAt: processStartedAt) == nil) try """ - [{"session_id":"11111111-2222-3333-4444-555555555555","pid":4242,"cwd":"/Users/me/App","opened_at":"not-a-date"}] - """.write(to: root.appending(path: ".grok/active_sessions.json"), atomically: true, encoding: .utf8) + [{"session_id":"11111111-2222-3333-4444-555555555555","pid":4242,"cwd":"/Users/me/App","opened_at":"not-a-date"}] + """.write(to: root.appending(path: ".grok/active_sessions.json"), atomically: true, encoding: .utf8) #expect(GrokActiveSessions.session(home: root, pid: 4242, processStartedAt: processStartedAt) == nil) } @@ -534,6 +547,8 @@ struct AgentSessionProfileTests { let parsedEvents = profile.parsePath(sessionDir.appending(path: "events.jsonl").path) #expect(parsedEvents?.id == id) + // Every non-chat open file canonicalizes to the conversation log. + #expect(parsedEvents?.transcriptPath?.lastPathComponent == "chat_history.jsonl") try? FileManager.default.createDirectory( at: sessionDir.appending(path: "terminal"), @@ -543,6 +558,9 @@ struct AgentSessionProfileTests { FileManager.default.createFile(atPath: nestedPath, contents: Data()) let parsedNested = profile.parsePath(nestedPath) #expect(parsedNested?.id == id) + // Non-transcript files resolve to the conversation log at the root. + #expect(parsedNested?.transcriptPath?.lastPathComponent == "chat_history.jsonl") + #expect(parsedNested?.transcriptPath?.deletingLastPathComponent().lastPathComponent == id) #expect(profile.parsePath("/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/not-a-uuid/events.jsonl") == nil) } diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift index 04a6a857..5e6e05c4 100644 --- a/supacodeTests/AgentSessionResolverTests.swift +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -61,7 +61,7 @@ struct AgentSessionResolverTests { ( .grok, ( - "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/019f5e7e-4269-7e33-9eaf-d535ff8ebafb/events.jsonl", + "/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/019f5e7e-4269-7e33-9eaf-d535ff8ebafb/chat_history.jsonl", "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" ) ), @@ -69,7 +69,8 @@ struct AgentSessionResolverTests { .grok, ( // Earlier `sessions` path component must not be used as the marker. - "/Volumes/sessions/home/.grok/sessions/%2FUsers%2Fme%2FApp/019f5e7e-4269-7e33-9eaf-d535ff8ebafb/events.jsonl", + "/Volumes/sessions/home/.grok/sessions/%2FUsers%2Fme%2FApp/019f5e7e-4269-7e33-9eaf-d535ff8ebafb/" + + "chat_history.jsonl", "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" ) ),