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 {