diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 686c5ee2..23f8f851 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -227,7 +227,8 @@ enum OutputRenderer { return sortedAgents.map { agent in let statusLabel = agentStatusLabel(agent.status) let projectLabel = "\(agent.project.name):\(agent.project.branch)" - return "\(statusLabel) \(agent.name) \(projectLabel) \(agent.tab.title) \(agent.pane.id)" + let sessionLabel = agent.session.map { " session=\($0.id) [\($0.confidence)]" } ?? "" + return "\(statusLabel) \(agent.name) \(projectLabel) \(agent.tab.title) \(agent.pane.id)\(sessionLabel)" }.joined(separator: "\n") } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index fe2fef19..860cf14f 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -534,7 +534,13 @@ final class ProwlCLIIntegrationTests: XCTestCase { status: "done", projectName: "Prowl", branch: "main", - tabTitle: "Done tab" + tabTitle: "Done tab", + session: AgentsResponseSession( + id: "019f4e9e-1234-4567-89ab-0123456789ab", + path: "/Users/me/.codex/sessions/rollout.jsonl", + confidence: "exact", + source: "open_file" + ) ), makeAgentResponse( id: "blocked-pane", @@ -572,6 +578,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(lines[0].contains("blocked-pane"), "Missing pane id: \(result.stdout)") XCTAssertTrue(lines[1].contains("Working"), "Expected working second: \(result.stdout)") XCTAssertTrue(lines[2].contains("Done"), "Expected done third: \(result.stdout)") + XCTAssertTrue(lines[2].contains("session=019f4e9e-1234-4567-89ab-0123456789ab [exact]")) } func testAgentsEmptyPayloadShowsNoAgentsFound() throws { @@ -1574,7 +1581,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { status: String, projectName: String, branch: String, - tabTitle: String + tabTitle: String, + session: AgentsResponseSession? = nil ) -> AgentsResponseAgent { AgentsResponseAgent( id: id, @@ -1592,7 +1600,8 @@ final class ProwlCLIIntegrationTests: XCTestCase { kind: "git" ), tab: ListTab(id: "\(id)-tab", title: tabTitle, selected: true), - pane: AgentsResponsePane(id: id, index: 1, title: name, cwd: "/Projects/\(projectName)", focused: false) + pane: AgentsResponsePane(id: id, index: 1, title: name, cwd: "/Projects/\(projectName)", focused: false), + session: session ) } @@ -1814,6 +1823,7 @@ private struct AgentsResponseAgent: Encodable { case worktree case tab case pane + case session } let rawState: String @@ -1822,6 +1832,14 @@ private struct AgentsResponseAgent: Encodable { let worktree: ListWorktree let tab: ListTab let pane: AgentsResponsePane + let session: AgentsResponseSession? +} + +private struct AgentsResponseSession: Encodable { + let id: String + let path: String? + let confidence: String + let source: String } private struct AgentsResponseProject: Encodable { diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md new file mode 100644 index 00000000..16bba3db --- /dev/null +++ b/doc-onevcat/agent-session-detection.md @@ -0,0 +1,90 @@ +# Agent Session Detection + +## Purpose + +Prowl resolves a detected terminal agent process to its native session metadata without requiring hooks. This is a +foundation for future handoff, resume, transcript, and automation features; this layer does not resume or mutate an +agent session. + +## Resolution Model + +Resolution is anchored to the exact process selected by Active Agents: + +1. Preserve the matched process PID instead of only the normalized agent name. +2. Inspect that PID's open vnode paths. A unique recognized session file is `exact` evidence. +3. For agents that open transcripts only while appending, enumerate the agent's known session directory and keep files + created or modified during the current process lifetime. +4. Extract a bounded tail from each candidate transcript and compare recent `user`, `assistant`, and `result` text with + the pane's live bottom buffer. +5. Accept only a unique match with a sufficient score and margin. Ambiguous results remain unresolved. +6. Cache and periodically revalidate a mapping by `(pid, process start time)` so PID reuse cannot inherit an old + session and in-process session rotation can be detected. + +The resolver never selects a candidate merely because it is the newest file. A sole process-lifetime candidate is +reported as `medium`; a unique text correlation is `high`; an open file owned by the exact process is `exact`. + +## Support Matrix + +The following versions were inspected on 2026-07-11: + +| Agent | Version | Storage recognized | Current confidence path | +| --- | --- | --- | --- | +| Codex | 0.144.1 | `~/.codex/sessions/**/rollout-*.jsonl` | Exact writable open FD; transcript fallback | +| Claude Code | 2.1.206 | `~/.claude/projects//*.jsonl` | Process lifetime + transcript/screen match | +| Pi / OMP | Pi 0.79.2 | `~/.pi/agent/sessions//*.jsonl` | Process lifetime + transcript/screen match | +| Gemini CLI | 0.46.0 | `~/.gemini/tmp/**/chats/session-*.jsonl` | Best effort, strict unique match | +| Cursor Agent | 2026.05.09-0afadcc | `~/.cursor/chats///store.db` | Path recognition; best effort | +| Cline | 2.18.0 | `~/.cline/data/tasks//...` | Path recognition; best effort | +| GitHub Copilot CLI | 1.0.44 | `~/.copilot/session-state//...` | Path recognition; best effort | +| Kimi Code | 1.41.0 | `~/.kimi/sessions///...` | Path recognition; best effort | +| Droid | 0.147.0 | `~/.factory/sessions//.jsonl` | Process lifetime + transcript/screen match | +| OpenCode | 1.17.18 | Shared `opencode.db` | Unresolved: no safe process-to-row mapping yet | +| Amp | 0.0.1778328768-gb9a37d | No stable transcript mapping observed | Unresolved | +| Qwen | Not installed | Not verified | Unresolved | + +Codex kept its rollout JSONL open for the whole interactive session. Claude and Pi were both tested after completing a +real prompt; each created and wrote its JSONL, then closed it while the interactive process remained alive. This is why +open-FD inspection is primary evidence rather than the only strategy. + +## Safety and Performance + +- Darwin inspection uses `proc_pidinfo` / `proc_pidfdinfo`; Prowl never shells out to `lsof`. +- Results are cached per process lifetime. Unresolved processes retry at most once per second. +- Transcript reads are capped at 128 KiB from the tail. +- Known storage roots are used; arbitrary home-directory searching is not performed. +- Matching accepts ambiguity as a normal outcome. A session ID from `medium` confidence must not be used for automatic + resume/fork without additional confirmation. +- Local PID inspection cannot see agents behind SSH, containers, VMs, or nested tmux servers. + +## Verification + +Build and run a Debug Prowl app, start an agent in a pane, send at least one distinctive prompt, then inspect: + +```bash +prowl agents --json | jq '.data.agents[] | {type, pane: .pane.id, session}' +``` + +A simple Codex process with one open rollout uses `source: "open_file"` and `confidence: "exact"`. A Codex process that +also owns subagent rollouts is disambiguated like Claude and Pi: after a distinctive user or assistant message it uses +`source: "transcript_match"` and `confidence: "high"`. Parallel sessions in the same directory with indistinguishable +visible text must return `session: null` rather than guess. + +Targeted automated coverage: + +```bash +xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ + -only-testing:supacodeTests/AgentSessionResolverTests \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation +``` + +The test suite covers known path parsing, native open-FD enumeration against the test process, process-lifetime +filtering, unique transcript matching, and ambiguous transcript rejection. + +## Future Uses + +- Add native session identity and confidence to handoff preparation. +- Fork or resume a stopped source agent only when the mapping is exact/high. +- Open or export the correct transcript from Active Agents. +- Restore agent sessions after an app restart. +- Diagnose duplicated or stale agent sessions from `prowl agents` output. +- Add an optional hook provider later as `exact` evidence without changing the consumer contract. diff --git a/docs/components/cli.md b/docs/components/cli.md index 0223452e..7d5146aa 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -101,6 +101,9 @@ Each agent contains: agent's working directory. - `worktree`, `tab`, `pane`: the actual terminal owner and pane metadata for automation. +- `session`: optional native agent session metadata. When resolved, it contains + `id`, local transcript `path`, `confidence` (`exact`, `high`, or `medium`), + and the evidence `source`. Ambiguous sessions are omitted instead of guessed. `prowl agents` is read-only. To jump to or operate on an agent, resolve `.data.agents[].pane.id`, then use existing commands: diff --git a/supacode/CLIService/AgentsCommandHandler.swift b/supacode/CLIService/AgentsCommandHandler.swift index e9234179..69a5b5c4 100644 --- a/supacode/CLIService/AgentsCommandHandler.swift +++ b/supacode/CLIService/AgentsCommandHandler.swift @@ -105,7 +105,15 @@ final class AgentsCommandHandler: CommandHandler { title: terminalContext.pane.title, cwd: terminalContext.pane.cwd, focused: terminalContext.focused - ) + ), + session: entry.session.map { + AgentsCommandSession( + id: $0.id, + path: $0.transcriptPath?.path, + confidence: $0.confidence.rawValue, + source: $0.source.rawValue + ) + } ) } diff --git a/supacode/CLIService/Shared/AgentsCommandPayload.swift b/supacode/CLIService/Shared/AgentsCommandPayload.swift index 4f94acbd..2e1951aa 100644 --- a/supacode/CLIService/Shared/AgentsCommandPayload.swift +++ b/supacode/CLIService/Shared/AgentsCommandPayload.swift @@ -21,6 +21,7 @@ public struct AgentsCommandAgent: Codable, Equatable { public let worktree: AgentsCommandWorktree public let tab: AgentsCommandTab public let pane: AgentsCommandPane + public let session: AgentsCommandSession? enum CodingKeys: String, CodingKey { case id @@ -33,6 +34,7 @@ public struct AgentsCommandAgent: Codable, Equatable { case worktree case tab case pane + case session } public init( @@ -45,7 +47,8 @@ public struct AgentsCommandAgent: Codable, Equatable { project: AgentsCommandProject, worktree: AgentsCommandWorktree, tab: AgentsCommandTab, - pane: AgentsCommandPane + pane: AgentsCommandPane, + session: AgentsCommandSession? = nil ) { self.id = id self.type = type @@ -57,6 +60,21 @@ public struct AgentsCommandAgent: Codable, Equatable { self.worktree = worktree self.tab = tab self.pane = pane + self.session = session + } +} + +public struct AgentsCommandSession: Codable, Equatable { + public let id: String + public let path: String? + public let confidence: String + public let source: String + + public init(id: String, path: String?, confidence: String, source: String) { + self.id = id + self.path = path + self.confidence = confidence + self.source = source } } diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 480025a8..c292259c 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -2,6 +2,8 @@ import Foundation struct PaneAgentState: Equatable, Sendable { var detectedAgent: DetectedAgent? + var agentProcessID: pid_t? + var session: AgentSession? var iconLookupToken: String? var fallbackState: AgentRawState var state: AgentRawState @@ -10,6 +12,8 @@ struct PaneAgentState: Equatable, Sendable { init( detectedAgent: DetectedAgent? = nil, + agentProcessID: pid_t? = nil, + session: AgentSession? = nil, iconLookupToken: String? = nil, fallbackState: AgentRawState = .unknown, state: AgentRawState = .unknown, @@ -17,6 +21,8 @@ struct PaneAgentState: Equatable, Sendable { lastChangedAt: Date = Date() ) { self.detectedAgent = detectedAgent + self.agentProcessID = agentProcessID + self.session = session self.iconLookupToken = iconLookupToken self.fallbackState = fallbackState self.state = state diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index e58deb80..a018bae5 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -20,6 +20,7 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { /// `agent` for aliases that share one semantic agent, e.g. `omp` vs `pi`. let iconLookupToken: String let agent: DetectedAgent + var session: AgentSession? let rawState: AgentRawState let displayState: AgentDisplayState let lastChangedAt: Date diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 53172361..74292bbc 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -121,9 +121,18 @@ extension WorktreeTerminalState { seen = previous.seen } let iconLookupToken = identified?.name ?? previous.iconLookupToken ?? agent.iconLookupToken + let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) + let session = await resolveAgentSession( + identified: identified, + previous: previous, + workingDirectory: workingDirectory, + activeText: activeText + ) let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt let next = PaneAgentState( detectedAgent: agent, + agentProcessID: identified?.process.pid, + session: session, iconLookupToken: iconLookupToken, fallbackState: raw, state: stabilized, @@ -155,6 +164,21 @@ extension WorktreeTerminalState { return true } + private func resolveAgentSession( + identified: IdentifiedAgentProcess?, + previous: PaneAgentState, + workingDirectory: URL?, + activeText: String + ) async -> AgentSession? { + guard let identified else { return nil } + let resolved = await AgentSessionResolver.shared.resolve( + identified: identified, + workingDirectory: workingDirectory, + activeText: activeText + ) + return resolved ?? (previous.agentProcessID == identified.process.pid ? previous.session : nil) + } + func markAgentSeen(surfaceID: UUID) { guard var state = surfaceAgentStates[surfaceID], !state.seen else { return } state.seen = true @@ -228,6 +252,7 @@ extension WorktreeTerminalState { paneIndex: paneIndex, iconLookupToken: state.iconLookupToken ?? agent.iconLookupToken, agent: agent, + session: state.session, rawState: state.fallbackState, displayState: state.displayState, lastChangedAt: state.lastChangedAt diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 7c12ae22..d01bfa90 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -47,7 +47,7 @@ struct AgentDetectionDiagnostic { let childPID: pid_t? let processGroupID: pid_t? let job: ForegroundJob? - let identified: (agent: DetectedAgent, name: String)? + let identified: IdentifiedAgentProcess? let retainedAgent: DetectedAgent? let raw: AgentRawState? let stabilized: AgentRawState? diff --git a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift index 76eb2ddd..dbaac83d 100644 --- a/supacode/Infrastructure/AgentDetection/AgentClassifier.swift +++ b/supacode/Infrastructure/AgentDetection/AgentClassifier.swift @@ -31,19 +31,25 @@ func identifyAgent(processName: String) -> DetectedAgent? { } } -func identifyAgentInJob(_ job: ForegroundJob) -> (agent: DetectedAgent, name: String)? { +struct IdentifiedAgentProcess: Equatable, Sendable { + let agent: DetectedAgent + let name: String + let process: ForegroundProcess +} + +func identifyAgentInJob(_ job: ForegroundJob) -> IdentifiedAgentProcess? { var best: AgentCandidate? for process in job.processes { for candidate in agentCandidates(for: process) { guard let agent = identifyAgent(candidate: candidate, process: process) else { continue } if best == nil || candidate.score > best!.score { - best = AgentCandidate(score: candidate.score, agent: agent, name: candidate.name) + best = AgentCandidate(score: candidate.score, agent: agent, name: candidate.name, process: process) } } } - return best.map { ($0.agent, $0.name) } + return best.map { IdentifiedAgentProcess(agent: $0.agent, name: $0.name, process: $0.process) } } private func agentCandidates(for process: ForegroundProcess) -> [(name: String, score: Int)] { @@ -91,6 +97,7 @@ private struct AgentCandidate { let score: Int let agent: DetectedAgent let name: String + let process: ForegroundProcess } private func normalizedProcessName(_ raw: String) -> String? { diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift new file mode 100644 index 00000000..17cbb522 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -0,0 +1,372 @@ +import Foundation + +nonisolated struct AgentSession: Equatable, Sendable { + enum Source: String, Equatable, Sendable { + case commandLine = "command_line" + case openFile = "open_file" + case transcriptMatch = "transcript_match" + case recentFile = "recent_file" + } + + enum Confidence: String, Equatable, Sendable { + case exact + case high + case medium + } + + let id: String + let transcriptPath: URL? + let source: Source + let confidence: Confidence + + init( + id: String, + transcriptPath: URL?, + source: Source, + confidence: Confidence = .medium + ) { + self.id = id + self.transcriptPath = transcriptPath + self.source = source + self.confidence = confidence + } +} + +nonisolated struct AgentSessionCandidate: Equatable, Sendable { + let session: AgentSession + let modifiedAt: Date + + nonisolated static func uniqueActiveCandidate( + _ candidates: [Self], + processStartedAt: Date, + clockSkew: TimeInterval = 2 + ) -> Self? { + let active = candidates.filter { $0.modifiedAt >= processStartedAt.addingTimeInterval(-clockSkew) } + return active.count == 1 ? active[0] : nil + } +} + +nonisolated enum AgentSessionPathParser { + static func parse(path: String, agent: DetectedAgent) -> AgentSession? { + let url = URL(fileURLWithPath: path) + let id = sessionID(path: path, url: url, agent: agent) + guard let id, !id.isEmpty else { return nil } + return AgentSession(id: id, transcriptPath: url, source: .recentFile) + } + + private static func sessionID(path: String, url: URL, agent: DetectedAgent) -> String? { + switch agent { + case .codex: uuidJSONL(path: path, marker: "/.codex/sessions/", url: url) + case .claude: uuidJSONL(path: path, marker: "/.claude/projects/", url: url) + case .pi: uuidJSONL(path: path, marker: "/.pi/agent/sessions/", url: url) + case .gemini: geminiID(path: path, url: url) + case .cursor: parentID(path: path, marker: "/.cursor/chats/", filename: "store.db", url: url) + case .cline: markedComponent(path: path, marker: "/.cline/data/tasks/", component: "tasks", url: url) + case .copilot: + markedComponent(path: path, marker: "/.copilot/session-state/", component: "session-state", url: url) + case .kimi: kimiID(path: path, url: url) + case .droid: uuidJSONL(path: path, marker: "/.factory/sessions/", url: url) + case .opencode, .amp, .qwen: nil + } + } + + private static func uuidJSONL(path: String, marker: String, url: URL) -> String? { + guard path.contains(marker), url.pathExtension == "jsonl" else { return nil } + return uuid(in: url.deletingPathExtension().lastPathComponent) + } + + private static func geminiID(path: String, url: URL) -> String? { + guard path.contains("/.gemini/tmp/"), path.contains("/chats/"), url.pathExtension == "jsonl" else { return nil } + return url.deletingPathExtension().lastPathComponent.split(separator: "-").last.map(String.init) + } + + private static func parentID(path: String, marker: String, filename: String, url: URL) -> String? { + guard path.contains(marker), url.lastPathComponent == filename else { return nil } + return url.pathComponents.dropLast().last + } + + private static func markedComponent(path: String, marker: String, component: String, url: URL) -> String? { + guard path.contains(marker) else { return nil } + return self.component(after: component, in: url.pathComponents) + } + + private static func kimiID(path: String, url: URL) -> String? { + guard path.contains("/.kimi/sessions/") else { return nil } + let components = url.pathComponents + guard let index = components.firstIndex(of: "sessions"), components.count > index + 2 else { return nil } + return components[index + 2] + } + + private static func uuid(in value: String) -> String? { + let pattern = #"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"# + guard let range = value.range(of: pattern, options: .regularExpression) else { return nil } + return String(value[range]).lowercased() + } + + private static func component(after marker: String, in components: [String]) -> String? { + guard let index = components.firstIndex(of: marker), components.indices.contains(index + 1) else { return nil } + return components[index + 1] + } +} + +actor AgentSessionResolver { + static let shared = AgentSessionResolver() + + private struct CacheKey: Hashable { + let pid: pid_t + let startedAt: Date + } + + private struct CachedResult { + let resolvedAt: Date + let session: AgentSession? + } + + private var cache: [CacheKey: CachedResult] = [:] + private let fileManager: FileManager + private let homeDirectory: URL + + init( + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + self.fileManager = fileManager + self.homeDirectory = homeDirectory + } + + func resolve( + identified: IdentifiedAgentProcess, + workingDirectory: URL?, + activeText: String, + now: Date = Date() + ) -> AgentSession? { + let process = identified.process + guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { return nil } + let key = CacheKey(pid: process.pid, startedAt: startedAt) + if let cached = cache[key] { + let lifetime: TimeInterval = cached.session == nil ? 1 : 5 + if now.timeIntervalSince(cached.resolvedAt) < lifetime { return cached.session } + } + + let session = resolveUncached( + identified: identified, + processStartedAt: startedAt, + workingDirectory: workingDirectory, + activeText: activeText + ) + cache[key] = CachedResult(resolvedAt: now, session: session) + if cache.count > 128 { + cache = cache.filter { ProcessDetection.processBSDInfo(pid: $0.key.pid) != nil } + } + return session + } + + private func resolveUncached( + identified: IdentifiedAgentProcess, + processStartedAt: Date, + workingDirectory: URL?, + activeText: String + ) -> AgentSession? { + let openSessions = ProcessDetection.openFilePaths(pid: identified.process.pid) + .compactMap { AgentSessionPathParser.parse(path: $0, agent: identified.agent) } + if let session = uniqueSession(openSessions) { + return AgentSession( + id: session.id, + transcriptPath: session.transcriptPath, + source: .openFile, + confidence: .exact + ) + } + let openCandidates = openSessions.compactMap { session -> AgentSessionCandidate? in + guard let path = session.transcriptPath, + let attributes = try? fileManager.attributesOfItem(atPath: path.path), + let modifiedAt = attributes[.modificationDate] as? Date + else { return nil } + return AgentSessionCandidate(session: session, modifiedAt: modifiedAt) + } + if let matched = AgentSessionFingerprintMatcher.bestMatch( + activeText: activeText, + candidates: openCandidates + ) { + return AgentSession( + id: matched.session.id, + transcriptPath: matched.session.transcriptPath, + source: .transcriptMatch, + confidence: .high + ) + } + + let candidates = recentCandidates( + agent: identified.agent, + workingDirectory: workingDirectory, + processStartedAt: processStartedAt + ) + if let matched = AgentSessionFingerprintMatcher.bestMatch(activeText: activeText, candidates: candidates) { + return AgentSession( + id: matched.session.id, + transcriptPath: matched.session.transcriptPath, + source: .transcriptMatch, + confidence: .high + ) + } + return AgentSessionCandidate.uniqueActiveCandidate(candidates, processStartedAt: processStartedAt)?.session + } + + private func uniqueSession(_ sessions: [AgentSession]) -> AgentSession? { + let unique = Dictionary(sessions.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return unique.count == 1 ? unique.values.first : nil + } + + private func recentCandidates( + agent: DetectedAgent, + workingDirectory: URL?, + processStartedAt: Date + ) -> [AgentSessionCandidate] { + candidateRoots(agent: agent, workingDirectory: workingDirectory).flatMap { root in + recentFiles(in: root, modifiedAfter: processStartedAt.addingTimeInterval(-2)).compactMap { item in + guard let session = AgentSessionPathParser.parse(path: item.url.path, agent: agent) else { return nil } + let enriched = + sessionIDFromHeader(at: item.url).map { + AgentSession(id: $0, transcriptPath: item.url, source: .recentFile) + } ?? session + return AgentSessionCandidate(session: enriched, modifiedAt: item.modifiedAt) + } + } + } + + private func sessionIDFromHeader(at url: URL) -> String? { + guard url.pathExtension == "jsonl", + let handle = try? FileHandle(forReadingFrom: url) + else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 8_192), + let line = data.split(separator: 0x0A).first, + let object = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] + else { return nil } + for key in ["sessionId", "session_id", "id"] { + if let value = object[key] as? String, !value.isEmpty { return value } + } + return nil + } + + private func candidateRoots(agent: DetectedAgent, workingDirectory: URL?) -> [URL] { + switch agent { + case .codex: + return [homeDirectory.appending(path: ".codex/sessions")] + case .claude: + guard let workingDirectory else { return [] } + return [homeDirectory.appending(path: ".claude/projects/\(encodedClaudePath(workingDirectory.path))")] + case .pi: + guard let workingDirectory else { return [] } + return [homeDirectory.appending(path: ".pi/agent/sessions/-\(encodedClaudePath(workingDirectory.path))--")] + case .gemini: + return [homeDirectory.appending(path: ".gemini/tmp")] + case .cursor: + return [homeDirectory.appending(path: ".cursor/chats")] + case .cline: + return [homeDirectory.appending(path: ".cline/data/tasks")] + case .copilot: + return [homeDirectory.appending(path: ".copilot/session-state")] + case .kimi: + return [homeDirectory.appending(path: ".kimi/sessions")] + case .droid: + guard let workingDirectory else { return [] } + return [homeDirectory.appending(path: ".factory/sessions/\(encodedClaudePath(workingDirectory.path))")] + case .opencode, .amp, .qwen: + return [] + } + } + + private func encodedClaudePath(_ path: String) -> String { + path.replacing("/", with: "-") + } + + private func recentFiles(in root: URL, modifiedAfter threshold: Date) -> [(url: URL, modifiedAt: Date)] { + guard + let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + else { return [] } + + var result: [(URL, Date)] = [] + for case let url as URL in enumerator { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), + values.isRegularFile == true, + let modifiedAt = values.contentModificationDate, + modifiedAt >= threshold + else { continue } + result.append((url, modifiedAt)) + } + return result + } +} + +nonisolated enum AgentSessionFingerprintMatcher { + static func bestMatch( + activeText: String, + candidates: [AgentSessionCandidate] + ) -> AgentSessionCandidate? { + let screen = normalize(activeText) + guard screen.count >= 12 else { return nil } + let scored = candidates.compactMap { candidate -> (AgentSessionCandidate, Int)? in + guard let path = candidate.session.transcriptPath, + let data = tailData(at: path), + let text = String(data: data, encoding: .utf8) + else { return nil } + let score = transcriptStrings(text).reduce(0) { best, fragment in + let normalized = normalize(fragment) + guard normalized.count >= 12 else { return best } + if screen.contains(normalized) { return max(best, min(200, normalized.count + 80)) } + let suffix = String(normalized.suffix(80)) + return suffix.count >= 24 && screen.contains(suffix) ? max(best, suffix.count) : best + } + return score > 0 ? (candidate, score) : nil + }.sorted { $0.1 > $1.1 } + + guard let best = scored.first, best.1 >= 40 else { return nil } + if scored.count > 1, best.1 - scored[1].1 < 20 { return nil } + return best.0 + } + + static func normalize(_ value: String) -> String { + value + .replacing(#/\u{001B}\[[0-?]*[ -\/]*[@-~]/#, with: " ") + .lowercased() + .split(whereSeparator: \Character.isWhitespace) + .joined(separator: " ") + } + + private static func tailData(at url: URL, byteLimit: UInt64 = 131_072) -> Data? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard let size = try? handle.seekToEnd() else { return nil } + try? handle.seek(toOffset: size > byteLimit ? size - byteLimit : 0) + return try? handle.readToEnd() + } + + private static func transcriptStrings(_ text: String) -> [String] { + text.split(separator: "\n").suffix(80).flatMap { line -> [String] in + guard let data = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + else { return [] } + return strings(in: json, key: nil) + } + } + + private static func strings(in value: Any, key: String?) -> [String] { + if let string = value as? String, + ["content", "text", "message", "result", "prompt", "last_assistant_message"].contains(key ?? "") + { + return [string] + } + if let array = value as? [Any] { + return array.flatMap { strings(in: $0, key: key) } + } + if let object = value as? [String: Any] { + return object.flatMap { strings(in: $0.value, key: $0.key) } + } + return [] + } +} diff --git a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift index cc5966f7..d3ae6621 100644 --- a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift +++ b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift @@ -154,6 +154,47 @@ nonisolated enum ProcessDetection { return result == Int32(size) ? info : nil } + static func processStartDate(pid: pid_t) -> Date? { + guard let info = processBSDInfo(pid: pid), info.pbi_start_tvsec > 0 else { return nil } + return Date( + timeIntervalSince1970: TimeInterval(info.pbi_start_tvsec) + + TimeInterval(info.pbi_start_tvusec) / 1_000_000 + ) + } + + static func openFilePaths(pid: pid_t) -> [String] { + guard pid > 0 else { return [] } + var capacity = 256 + var descriptors: [proc_fdinfo] = [] + var count = 0 + while capacity <= 4_096 { + descriptors = [proc_fdinfo](repeating: proc_fdinfo(), count: capacity) + let filled = descriptors.withUnsafeMutableBytes { buffer in + proc_pidinfo(pid, PROC_PIDLISTFDS, 0, buffer.baseAddress, Int32(buffer.count)) + } + guard filled > 0 else { return [] } + count = Int(filled) / MemoryLayout.stride + if count < capacity { break } + capacity *= 2 + } + + return descriptors.prefix(count).compactMap { descriptor -> String? in + guard descriptor.proc_fdtype == PROX_FDTYPE_VNODE else { return nil } + var info = vnode_fdinfowithpath() + let size = MemoryLayout.size + let result = withUnsafeMutablePointer(to: &info) { pointer in + proc_pidfdinfo(pid, descriptor.proc_fd, PROC_PIDFDVNODEPATHINFO, pointer, Int32(size)) + } + guard result > 0 else { return nil } + return withUnsafeBytes(of: info.pvip.vip_path) { rawBuffer -> String? in + let bytes = rawBuffer.bindMemory(to: UInt8.self) + let end = bytes.firstIndex(of: 0) ?? bytes.endIndex + guard end > bytes.startIndex else { return nil } + return String(bytes: bytes[.. String? { let bytes = withUnsafeBytes(of: info.pbi_comm) { rawBuffer -> [UInt8] in Array(rawBuffer) diff --git a/supacodeTests/AgentClassifierTests.swift b/supacodeTests/AgentClassifierTests.swift index a27ce770..ba0b7f25 100644 --- a/supacodeTests/AgentClassifierTests.swift +++ b/supacodeTests/AgentClassifierTests.swift @@ -45,6 +45,7 @@ struct AgentClassifierTests { let result = try #require(identifyAgentInJob(job)) #expect(result.agent == .pi) #expect(result.name == "omp") + #expect(result.process.pid == 100) } @Test func identifiesCursorAgentAliasCommandLines() throws { @@ -158,5 +159,6 @@ struct AgentClassifierTests { let result = try #require(identifyAgentInJob(job)) #expect(result.agent == .claude) #expect(result.name == "claude") + #expect(result.process.pid == 101) } } diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift new file mode 100644 index 00000000..b57e9fab --- /dev/null +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -0,0 +1,167 @@ +import Darwin +import Foundation +import Testing + +@testable import supacode + +struct AgentSessionResolverTests { + @Test func parsesKnownSessionPaths() throws { + let fixtures: [(DetectedAgent, (path: String, id: String))] = [ + ( + .codex, + ( + "/Users/me/.codex/sessions/2026/07/11/rollout-2026-07-11T00-00-00-019f4e9e-1234-4567-89ab-0123456789ab.jsonl", + "019f4e9e-1234-4567-89ab-0123456789ab" + ) + ), + ( + .claude, + ( + "/Users/me/.claude/projects/-Users-me-App/8f43a7f5-eb10-476d-848b-b51999be23ba.jsonl", + "8f43a7f5-eb10-476d-848b-b51999be23ba" + ) + ), + ( + .pi, + ( + "/Users/me/.pi/agent/sessions/--Users-me-App--/session_019f4f1b-3650-7661-a56d-351f02f01139.jsonl", + "019f4f1b-3650-7661-a56d-351f02f01139" + ) + ), + (.gemini, ("/Users/me/.gemini/tmp/app/chats/session-2fab0218.jsonl", "2fab0218")), + ( + .cursor, + ( + "/Users/me/.cursor/chats/project/49ebc2f9-80da-4462-b20a-030f6d1ea944/store.db", + "49ebc2f9-80da-4462-b20a-030f6d1ea944" + ) + ), + (.cline, ("/Users/me/.cline/data/tasks/1778406986752/history.json", "1778406986752")), + ( + .copilot, + ( + "/Users/me/.copilot/session-state/49d53d20-5cde-4511-98d2-788e2def14fb/events.jsonl", + "49d53d20-5cde-4511-98d2-788e2def14fb" + ) + ), + ( + .kimi, + ( + "/Users/me/.kimi/sessions/project/e447232d-2601-4905-a943-3a4e4ac6c434/context.jsonl", + "e447232d-2601-4905-a943-3a4e4ac6c434" + ) + ), + ( + .droid, + ( + "/Users/me/.factory/sessions/-Users-me-App/02d412eb-2402-44d0-be90-faeb3f27fab6.jsonl", + "02d412eb-2402-44d0-be90-faeb3f27fab6" + ) + ), + ] + + for (agent, expected) in fixtures { + let parsed = try #require(AgentSessionPathParser.parse(path: expected.path, agent: agent)) + #expect(parsed.id == expected.id) + #expect(parsed.transcriptPath?.path == expected.path) + } + } + + @Test func rejectsLookalikeFilesOutsideAgentStorage() { + #expect(AgentSessionPathParser.parse(path: "/tmp/rollout-019f4e9e.jsonl", agent: .codex) == nil) + #expect(AgentSessionPathParser.parse(path: "/tmp/session.jsonl", agent: .claude) == nil) + #expect(AgentSessionPathParser.parse(path: "/tmp/store.db", agent: .cursor) == nil) + } + + @Test func uniqueCandidateRejectsAmbiguityAndFilesOlderThanProcess() { + let start = Date(timeIntervalSince1970: 1_000) + let old = AgentSessionCandidate( + session: AgentSession(id: "old", transcriptPath: URL(fileURLWithPath: "/tmp/old"), source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 900) + ) + let first = AgentSessionCandidate( + session: AgentSession(id: "first", transcriptPath: URL(fileURLWithPath: "/tmp/first"), source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 1_001) + ) + let second = AgentSessionCandidate( + session: AgentSession(id: "second", transcriptPath: URL(fileURLWithPath: "/tmp/second"), source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 1_002) + ) + + #expect(AgentSessionCandidate.uniqueActiveCandidate([old, first], processStartedAt: start)?.session.id == "first") + #expect(AgentSessionCandidate.uniqueActiveCandidate([first, second], processStartedAt: start) == nil) + } + + @Test func readsCurrentProcessOpenFilePaths() throws { + let url = FileManager.default.temporaryDirectory + .appending(path: "prowl-agent-session-\(UUID().uuidString).jsonl") + FileManager.default.createFile(atPath: url.path, contents: Data()) + let descriptor = open(url.path, O_WRONLY) + #expect(descriptor >= 0) + defer { + close(descriptor) + try? FileManager.default.removeItem(at: url) + } + + let paths = ProcessDetection.openFilePaths(pid: getpid()) + #expect( + paths.contains { URL(fileURLWithPath: $0).lastPathComponent == url.lastPathComponent }, + "Expected \(url.lastPathComponent) in \(paths)" + ) + } + + @Test func matchesUniqueRecentTranscriptAgainstPaneText() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-session-match-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let firstURL = root.appending(path: "first.jsonl") + let secondURL = root.appending(path: "second.jsonl") + let firstContent = + #"{"type":"user","message":{"content":"Refactor authentication middleware without changing its API."}}"# + try firstContent.write(to: firstURL, atomically: true, encoding: .utf8) + try #"{"type":"user","message":{"content":"Investigate the unrelated rendering regression."}}"# + .write(to: secondURL, atomically: true, encoding: .utf8) + + let candidates = [ + AgentSessionCandidate( + session: AgentSession(id: "first", transcriptPath: firstURL, source: .recentFile), + modifiedAt: .now + ), + AgentSessionCandidate( + session: AgentSession(id: "second", transcriptPath: secondURL, source: .recentFile), + modifiedAt: .now + ), + ] + + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "❯ Refactor authentication middleware without changing its API.", + candidates: candidates + ) + #expect(match?.session.id == "first") + } + + @Test func rejectsTranscriptMatchWhenPaneTextIsSharedByCandidates() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-session-ambiguous-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let content = #"{"message":{"content":"Reply exactly READY and wait."}}"# + let urls = [root.appending(path: "a.jsonl"), root.appending(path: "b.jsonl")] + for url in urls { try content.write(to: url, atomically: true, encoding: .utf8) } + let candidates = urls.enumerated().map { index, url in + AgentSessionCandidate( + session: AgentSession(id: String(index), transcriptPath: url, source: .recentFile), + modifiedAt: .now + ) + } + + #expect( + AgentSessionFingerprintMatcher.bestMatch( + activeText: "Reply exactly READY and wait.", + candidates: candidates + ) == nil + ) + } +} diff --git a/supacodeTests/CLIAgentsCommandHandlerTests.swift b/supacodeTests/CLIAgentsCommandHandlerTests.swift index 89a78aa8..1162c999 100644 --- a/supacodeTests/CLIAgentsCommandHandlerTests.swift +++ b/supacodeTests/CLIAgentsCommandHandlerTests.swift @@ -45,6 +45,10 @@ struct CLIAgentsCommandHandlerTests { #expect(agent.pane.title == "omp") #expect(agent.pane.cwd == "/tmp/project-repo/Sources") #expect(agent.pane.focused) + #expect(agent.session?.id == "019f4f1b-3650-7661-a56d-351f02f01139") + #expect(agent.session?.path == "/tmp/pi-session.jsonl") + #expect(agent.session?.confidence == "high") + #expect(agent.session?.source == "transcript_match") let idleAgent = payload.agents[1] #expect(idleAgent.status == .idle) @@ -117,6 +121,12 @@ struct CLIAgentsCommandHandlerTests { paneIndex: 2, iconLookupToken: "omp", agent: .pi, + session: AgentSession( + id: "019f4f1b-3650-7661-a56d-351f02f01139", + transcriptPath: URL(fileURLWithPath: "/tmp/pi-session.jsonl"), + source: .transcriptMatch, + confidence: .high + ), rawState: .blocked, displayState: .blocked, lastChangedAt: Date(timeIntervalSince1970: 1_789_999_200) @@ -165,6 +175,7 @@ struct CLIAgentsCommandHandlerTests { paneIndex: input.paneIndex, iconLookupToken: input.iconLookupToken, agent: input.agent, + session: input.session, rawState: input.rawState, displayState: input.displayState, lastChangedAt: input.lastChangedAt @@ -220,6 +231,7 @@ struct CLIAgentsCommandHandlerTests { let paneIndex: Int let iconLookupToken: String let agent: DetectedAgent + var session: AgentSession? let rawState: AgentRawState let displayState: AgentDisplayState let lastChangedAt: Date -- 2.51.2 From cff1d78da928020d8d8c3418a7721d6addb7ab5d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 15:37:15 +0900 Subject: [PATCH 2/8] Harden session detection and add per-agent profiles Fix three review findings and extend native session detection to the agents that previously had none, driven by on-machine verification of each CLI's storage and process artifacts: - Claude project roots now encode every non-alphanumeric character as "-" (matching Claude Code); the old slash-only rule failed for every managed worktree under ~/.prowl/repos. - Candidate uniqueness and fingerprint margins group by session id, so multi-file layouts (Kimi, Cline, Copilot) resolve after cold starts. - detectAgentState re-checks the surface after the resolver suspension to avoid resurrecting Active Agents entries for closed panes. - Per-agent knowledge now lives in AgentSessionProfile, one declarative block per agent targeting the latest CLI release only. - New exact evidence: Amp per-thread log descriptors, Copilot process-.log registration lines, Qwen runtime.json pid sidecars. - OpenCode resolves via a read-only query of opencode.db session rows (directory + process lifetime), reported as store_record/medium. - Storage scans narrow to Codex day directories, Kimi/Cursor md5(cwd), and Gemini projects.json slugs, with wide-root fallbacks for resumed sessions; responds to the Copilot review comment on PR #556. Child-environment scanning (CLAUDE_CODE_SESSION_ID etc.) was prototyped and rejected: macOS strips the environment block from KERN_PROCARGS2 for non-entitled callers, so the channel is documented as unavailable instead. --- doc-onevcat/agent-session-detection.md | 159 ++++++--- docs/components/cli.md | 8 +- ...WorktreeTerminalState+AgentDetection.swift | 4 + .../AgentDetection/AgentPidArtifacts.swift | 80 +++++ .../AgentDetection/AgentSessionProfile.swift | 322 ++++++++++++++++++ .../AgentDetection/AgentSessionResolver.swift | 166 ++++----- .../AgentDetection/OpenCodeSessionStore.swift | 47 +++ supacodeTests/AgentSessionProfileTests.swift | 223 ++++++++++++ 8 files changed, 860 insertions(+), 149 deletions(-) create mode 100644 supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift create mode 100644 supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift create mode 100644 supacode/Infrastructure/AgentDetection/OpenCodeSessionStore.swift create mode 100644 supacodeTests/AgentSessionProfileTests.swift diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md index 16bba3db..fda5f151 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/doc-onevcat/agent-session-detection.md @@ -8,53 +8,118 @@ agent session. ## Resolution Model -Resolution is anchored to the exact process selected by Active Agents: - -1. Preserve the matched process PID instead of only the normalized agent name. -2. Inspect that PID's open vnode paths. A unique recognized session file is `exact` evidence. -3. For agents that open transcripts only while appending, enumerate the agent's known session directory and keep files - created or modified during the current process lifetime. -4. Extract a bounded tail from each candidate transcript and compare recent `user`, `assistant`, and `result` text with - the pane's live bottom buffer. -5. Accept only a unique match with a sufficient score and margin. Ambiguous results remain unresolved. -6. Cache and periodically revalidate a mapping by `(pid, process start time)` so PID reuse cannot inherit an old - session and in-process session rotation can be detected. - -The resolver never selects a candidate merely because it is the newest file. A sole process-lifetime candidate is -reported as `medium`; a unique text correlation is `high`; an open file owned by the exact process is `exact`. +Resolution is anchored to the exact process selected by Active Agents. Evidence is tried strongest-first: + +1. **Open descriptors** (`exact`, `open_file`): inspect the agent pid's open vnode paths via + `proc_pidinfo`/`proc_pidfdinfo`. A unique recognized session file (or Amp's per-thread log) is decisive. +2. **Pid-keyed artifacts** (`exact`, `process_log`): files that name the agent pid directly — Copilot's + `logs/process--.log` (containing "Registering foreground session: ") and Qwen's + `.runtime.json` sidecar (`{"pid": ..., "session_id": ...}`). +3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts are compared + with the pane's live text. Only a unique match with sufficient score and margin wins; the margin rule applies + between *distinct sessions* — several files of one session (Kimi, Cline, Copilot) reinforce it instead of competing. +4. **Sole process-lifetime candidate** (`medium`, `recent_file` / `store_record`): storage roots (or OpenCode's sqlite + `session` table) are filtered to entries modified during the process lifetime; a single distinct session id wins. + +Supporting rules: + +- Per-agent knowledge (path grammar, storage roots, cwd encoders, pid artifacts, store queries) lives in one place: + `AgentSessionProfile`. Prowl targets only the **latest released CLI** of each agent — when a CLI changes layout, + edit its profile in place rather than adding version detection. +- The resolver never picks a candidate merely because it is the newest file. Ambiguity is a normal outcome. +- Results are cached by `(pid, process start time)` so pid reuse cannot inherit an old session; resolved mappings are + revalidated every 5 s (rotation via `/clear` is caught by the transcript match), unresolved ones retried once per + second. +- Storage scans use narrowed roots first (Codex day directories, Kimi/Cursor `md5(cwd)`, Gemini slug/sha256), then a + wider fallback root only when the narrow scan finds nothing (a resumed Codex rollout lives in its original day + directory). + +## Working-directory encoders (verified on-disk) + +| Encoder | Rule | Used by | +| --- | --- | --- | +| `alphanumericDashed` | every char outside `[A-Za-z0-9]` → `-` (`/a/b_c.d` → `-a-b-c-d`) | Claude (`~/.claude/projects/`) | +| `slashDashed` | only `/` → `-`; dots and spaces kept | Pi (wrapped `-…--`), Droid | +| `md5(cwd)` | lowercase hex md5 of the absolute path | Kimi, Cursor | +| `sha256(cwd)` | lowercase hex sha256 | Gemini (older layout); newer maps cwd→slug in `~/.gemini/projects.json` | +| plain cwd | stored verbatim in metadata | OpenCode (`session.directory`), Copilot (`workspace.yaml`), Qwen sidecar | + +Claude's rule matters in production: Prowl worktrees live under `~/.prowl/repos/...`, and the dot in `.prowl` must be +encoded as `-` or every Claude session in a managed worktree fails to resolve. ## Support Matrix -The following versions were inspected on 2026-07-11: +Verified 2026-07-11 against locally installed CLIs (best effort where noted): -| Agent | Version | Storage recognized | Current confidence path | +| Agent | Version | Storage recognized | Strongest evidence path | +| --- | --- | --- | --- | +| Codex | 0.144.1 | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | Open rollout FD (`exact`) | +| Claude Code | 2.1.207 | `~/.claude/projects//*.jsonl` | Lifetime + transcript match | +| Pi / OMP | 0.79.2 | `~/.pi/agent/sessions/---/*.jsonl` | Lifetime + transcript match | +| Gemini CLI | 0.46.0 | `~/.gemini/tmp//chats/session-*.jsonl` | Lifetime + transcript match | +| Cursor Agent | 2026.05.09 | `~/.cursor/chats///store.db` | Open store.db FD (unverified); path recognition | +| Cline | 2.18.0 | `~/.cline/data/tasks//...` | Path recognition; best effort | +| GitHub Copilot CLI | 1.0.70 | `~/.copilot/session-state//...` | **Pid log artifact (`exact`)** | +| Kimi (Python 1.x) | 1.41.0 | `~/.kimi/sessions///...` | Lifetime + transcript match | +| Droid | 0.147.0 | `~/.factory/sessions//.jsonl` | Lifetime + transcript match | +| OpenCode | 1.17.18 | `opencode.db` `session(id, directory, time_updated)` | **Store query (`medium`)** | +| Amp | 2026-05 build | `~/.cache/amp/logs/threads/T-.log` (open FD, logged in) | **Open thread-log FD (`exact`)** | +| Qwen Code | not installed | `~/.qwen/projects//chats/*.jsonl` + `*.runtime.json` | **Pid sidecar (`exact`)**; best effort | + +Codex keeps its rollout JSONL open for the whole interactive session. Claude, Pi, Droid, and Kimi close their session +files between writes; Amp only materializes local thread artifacts when logged in; OpenCode's TUI holds only the shared +sqlite database (no per-session file, and by default no local server port — the API worker is in-process). + +## Rejected channel: child-process environment variables + +Five CLIs inject their session id into tool child processes (Claude `CLAUDE_CODE_SESSION_ID`, Codex `CODEX_THREAD_ID`, +Copilot `COPILOT_AGENT_SESSION_ID`, Qwen `QWEN_CODE_SESSION_ID`, Amp `AMP_CURRENT_THREAD_ID`/`AGENT_THREAD_ID`). +**Reading them from outside is not possible on modern macOS**: since the macOS 15 hardening, `KERN_PROCARGS2` returns +only `argc + exec path + argv` for other processes — the environment block is stripped for non-entitled callers, even +for the caller's own children (verify with `ps eww `: no env shown). Do not reattempt this channel; the variable +names remain valuable for a future cooperative hook/shell-integration provider that runs *inside* the pane. + +## Native identity/handoff surface per agent (2026-07 research) + +For the future handoff/dispatch features. "Pre-mint" = the orchestrator chooses or learns the id at spawn time, +removing any need for detection. + +| Agent | Hook w/ session id | Pre-mint at spawn | Headless resume | | --- | --- | --- | --- | -| Codex | 0.144.1 | `~/.codex/sessions/**/rollout-*.jsonl` | Exact writable open FD; transcript fallback | -| Claude Code | 2.1.206 | `~/.claude/projects//*.jsonl` | Process lifetime + transcript/screen match | -| Pi / OMP | Pi 0.79.2 | `~/.pi/agent/sessions//*.jsonl` | Process lifetime + transcript/screen match | -| Gemini CLI | 0.46.0 | `~/.gemini/tmp/**/chats/session-*.jsonl` | Best effort, strict unique match | -| Cursor Agent | 2026.05.09-0afadcc | `~/.cursor/chats///store.db` | Path recognition; best effort | -| Cline | 2.18.0 | `~/.cline/data/tasks//...` | Path recognition; best effort | -| GitHub Copilot CLI | 1.0.44 | `~/.copilot/session-state//...` | Path recognition; best effort | -| Kimi Code | 1.41.0 | `~/.kimi/sessions///...` | Path recognition; best effort | -| Droid | 0.147.0 | `~/.factory/sessions//.jsonl` | Process lifetime + transcript/screen match | -| OpenCode | 1.17.18 | Shared `opencode.db` | Unresolved: no safe process-to-row mapping yet | -| Amp | 0.0.1778328768-gb9a37d | No stable transcript mapping observed | Unresolved | -| Qwen | Not installed | Not verified | Unresolved | - -Codex kept its rollout JSONL open for the whole interactive session. Claude and Pi were both tested after completing a -real prompt; each created and wrote its JSONL, then closed it while the interactive process remained alive. This is why -open-FD inspection is primary evidence rather than the only strategy. +| Claude Code | `SessionStart` etc., stdin JSON incl. `transcript_path` | `--session-id ` | `claude -p --resume ` | +| Codex | hooks GA (`session_id` + rollout path); legacy `notify` | — | `codex exec resume "prompt"` | +| Gemini | hooks ≥ 0.26 (`session_id` + `transcript_path`) | `--session-id ` | `gemini -r "prompt"` | +| Qwen | Claude-style hooks; official pid sidecar | `--session-id ` | `qwen --resume -p` | +| Copilot | `sessionStart` hooks (`sessionId`) | `--session-id ` | `copilot -p "..." --session-id ` | +| Cursor | hooks partial in CLI (`sessionStart` fires) | `create-chat` prints id | `cursor-agent -p --resume=` | +| Kimi | hooks in config (`session_id`, no transcript) | — | `kimi -p --session ` | +| Droid | Claude-style hooks incl. `transcript_path` | — | `droid exec -s "prompt"` | +| Pi | TS extensions (`session_start`, `getSessionFile()`) | `--session-id ` (creates) | `pi --session ` | +| OpenCode | JS plugins (`session.created/idle`, `shell.env`) | — | `opencode run -s "msg"` | +| Amp | JS plugins (`session.start` w/ thread id) | `amp threads new` prints id | `amp threads continue -x "msg"` | +| Cline | hooks (`TaskStart`, `taskId`) | — | `cline -y -T ` | + +Implications: + +- When Prowl itself spawns the agent (task dispatch), prefer pre-minting (`--session-id`/`create-chat`/`threads new`): + identity is exact by construction and this table is the contract to use. +- A cooperative hook provider (Prowl-installed hook configs reporting `{session_id, transcript_path, pid}` back over + the prowl socket, or via an OSC sequence written to `/dev/tty` so it survives SSH) can later supply `exact` evidence + for user-started sessions without changing the consumer contract. +- Version drift is real (Kimi is migrating `~/.kimi` → `~/.kimi-code`; Cline 3.x moves to a hub + sqlite; Droid docs + show a different transcript root than 0.147 uses). Profiles encode the latest observed truth only, and hooks that + hand Prowl a `transcript_path` should always win over computed paths. ## Safety and Performance - Darwin inspection uses `proc_pidinfo` / `proc_pidfdinfo`; Prowl never shells out to `lsof`. - Results are cached per process lifetime. Unresolved processes retry at most once per second. -- Transcript reads are capped at 128 KiB from the tail. -- Known storage roots are used; arbitrary home-directory searching is not performed. -- Matching accepts ambiguity as a normal outcome. A session ID from `medium` confidence must not be used for automatic - resume/fork without additional confirmation. -- Local PID inspection cannot see agents behind SSH, containers, VMs, or nested tmux servers. +- Transcript reads are capped at 128 KiB from the tail; Copilot log reads at 256 KiB. +- Known storage roots only; narrowed roots first, wider fallback second; no arbitrary home-directory searching. +- OpenCode's database is opened read-only with a 50 ms busy timeout; failures degrade to "unresolved". +- Matching accepts ambiguity as a normal outcome. A `medium` session id must not be used for automatic resume/fork + without additional confirmation. +- Local pid inspection cannot see agents behind SSH, containers, VMs, or nested tmux servers. ## Verification @@ -64,25 +129,31 @@ Build and run a Debug Prowl app, start an agent in a pane, send at least one dis prowl agents --json | jq '.data.agents[] | {type, pane: .pane.id, session}' ``` -A simple Codex process with one open rollout uses `source: "open_file"` and `confidence: "exact"`. A Codex process that -also owns subagent rollouts is disambiguated like Claude and Pi: after a distinctive user or assistant message it uses -`source: "transcript_match"` and `confidence: "high"`. Parallel sessions in the same directory with indistinguishable -visible text must return `session: null` rather than guess. +- Codex with one open rollout: `source: "open_file"`, `confidence: "exact"`. +- Claude in a directory containing `.`/`_`/space (e.g. a `~/.prowl/repos/...` worktree): must resolve via + `recent_file` or `transcript_match` — this exercises the `alphanumericDashed` encoder. +- Copilot: `source: "process_log"`, `confidence: "exact"` once the TUI has registered its session. +- Amp (logged in): `source: "open_file"` with the thread id from the open per-thread log. +- OpenCode: sole session in the worktree resolves as `source: "store_record"`, `confidence: "medium"`; parallel + sessions in one directory stay `null`. +- Parallel same-directory sessions with indistinguishable visible text must return `session: null` rather than guess. Targeted automated coverage: ```bash xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ -only-testing:supacodeTests/AgentSessionResolverTests \ + -only-testing:supacodeTests/AgentSessionProfileTests \ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation ``` -The test suite covers known path parsing, native open-FD enumeration against the test process, process-lifetime -filtering, unique transcript matching, and ambiguous transcript rejection. +The suites cover path parsing per agent, cwd encoders, root narrowing (day directories, md5, slug), native open-FD +enumeration against the test process, process-lifetime filtering with session-id grouping, unique/ambiguous transcript +matching, the Copilot pid-log and Qwen sidecar artifacts, and the OpenCode store query against a fixture database. ## Future Uses -- Add native session identity and confidence to handoff preparation. +- Add native session identity and confidence to handoff preparation; pre-mint ids for Prowl-initiated sessions. - Fork or resume a stopped source agent only when the mapping is exact/high. - Open or export the correct transcript from Active Agents. - Restore agent sessions after an app restart. diff --git a/docs/components/cli.md b/docs/components/cli.md index 7d5146aa..d0886e28 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -102,8 +102,12 @@ Each agent contains: - `worktree`, `tab`, `pane`: the actual terminal owner and pane metadata for automation. - `session`: optional native agent session metadata. When resolved, it contains - `id`, local transcript `path`, `confidence` (`exact`, `high`, or `medium`), - and the evidence `source`. Ambiguous sessions are omitted instead of guessed. + `id`, local transcript `path` (may be null when the id comes from a non-file + artifact), `confidence` (`exact`, `high`, or `medium`), and the evidence + `source` (`open_file`, `process_log`, `transcript_match`, `recent_file`, or + `store_record`). Ambiguous sessions are omitted instead of guessed. A + `medium` session id must not be used for automatic resume/fork without + additional confirmation. `prowl agents` is read-only. To jump to or operate on an agent, resolve `.data.agents[].pane.id`, then use existing commands: diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 74292bbc..f2aefc65 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -128,6 +128,10 @@ extension WorktreeTerminalState { workingDirectory: workingDirectory, activeText: activeText ) + // Re-check after the suspension: the pane may have been closed and its + // agent state cleaned up while the resolver was doing file inspection; + // writing below would resurrect a ghost Active Agents entry. + guard surfaces[surfaceID] != nil else { return false } let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt let next = PaneAgentState( detectedAgent: agent, diff --git a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift new file mode 100644 index 00000000..6a61f968 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Copilot CLI writes `~/.copilot/logs/process--.log` and logs +/// "Registering foreground session: " once the interactive session is +/// live, giving an exact pid→session mapping without descriptor inspection. +nonisolated enum CopilotProcessLog { + static func session( + logsDirectory: URL, + sessionStateDirectory: URL, + pid: pid_t, + processStartedAt: Date, + fileManager: FileManager = .default + ) -> AgentSession? { + guard let log = latestLog(in: logsDirectory, pid: pid, fileManager: fileManager), + let modifiedAt = try? log.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, + modifiedAt >= processStartedAt.addingTimeInterval(-2), + let id = lastRegisteredSession(in: log) + else { return nil } + let transcript = sessionStateDirectory.appending(path: "\(id)/events.jsonl") + return AgentSession( + id: id, + transcriptPath: fileManager.fileExists(atPath: transcript.path) ? transcript : nil, + source: .processLog, + confidence: .exact + ) + } + + /// Pid reuse across app restarts leaves several `process-*-.log` files; + /// the largest epoch prefix is the current process's log. + private static func latestLog(in directory: URL, pid: pid_t, fileManager: FileManager) -> URL? { + let suffix = "-\(pid).log" + let logs = (try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] + return + logs + .filter { $0.lastPathComponent.hasPrefix("process-") && $0.lastPathComponent.hasSuffix(suffix) } + .max { $0.lastPathComponent < $1.lastPathComponent } + } + + private static func lastRegisteredSession(in url: URL, byteLimit: Int = 262_144) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + guard let size = try? handle.seekToEnd() else { return nil } + try? handle.seek(toOffset: size > UInt64(byteLimit) ? size - UInt64(byteLimit) : 0) + guard let data = try? handle.readToEnd(), let text = String(data: data, encoding: .utf8) else { return nil } + let pattern = /Registering foreground session: ([0-9a-fA-F-]{36})/ + return text.matches(of: pattern).last.map { String($0.output.1).lowercased() } + } +} + +/// Qwen Code writes `/.runtime.json` sidecars +/// (`{"pid": ..., "session_id": ...}`) for every live session, intended for +/// external observers. +nonisolated enum QwenRuntimeStatus { + static func session( + projectsRoot: URL, + pid: pid_t, + fileManager: FileManager = .default + ) -> AgentSession? { + let projects = (try? fileManager.contentsOfDirectory(at: projectsRoot, includingPropertiesForKeys: nil)) ?? [] + for project in projects { + let chats = project.appending(path: "chats") + let sidecars = (try? fileManager.contentsOfDirectory(at: chats, includingPropertiesForKeys: nil)) ?? [] + for sidecar in sidecars where sidecar.lastPathComponent.hasSuffix(".runtime.json") { + guard let data = try? Data(contentsOf: sidecar), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let sidecarPID = object["pid"] as? Int, sidecarPID == Int(pid), + let id = object["session_id"] as? String, !id.isEmpty + else { continue } + let transcript = chats.appending(path: "\(id).jsonl") + return AgentSession( + id: id, + transcriptPath: fileManager.fileExists(atPath: transcript.path) ? transcript : nil, + source: .processLog, + confidence: .exact + ) + } + } + return nil + } +} diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift new file mode 100644 index 00000000..d46674ae --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -0,0 +1,322 @@ +import CryptoKit +import Foundation + +/// Single source of truth for one agent CLI's native session knowledge. +/// +/// Prowl tracks only the latest released CLI of each agent. When a CLI changes +/// its storage layout, environment contract, or identity artifacts, update that +/// agent's builder below in place; do not add version detection layers. +nonisolated struct AgentSessionProfile: Sendable { + /// Parses an absolute file path owned by the agent (open descriptor or + /// storage scan hit) into a session. + var parsePath: @Sendable (_ path: String) -> AgentSession? = { _ in nil } + /// Storage roots scanned for session files modified during the process + /// lifetime. Narrow these as much as the layout allows. + var candidateRoots: @Sendable (_ home: URL, _ cwd: URL?, _ processStartedAt: Date, _ now: Date) -> [URL] = { + _, _, _, _ in [] + } + /// Wider roots scanned only when `candidateRoots` yields no candidate, e.g. + /// resumed Codex rollouts that live in their original date directory. + var fallbackRoots: @Sendable (_ home: URL, _ cwd: URL?) -> [URL] = { _, _ in [] } + /// Exact artifact lookup keyed by the agent process id (Copilot process log, + /// Qwen runtime sidecar). + var pidKeyedSession: (@Sendable (_ home: URL, _ pid: pid_t, _ processStartedAt: Date) -> AgentSession?)? + /// Candidate enumeration for agents whose sessions live in a shared store + /// instead of per-session files (OpenCode's sqlite database). + var storeCandidates: (@Sendable (_ home: URL, _ cwd: URL?, _ processStartedAt: Date) -> [AgentSessionCandidate])? + + static func profile(for agent: DetectedAgent) -> AgentSessionProfile { + switch agent { + case .codex: .codex + case .claude: .claude + case .pi: .pi + case .gemini: .gemini + case .cursor: .cursor + case .cline: .cline + case .copilot: .copilot + case .kimi: .kimi + case .droid: .droid + case .opencode: .opencode + case .amp: .amp + case .qwen: .qwen + } + } +} + +// MARK: - Per-agent profiles + +nonisolated extension AgentSessionProfile { + /// Codex ≥ 0.144: `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`, + /// held open for the whole interactive session. `CODEX_THREAD_ID` on exec + /// tool children. New sessions land in day directories within the process + /// lifetime; resumed rollouts stay in their original day, hence the full + /// fallback root. + fileprivate static let codex = AgentSessionProfile( + parsePath: { uuidJSONL(path: $0, marker: "/.codex/sessions/") }, + candidateRoots: { home, _, processStartedAt, now in + dayDirectories( + root: home.appending(path: ".codex/sessions"), + from: processStartedAt, + to: now + ) + }, + fallbackRoots: { home, _ in + [home.appending(path: ".codex/sessions")] + } + ) + + /// Claude Code ≥ 2.1: `~/.claude/projects//.jsonl`, + /// closed between writes; every non-alphanumeric cwd character becomes `-`. + /// `CLAUDE_CODE_SESSION_ID` on Bash/MCP tool children. + fileprivate static let claude = AgentSessionProfile( + parsePath: { uuidJSONL(path: $0, marker: "/.claude/projects/") }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [] } + return [home.appending(path: ".claude/projects/\(alphanumericDashed(cwd.path))")] + } + ) + + // Pi ≥ 0.79: `~/.pi/agent/sessions/----/*.jsonl`; dots + // and spaces in the cwd are preserved. + // swiftlint:disable:next identifier_name + fileprivate static let pi = AgentSessionProfile( + parsePath: { uuidJSONL(path: $0, marker: "/.pi/agent/sessions/") }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [] } + return [home.appending(path: ".pi/agent/sessions/-\(slashDashed(cwd.path))--")] + } + ) + + /// Gemini CLI ≥ 0.46: `~/.gemini/tmp//chats/session--.jsonl` + /// with the full id in the JSONL header; `~/.gemini/projects.json` maps the + /// absolute cwd to the slug, and older layouts used `sha256(cwd)` directories. + fileprivate static let gemini = AgentSessionProfile( + parsePath: { path in + let url = URL(fileURLWithPath: path) + guard path.contains("/.gemini/tmp/"), path.contains("/chats/"), url.pathExtension == "jsonl" else { return nil } + guard let id = url.deletingPathExtension().lastPathComponent.split(separator: "-").last.map(String.init), + !id.isEmpty + else { return nil } + return AgentSession(id: id, transcriptPath: url, source: .recentFile) + }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [home.appending(path: ".gemini/tmp")] } + let tmp = home.appending(path: ".gemini/tmp") + var roots: [URL] = [] + if let slug = geminiProjectSlug(home: home, cwd: cwd) { + roots.append(tmp.appending(path: "\(slug)/chats")) + } + roots.append(tmp.appending(path: "\(sha256Hex(cwd.path))/chats")) + return roots + }, + fallbackRoots: { home, _ in + [home.appending(path: ".gemini/tmp")] + } + ) + + /// Cursor Agent: `~/.cursor/chats///store.db`. + fileprivate static let cursor = AgentSessionProfile( + parsePath: { parentID(path: $0, marker: "/.cursor/chats/", filename: "store.db") }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [home.appending(path: ".cursor/chats")] } + return [home.appending(path: ".cursor/chats/\(md5Hex(cwd.path))")] + }, + fallbackRoots: { home, _ in + [home.appending(path: ".cursor/chats")] + } + ) + + /// Cline 2.x: `~/.cline/data/tasks//...`. + fileprivate static let cline = AgentSessionProfile( + parsePath: { markedComponent(path: $0, marker: "/.cline/data/tasks/", component: "tasks") }, + candidateRoots: { home, _, _, _ in + [home.appending(path: ".cline/data/tasks")] + } + ) + + /// Copilot CLI ≥ 1.0: `~/.copilot/session-state//...` plus + /// `~/.copilot/logs/process--.log` containing + /// "Registering foreground session: ". `COPILOT_AGENT_SESSION_ID` on + /// shell children. + fileprivate static let copilot = AgentSessionProfile( + parsePath: { markedComponent(path: $0, marker: "/.copilot/session-state/", component: "session-state") }, + candidateRoots: { home, _, _, _ in + [home.appending(path: ".copilot/session-state")] + }, + pidKeyedSession: { home, pid, processStartedAt in + CopilotProcessLog.session( + logsDirectory: home.appending(path: ".copilot/logs"), + sessionStateDirectory: home.appending(path: ".copilot/session-state"), + pid: pid, + processStartedAt: processStartedAt + ) + } + ) + + /// Kimi (Python CLI 1.x): `~/.kimi/sessions///{context.jsonl, + /// wire.jsonl, state.json}`. + fileprivate static let kimi = AgentSessionProfile( + parsePath: { path in + guard path.contains("/.kimi/sessions/") else { return nil } + let components = URL(fileURLWithPath: path).pathComponents + guard let index = components.firstIndex(of: "sessions"), components.count > index + 2 else { return nil } + return AgentSession( + id: components[index + 2], + transcriptPath: URL(fileURLWithPath: path), + source: .recentFile + ) + }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [home.appending(path: ".kimi/sessions")] } + return [home.appending(path: ".kimi/sessions/\(md5Hex(cwd.path))")] + }, + fallbackRoots: { home, _ in + [home.appending(path: ".kimi/sessions")] + } + ) + + /// Droid ≥ 0.147: `~/.factory/sessions//.jsonl`; + /// spaces in the cwd are preserved. + fileprivate static let droid = AgentSessionProfile( + parsePath: { uuidJSONL(path: $0, marker: "/.factory/sessions/") }, + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [] } + return [home.appending(path: ".factory/sessions/\(slashDashed(cwd.path))")] + } + ) + + /// OpenCode ≥ 1.2: sessions live in the shared sqlite database + /// `~/.local/share/opencode/opencode.db` (`session.directory` = plain cwd); + /// the TUI holds no per-session file, so store rows stand in for candidate + /// files. Rows carry no transcript, so text correlation is unavailable and + /// parallel sessions in one directory stay unresolved. + fileprivate static let opencode = AgentSessionProfile( + storeCandidates: { home, cwd, processStartedAt in + guard let cwd else { return [] } + return OpenCodeSessionStore.candidates( + databaseURL: home.appending(path: ".local/share/opencode/opencode.db"), + directory: cwd.path, + modifiedAfter: processStartedAt + ) + } + ) + + /// Amp: threads are server-side; the logged-in TUI holds a per-thread log + /// `~/.cache/amp/logs/threads/T-.log` open, and injects + /// `AMP_CURRENT_THREAD_ID` into Bash tool children (undocumented). + fileprivate static let amp = AgentSessionProfile( + parsePath: { path in + let url = URL(fileURLWithPath: path) + let id = url.deletingPathExtension().lastPathComponent + guard id.hasPrefix("T-") else { return nil } + if path.contains("/.cache/amp/logs/threads/"), url.pathExtension == "log" { + return AgentSession(id: id, transcriptPath: nil, source: .recentFile) + } + if path.contains("/.local/share/amp/threads/"), url.pathExtension == "json" { + return AgentSession(id: id, transcriptPath: url, source: .recentFile) + } + return nil + } + ) + + /// Qwen Code: `~/.qwen/projects//chats/.jsonl` plus an + /// official pid→session sidecar `.runtime.json`. `QWEN_CODE_SESSION_ID` + /// on shell children. Storage layout is best-effort: not verified against a + /// local install. + fileprivate static let qwen = AgentSessionProfile( + parsePath: { uuidJSONL(path: $0, marker: "/.qwen/projects/") }, + candidateRoots: { home, _, _, _ in + [home.appending(path: ".qwen/projects")] + }, + pidKeyedSession: { home, pid, _ in + QwenRuntimeStatus.session(projectsRoot: home.appending(path: ".qwen/projects"), pid: pid) + } + ) +} + +// MARK: - Path parsing helpers + +nonisolated extension AgentSessionProfile { + fileprivate static func uuidJSONL(path: String, marker: String) -> AgentSession? { + let url = URL(fileURLWithPath: path) + guard path.contains(marker), url.pathExtension == "jsonl", + let id = uuid(in: url.deletingPathExtension().lastPathComponent) + else { return nil } + return AgentSession(id: id, transcriptPath: url, source: .recentFile) + } + + fileprivate static func parentID(path: String, marker: String, filename: String) -> AgentSession? { + let url = URL(fileURLWithPath: path) + guard path.contains(marker), url.lastPathComponent == filename, + let id = url.pathComponents.dropLast().last + else { return nil } + return AgentSession(id: id, transcriptPath: url, source: .recentFile) + } + + fileprivate static func markedComponent(path: String, marker: String, component: String) -> AgentSession? { + guard path.contains(marker) else { return nil } + let components = URL(fileURLWithPath: path).pathComponents + guard let index = components.firstIndex(of: component), components.indices.contains(index + 1) else { return nil } + return AgentSession( + id: components[index + 1], + transcriptPath: URL(fileURLWithPath: path), + source: .recentFile + ) + } + + fileprivate static func uuid(in value: String) -> String? { + let pattern = #"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"# + guard let range = value.range(of: pattern, options: .regularExpression) else { return nil } + return String(value[range]).lowercased() + } +} + +// MARK: - Working-directory encoders + +nonisolated extension AgentSessionProfile { + /// `/a/b` → `-a-b`; only slashes are replaced (Pi and Droid keep every other + /// character verbatim). + fileprivate static func slashDashed(_ path: String) -> String { + path.replacing("/", with: "-") + } + + /// Claude Code's project-directory rule: every character outside + /// `[A-Za-z0-9]` becomes `-` (verified: `/a/b_c.d` → `-a-b-c-d`). + fileprivate static func alphanumericDashed(_ path: String) -> String { + String(path.map { $0.isASCII && ($0.isLetter || $0.isNumber) ? $0 : "-" }) + } + + fileprivate static func md5Hex(_ value: String) -> String { + Insecure.MD5.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + fileprivate static func sha256Hex(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + /// 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. + fileprivate static func dayDirectories(root: URL, from processStartedAt: Date, to now: Date, cap: Int = 32) -> [URL] { + let calendar = Calendar.current + let start = calendar.startOfDay(for: processStartedAt.addingTimeInterval(-86_400)) + let end = calendar.startOfDay(for: now) + guard let span = calendar.dateComponents([.day], from: start, to: end).day, span >= 0, span < cap else { + return [] + } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd" + return (0...span).compactMap { offset in + calendar.date(byAdding: .day, value: offset, to: start).map { root.appending(path: formatter.string(from: $0)) } + } + } + + fileprivate static func geminiProjectSlug(home: URL, cwd: URL) -> String? { + let url = home.appending(path: ".gemini/projects.json") + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let projects = object["projects"] as? [String: Any] + else { return nil } + return projects[cwd.path] as? String + } +} diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 17cbb522..224c12ab 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -4,6 +4,8 @@ nonisolated struct AgentSession: Equatable, Sendable { enum Source: String, Equatable, Sendable { case commandLine = "command_line" case openFile = "open_file" + case processLog = "process_log" + case storeRecord = "store_record" case transcriptMatch = "transcript_match" case recentFile = "recent_file" } @@ -36,76 +38,26 @@ nonisolated struct AgentSessionCandidate: Equatable, Sendable { let session: AgentSession let modifiedAt: Date + /// The sole session active during the process lifetime, or nil when zero or + /// several distinct sessions qualify. Grouping by session id keeps layouts + /// with several files per session (Kimi, Cline, Copilot) resolvable. nonisolated static func uniqueActiveCandidate( _ candidates: [Self], processStartedAt: Date, clockSkew: TimeInterval = 2 ) -> Self? { let active = candidates.filter { $0.modifiedAt >= processStartedAt.addingTimeInterval(-clockSkew) } - return active.count == 1 ? active[0] : nil + let sessions = Dictionary(grouping: active) { $0.session.id } + guard sessions.count == 1, let group = sessions.values.first else { return nil } + return group.max { $0.modifiedAt < $1.modifiedAt } } } +/// Compatibility shim over the per-agent profiles; the actual rules live in +/// `AgentSessionProfile`. nonisolated enum AgentSessionPathParser { static func parse(path: String, agent: DetectedAgent) -> AgentSession? { - let url = URL(fileURLWithPath: path) - let id = sessionID(path: path, url: url, agent: agent) - guard let id, !id.isEmpty else { return nil } - return AgentSession(id: id, transcriptPath: url, source: .recentFile) - } - - private static func sessionID(path: String, url: URL, agent: DetectedAgent) -> String? { - switch agent { - case .codex: uuidJSONL(path: path, marker: "/.codex/sessions/", url: url) - case .claude: uuidJSONL(path: path, marker: "/.claude/projects/", url: url) - case .pi: uuidJSONL(path: path, marker: "/.pi/agent/sessions/", url: url) - case .gemini: geminiID(path: path, url: url) - case .cursor: parentID(path: path, marker: "/.cursor/chats/", filename: "store.db", url: url) - case .cline: markedComponent(path: path, marker: "/.cline/data/tasks/", component: "tasks", url: url) - case .copilot: - markedComponent(path: path, marker: "/.copilot/session-state/", component: "session-state", url: url) - case .kimi: kimiID(path: path, url: url) - case .droid: uuidJSONL(path: path, marker: "/.factory/sessions/", url: url) - case .opencode, .amp, .qwen: nil - } - } - - private static func uuidJSONL(path: String, marker: String, url: URL) -> String? { - guard path.contains(marker), url.pathExtension == "jsonl" else { return nil } - return uuid(in: url.deletingPathExtension().lastPathComponent) - } - - private static func geminiID(path: String, url: URL) -> String? { - guard path.contains("/.gemini/tmp/"), path.contains("/chats/"), url.pathExtension == "jsonl" else { return nil } - return url.deletingPathExtension().lastPathComponent.split(separator: "-").last.map(String.init) - } - - private static func parentID(path: String, marker: String, filename: String, url: URL) -> String? { - guard path.contains(marker), url.lastPathComponent == filename else { return nil } - return url.pathComponents.dropLast().last - } - - private static func markedComponent(path: String, marker: String, component: String, url: URL) -> String? { - guard path.contains(marker) else { return nil } - return self.component(after: component, in: url.pathComponents) - } - - private static func kimiID(path: String, url: URL) -> String? { - guard path.contains("/.kimi/sessions/") else { return nil } - let components = url.pathComponents - guard let index = components.firstIndex(of: "sessions"), components.count > index + 2 else { return nil } - return components[index + 2] - } - - private static func uuid(in value: String) -> String? { - let pattern = #"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"# - guard let range = value.range(of: pattern, options: .regularExpression) else { return nil } - return String(value[range]).lowercased() - } - - private static func component(after marker: String, in components: [String]) -> String? { - guard let index = components.firstIndex(of: marker), components.indices.contains(index + 1) else { return nil } - return components[index + 1] + AgentSessionProfile.profile(for: agent).parsePath(path) } } @@ -152,7 +104,8 @@ actor AgentSessionResolver { identified: identified, processStartedAt: startedAt, workingDirectory: workingDirectory, - activeText: activeText + activeText: activeText, + now: now ) cache[key] = CachedResult(resolvedAt: now, session: session) if cache.count > 128 { @@ -165,10 +118,13 @@ actor AgentSessionResolver { identified: IdentifiedAgentProcess, processStartedAt: Date, workingDirectory: URL?, - activeText: String + activeText: String, + now: Date ) -> AgentSession? { + let profile = AgentSessionProfile.profile(for: identified.agent) + let openSessions = ProcessDetection.openFilePaths(pid: identified.process.pid) - .compactMap { AgentSessionPathParser.parse(path: $0, agent: identified.agent) } + .compactMap { profile.parsePath($0) } if let session = uniqueSession(openSessions) { return AgentSession( id: session.id, @@ -177,6 +133,11 @@ actor AgentSessionResolver { confidence: .exact ) } + + if let session = profile.pidKeyedSession?(homeDirectory, identified.process.pid, processStartedAt) { + return session + } + let openCandidates = openSessions.compactMap { session -> AgentSessionCandidate? in guard let path = session.transcriptPath, let attributes = try? fileManager.attributesOfItem(atPath: path.path), @@ -197,9 +158,10 @@ actor AgentSessionResolver { } let candidates = recentCandidates( - agent: identified.agent, + profile: profile, + processStartedAt: processStartedAt, workingDirectory: workingDirectory, - processStartedAt: processStartedAt + now: now ) if let matched = AgentSessionFingerprintMatcher.bestMatch(activeText: activeText, candidates: candidates) { return AgentSession( @@ -218,13 +180,35 @@ actor AgentSessionResolver { } private func recentCandidates( - agent: DetectedAgent, + profile: AgentSessionProfile, + processStartedAt: Date, workingDirectory: URL?, + now: Date + ) -> [AgentSessionCandidate] { + let stored = + profile.storeCandidates?(homeDirectory, workingDirectory, processStartedAt.addingTimeInterval(-2)) ?? [] + let primary = candidates( + in: profile.candidateRoots(homeDirectory, workingDirectory, processStartedAt, now), + profile: profile, + processStartedAt: processStartedAt + ) + let combined = primary + stored + guard combined.isEmpty else { return combined } + return candidates( + in: profile.fallbackRoots(homeDirectory, workingDirectory), + profile: profile, + processStartedAt: processStartedAt + ) + } + + private func candidates( + in roots: [URL], + profile: AgentSessionProfile, processStartedAt: Date ) -> [AgentSessionCandidate] { - candidateRoots(agent: agent, workingDirectory: workingDirectory).flatMap { root in + roots.flatMap { root in recentFiles(in: root, modifiedAfter: processStartedAt.addingTimeInterval(-2)).compactMap { item in - guard let session = AgentSessionPathParser.parse(path: item.url.path, agent: agent) else { return nil } + guard let session = profile.parsePath(item.url.path) else { return nil } let enriched = sessionIDFromHeader(at: item.url).map { AgentSession(id: $0, transcriptPath: item.url, source: .recentFile) @@ -249,38 +233,6 @@ actor AgentSessionResolver { return nil } - private func candidateRoots(agent: DetectedAgent, workingDirectory: URL?) -> [URL] { - switch agent { - case .codex: - return [homeDirectory.appending(path: ".codex/sessions")] - case .claude: - guard let workingDirectory else { return [] } - return [homeDirectory.appending(path: ".claude/projects/\(encodedClaudePath(workingDirectory.path))")] - case .pi: - guard let workingDirectory else { return [] } - return [homeDirectory.appending(path: ".pi/agent/sessions/-\(encodedClaudePath(workingDirectory.path))--")] - case .gemini: - return [homeDirectory.appending(path: ".gemini/tmp")] - case .cursor: - return [homeDirectory.appending(path: ".cursor/chats")] - case .cline: - return [homeDirectory.appending(path: ".cline/data/tasks")] - case .copilot: - return [homeDirectory.appending(path: ".copilot/session-state")] - case .kimi: - return [homeDirectory.appending(path: ".kimi/sessions")] - case .droid: - guard let workingDirectory else { return [] } - return [homeDirectory.appending(path: ".factory/sessions/\(encodedClaudePath(workingDirectory.path))")] - case .opencode, .amp, .qwen: - return [] - } - } - - private func encodedClaudePath(_ path: String) -> String { - path.replacing("/", with: "-") - } - private func recentFiles(in root: URL, modifiedAfter threshold: Date) -> [(url: URL, modifiedAt: Date)] { guard let enumerator = fileManager.enumerator( @@ -323,11 +275,19 @@ nonisolated enum AgentSessionFingerprintMatcher { return suffix.count >= 24 && screen.contains(suffix) ? max(best, suffix.count) : best } return score > 0 ? (candidate, score) : nil - }.sorted { $0.1 > $1.1 } + } + + // The margin rule guards against picking between *sessions* that look + // alike; files belonging to one session reinforce it instead of competing. + let sessions = Dictionary(grouping: scored) { $0.0.session.id } + .map { id, group in + (id: id, best: group.max { $0.1 < $1.1 }!) + } + .sorted { $0.best.1 > $1.best.1 } - guard let best = scored.first, best.1 >= 40 else { return nil } - if scored.count > 1, best.1 - scored[1].1 < 20 { return nil } - return best.0 + guard let winner = sessions.first, winner.best.1 >= 40 else { return nil } + if sessions.count > 1, winner.best.1 - sessions[1].best.1 < 20 { return nil } + return winner.best.0 } static func normalize(_ value: String) -> String { diff --git a/supacode/Infrastructure/AgentDetection/OpenCodeSessionStore.swift b/supacode/Infrastructure/AgentDetection/OpenCodeSessionStore.swift new file mode 100644 index 00000000..d5d6dbf4 --- /dev/null +++ b/supacode/Infrastructure/AgentDetection/OpenCodeSessionStore.swift @@ -0,0 +1,47 @@ +import Foundation +import SQLite3 + +/// Read-only lookup into OpenCode's shared sqlite database +/// (`~/.local/share/opencode/opencode.db`). The `session` table stores the +/// plain working directory per session, so process-lifetime filtering works +/// the same way it does for file-based agents. +nonisolated enum OpenCodeSessionStore { + static func candidates( + databaseURL: URL, + directory: String, + modifiedAfter threshold: Date, + limit: Int = 8 + ) -> [AgentSessionCandidate] { + var database: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let database else { + sqlite3_close(database) + return [] + } + defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 50) + + let query = + "SELECT id, time_updated FROM session WHERE directory = ?1 AND time_updated >= ?2 " + + "ORDER BY time_updated DESC LIMIT ?3" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK, let statement else { return [] } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, directory, -1, transient) + sqlite3_bind_int64(statement, 2, Int64(threshold.timeIntervalSince1970 * 1_000)) + sqlite3_bind_int(statement, 3, Int32(limit)) + + var candidates: [AgentSessionCandidate] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let idPointer = sqlite3_column_text(statement, 0) else { continue } + let updatedAt = Date(timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 1)) / 1_000) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: String(cString: idPointer), transcriptPath: nil, source: .storeRecord), + modifiedAt: updatedAt + ) + ) + } + return candidates + } +} diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift new file mode 100644 index 00000000..e6f0d463 --- /dev/null +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -0,0 +1,223 @@ +import Darwin +import Foundation +import SQLite3 +import Testing + +@testable import supacode + +struct AgentSessionProfileTests { + private let home = URL(fileURLWithPath: "/Users/me", isDirectory: true) + private let now = Date(timeIntervalSince1970: 1_783_800_000) + + // MARK: - Working-directory encoders + + @Test func claudeRootSanitizesEveryNonAlphanumericCharacter() { + let roots = AgentSessionProfile.profile(for: .claude).candidateRoots( + home, + URL(fileURLWithPath: "/private/tmp/prowl_556.enc", isDirectory: true), + now, + now + ) + #expect(roots.map(\.path) == ["/Users/me/.claude/projects/-private-tmp-prowl-556-enc"]) + } + + @Test func piAndDroidRootsKeepDotsAndSpaces() { + let cwd = URL(fileURLWithPath: "/Users/me/.prowl/repos/My App", isDirectory: true) + let piRoots = AgentSessionProfile.profile(for: .pi).candidateRoots(home, cwd, now, now) + #expect(piRoots.map(\.path) == ["/Users/me/.pi/agent/sessions/--Users-me-.prowl-repos-My App--"]) + let droidRoots = AgentSessionProfile.profile(for: .droid).candidateRoots(home, cwd, now, now) + #expect(droidRoots.map(\.path) == ["/Users/me/.factory/sessions/-Users-me-.prowl-repos-My App"]) + } + + // MARK: - Candidate-root narrowing + + @Test func codexRootsNarrowToProcessLifetimeDays() { + let calendar = Calendar.current + let processStartedAt = calendar.date(byAdding: .day, value: -2, to: now)! + let roots = AgentSessionProfile.profile(for: .codex).candidateRoots(home, nil, processStartedAt, now) + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd" + let expected = (0...3).map { offset in + let day = calendar.date(byAdding: .day, value: -offset, to: now)! + return "/Users/me/.codex/sessions/\(formatter.string(from: day))" + } + #expect(Set(roots.map(\.path)) == Set(expected)) + let fallback = AgentSessionProfile.profile(for: .codex).fallbackRoots(home, nil) + #expect(fallback.map(\.path) == ["/Users/me/.codex/sessions"]) + } + + @Test func kimiAndCursorRootsUseMD5OfWorkingDirectory() { + let cwd = URL(fileURLWithPath: "/Users/onevcat/Sync/github/Prowl", isDirectory: true) + let hash = "62c1e94d156560ededc10a672654f7ad" + let kimi = AgentSessionProfile.profile(for: .kimi) + #expect(kimi.candidateRoots(home, cwd, now, now).map(\.path) == ["/Users/me/.kimi/sessions/\(hash)"]) + #expect(kimi.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.kimi/sessions"]) + let cursor = AgentSessionProfile.profile(for: .cursor) + #expect(cursor.candidateRoots(home, cwd, now, now).map(\.path) == ["/Users/me/.cursor/chats/\(hash)"]) + #expect(cursor.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.cursor/chats"]) + } + + @Test func geminiRootsPreferProjectsJSONSlugAndHash() throws { + let temporaryHome = FileManager.default.temporaryDirectory + .appending(path: "prowl-gemini-home-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: temporaryHome.appending(path: ".gemini"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: temporaryHome) } + let cwd = URL(fileURLWithPath: "/Users/onevcat/Sync/github/Prowl", isDirectory: true) + try #"{"projects": {"/Users/onevcat/Sync/github/Prowl": "prowl"}}"# + .write(to: temporaryHome.appending(path: ".gemini/projects.json"), atomically: true, encoding: .utf8) + + let roots = AgentSessionProfile.profile(for: .gemini).candidateRoots(temporaryHome, cwd, now, now) + let paths = Set(roots.map(\.path)) + let sha = "a165a63702653924c088535c5594c435580d1ddeaf52f638d8a1fa830adf95e0" + #expect(paths.contains(temporaryHome.appending(path: ".gemini/tmp/prowl/chats").path)) + #expect(paths.contains(temporaryHome.appending(path: ".gemini/tmp/\(sha)/chats").path)) + let fallback = AgentSessionProfile.profile(for: .gemini).fallbackRoots(temporaryHome, cwd) + #expect(fallback.map(\.path) == [temporaryHome.appending(path: ".gemini/tmp").path]) + } + + // MARK: - Session-id grouping + + @Test func uniqueActiveCandidateAcceptsMultipleFilesOfOneSession() { + let start = Date(timeIntervalSince1970: 1_000) + let files = ["context.jsonl", "wire.jsonl", "state.json"].enumerated().map { index, name in + AgentSessionCandidate( + session: AgentSession( + id: "fe8b1447", + transcriptPath: URL(fileURLWithPath: "/tmp/session/\(name)"), + source: .recentFile + ), + modifiedAt: Date(timeIntervalSince1970: 1_001 + TimeInterval(index)) + ) + } + let unique = AgentSessionCandidate.uniqueActiveCandidate(files, processStartedAt: start) + #expect(unique?.session.id == "fe8b1447") + #expect(unique?.modifiedAt == Date(timeIntervalSince1970: 1_003)) + } + + @Test func fingerprintDoesNotApplyMarginBetweenFilesOfOneSession() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-same-session-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let content = #"{"message":{"content":"Refactor the authentication middleware without changing its API."}}"# + let urls = [root.appending(path: "context.jsonl"), root.appending(path: "wire.jsonl")] + for url in urls { try content.write(to: url, atomically: true, encoding: .utf8) } + let candidates = urls.map { url in + AgentSessionCandidate( + session: AgentSession(id: "same-session", transcriptPath: url, source: .recentFile), + modifiedAt: .now + ) + } + + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "❯ Refactor the authentication middleware without changing its API.", + candidates: candidates + ) + #expect(match?.session.id == "same-session") + } + + // MARK: - Pid-keyed artifacts + + @Test func copilotProcessLogYieldsRegisteredSession() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-copilot-\(UUID().uuidString)", directoryHint: .isDirectory) + let logs = root.appending(path: "logs") + let state = root.appending(path: "session-state") + let id = "e7f43538-26a4-4dfd-a4af-06427bc9d69d" + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: state.appending(path: id), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: state.appending(path: "\(id)/events.jsonl").path, contents: Data()) + defer { try? FileManager.default.removeItem(at: root) } + let log = """ + 2026-07-11T02:58:34.100Z [INFO] Registering foreground session: 11111111-2222-3333-4444-555555555555 + 2026-07-11T02:58:34.500Z [INFO] Unregistering foreground session: 11111111-2222-3333-4444-555555555555 + 2026-07-11T02:58:34.928Z [INFO] Registering foreground session: \(id) + """ + try log.write(to: logs.appending(path: "process-1783748855136-4242.log"), atomically: true, encoding: .utf8) + + let session = CopilotProcessLog.session( + logsDirectory: logs, + sessionStateDirectory: state, + pid: 4242, + processStartedAt: .now.addingTimeInterval(-60) + ) + #expect(session?.id == id) + #expect(session?.source == .processLog) + #expect(session?.confidence == .exact) + #expect(session?.transcriptPath?.path == state.appending(path: "\(id)/events.jsonl").path) + + let mismatch = CopilotProcessLog.session( + logsDirectory: logs, + sessionStateDirectory: state, + pid: 9999, + processStartedAt: .now.addingTimeInterval(-60) + ) + #expect(mismatch == nil) + } + + @Test func qwenRuntimeSidecarYieldsPidMatchedSession() throws { + let projects = FileManager.default.temporaryDirectory + .appending(path: "prowl-qwen-\(UUID().uuidString)", directoryHint: .isDirectory) + let chats = projects.appending(path: "-Users-me-App/chats") + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projects) } + let id = "019f4f1b-3650-7661-a56d-351f02f01139" + try #"{"schema_version":1,"pid":555,"session_id":"\#(id)","work_dir":"/Users/me/App"}"# + .write(to: chats.appending(path: "\(id).runtime.json"), atomically: true, encoding: .utf8) + try "{}".write(to: chats.appending(path: "\(id).jsonl"), atomically: true, encoding: .utf8) + + let session = QwenRuntimeStatus.session(projectsRoot: projects, pid: 555) + #expect(session?.id == id) + #expect(session?.source == .processLog) + #expect(session?.confidence == .exact) + // contentsOfDirectory resolves the /var → /private/var symlink; align both sides. + #expect( + session?.transcriptPath?.resolvingSymlinksInPath() + == chats.appending(path: "\(id).jsonl").resolvingSymlinksInPath() + ) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 556) == nil) + } + + // MARK: - Amp thread log parsing + + @Test func ampParsesOpenThreadLogPath() throws { + let path = "/Users/me/.cache/amp/logs/threads/T-019f4fbf-040b-704b-8247-d4754f7dae6c.log" + let session = try #require(AgentSessionPathParser.parse(path: path, agent: .amp)) + #expect(session.id == "T-019f4fbf-040b-704b-8247-d4754f7dae6c") + #expect(AgentSessionPathParser.parse(path: "/Users/me/.cache/amp/logs/cli.log", agent: .amp) == nil) + } + + // MARK: - OpenCode store + + @Test func openCodeStoreReturnsLifetimeCandidatesForDirectory() throws { + let databaseURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-opencode-\(UUID().uuidString).db") + defer { try? FileManager.default.removeItem(at: databaseURL) } + var database: OpaquePointer? + #expect(sqlite3_open(databaseURL.path, &database) == SQLITE_OK) + defer { sqlite3_close(database) } + let schema = "CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, time_updated INTEGER);" + #expect(sqlite3_exec(database, schema, nil, nil, nil) == SQLITE_OK) + let threshold = Date(timeIntervalSince1970: 1_783_700_000) + let rows = [ + "('ses_current', '/tmp/project', \(Int64((threshold.timeIntervalSince1970 + 60) * 1_000)))", + "('ses_stale', '/tmp/project', \(Int64((threshold.timeIntervalSince1970 - 60) * 1_000)))", + "('ses_other', '/tmp/other', \(Int64((threshold.timeIntervalSince1970 + 60) * 1_000)))", + ] + for row in rows { + #expect(sqlite3_exec(database, "INSERT INTO session VALUES \(row);", nil, nil, nil) == SQLITE_OK) + } + + let candidates = OpenCodeSessionStore.candidates( + databaseURL: databaseURL, + directory: "/tmp/project", + modifiedAfter: threshold + ) + #expect(candidates.map(\.session.id) == ["ses_current"]) + #expect(candidates.first?.session.source == .storeRecord) + } +} -- 2.51.2 From a5fad65a65e22348dd6e6673fe0b73e17e6bb381 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 17:43:28 +0900 Subject: [PATCH 3/8] Verify Qwen layout from source and harden sidecar validation Read QwenLM/qwen-code@deb45ae instead of installing the CLI: - sanitizeCwd is Claude's rule ([^a-zA-Z0-9] -> "-"), so the qwen scan root narrows to ~/.qwen/projects//chats with the full projects tree as fallback. - runtime.json sidecars are written for external observers, swapped on same-pid session rotation, and intentionally left behind on quit or crash. Validate schema_version == 1 and started_at >= process start so a stale claim inherited by a reused pid is rejected instead of reported as exact. --- doc-onevcat/agent-session-detection.md | 9 +++- .../AgentDetection/AgentPidArtifacts.swift | 16 +++++-- .../AgentDetection/AgentSessionProfile.swift | 23 +++++++--- supacodeTests/AgentSessionProfileTests.swift | 44 +++++++++++++++++-- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md index fda5f151..48b1cb3c 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/doc-onevcat/agent-session-detection.md @@ -40,6 +40,7 @@ Supporting rules: | --- | --- | --- | | `alphanumericDashed` | every char outside `[A-Za-z0-9]` → `-` (`/a/b_c.d` → `-a-b-c-d`) | Claude (`~/.claude/projects/`) | | `slashDashed` | only `/` → `-`; dots and spaces kept | Pi (wrapped `-…--`), Droid | +| `alphanumericDashed` (again) | same rule, independently implemented as `sanitizeCwd` | Qwen (`~/.qwen/projects/`) | | `md5(cwd)` | lowercase hex md5 of the absolute path | Kimi, Cursor | | `sha256(cwd)` | lowercase hex sha256 | Gemini (older layout); newer maps cwd→slug in `~/.gemini/projects.json` | | plain cwd | stored verbatim in metadata | OpenCode (`session.directory`), Copilot (`workspace.yaml`), Qwen sidecar | @@ -64,12 +65,18 @@ Verified 2026-07-11 against locally installed CLIs (best effort where noted): | Droid | 0.147.0 | `~/.factory/sessions//.jsonl` | Lifetime + transcript match | | OpenCode | 1.17.18 | `opencode.db` `session(id, directory, time_updated)` | **Store query (`medium`)** | | Amp | 2026-05 build | `~/.cache/amp/logs/threads/T-.log` (open FD, logged in) | **Open thread-log FD (`exact`)** | -| Qwen Code | not installed | `~/.qwen/projects//chats/*.jsonl` + `*.runtime.json` | **Pid sidecar (`exact`)**; best effort | +| Qwen Code | source@deb45ae | `~/.qwen/projects//chats/*.jsonl` + `*.runtime.json` | **Pid sidecar (`exact`)**; source-verified | Codex keeps its rollout JSONL open for the whole interactive session. Claude, Pi, Droid, and Kimi close their session files between writes; Amp only materializes local thread artifacts when logged in; OpenCode's TUI holds only the shared sqlite database (no per-session file, and by default no local server port — the API worker is in-process). +Qwen's `runtime.json` sidecar (source: `packages/core/src/utils/runtimeStatus.ts`) is written explicitly for external +observers like Prowl: created on session start, atomically swapped on `/clear`/`/resume` (same pid, new session id), +and intentionally **left behind** on quit/crash. Consumers must therefore validate the claim against the live process; +Prowl requires `schema_version == 1`, a pid match, and `started_at >= process start - 2 s`, which rejects stale claims +inherited by a reused pid. + ## Rejected channel: child-process environment variables Five CLIs inject their session id into tool child processes (Claude `CLAUDE_CODE_SESSION_ID`, Codex `CODEX_THREAD_ID`, diff --git a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift index 6a61f968..e1e2d996 100644 --- a/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift +++ b/supacode/Infrastructure/AgentDetection/AgentPidArtifacts.swift @@ -47,13 +47,20 @@ nonisolated enum CopilotProcessLog { } } -/// Qwen Code writes `/.runtime.json` sidecars -/// (`{"pid": ..., "session_id": ...}`) for every live session, intended for -/// external observers. +/// Qwen Code writes `/.runtime.json` sidecars for every +/// live interactive session, explicitly for external observers (source: +/// `packages/core/src/utils/runtimeStatus.ts`). Session rotation (`/clear`, +/// `/resume`) atomically swaps the sidecar, but quit/crash leaves it behind — +/// consumers must verify the claim against the live process. A claim whose +/// `started_at` predates the process start belongs to a previous owner of a +/// reused pid. nonisolated enum QwenRuntimeStatus { + private static let schemaVersion = 1 + static func session( projectsRoot: URL, pid: pid_t, + processStartedAt: Date, fileManager: FileManager = .default ) -> AgentSession? { let projects = (try? fileManager.contentsOfDirectory(at: projectsRoot, includingPropertiesForKeys: nil)) ?? [] @@ -63,7 +70,10 @@ nonisolated enum QwenRuntimeStatus { for sidecar in sidecars where sidecar.lastPathComponent.hasSuffix(".runtime.json") { guard let data = try? Data(contentsOf: sidecar), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["schema_version"] as? Int == schemaVersion, let sidecarPID = object["pid"] as? Int, sidecarPID == Int(pid), + let startedAt = object["started_at"] as? Double, + startedAt >= processStartedAt.timeIntervalSince1970 - 2, let id = object["session_id"] as? String, !id.isEmpty else { continue } let transcript = chats.appending(path: "\(id).jsonl") diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index d46674ae..88de2af0 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -219,17 +219,26 @@ nonisolated extension AgentSessionProfile { } ) - /// Qwen Code: `~/.qwen/projects//chats/.jsonl` plus an - /// official pid→session sidecar `.runtime.json`. `QWEN_CODE_SESSION_ID` - /// on shell children. Storage layout is best-effort: not verified against a - /// local install. + /// Qwen Code: `~/.qwen/projects//chats/.jsonl` plus the + /// official pid→session sidecar `.runtime.json` next to it. The cwd + /// sanitizer is Claude's rule (`[^a-zA-Z0-9]` → `-`, `sanitizeCwd` in + /// `packages/core/src/utils/paths.ts`). Source-verified against QwenLM/ + /// qwen-code@deb45ae; not exercised against a local install. fileprivate static let qwen = AgentSessionProfile( parsePath: { uuidJSONL(path: $0, marker: "/.qwen/projects/") }, - candidateRoots: { home, _, _, _ in + candidateRoots: { home, cwd, _, _ in + guard let cwd else { return [home.appending(path: ".qwen/projects")] } + return [home.appending(path: ".qwen/projects/\(alphanumericDashed(cwd.path))/chats")] + }, + fallbackRoots: { home, _ in [home.appending(path: ".qwen/projects")] }, - pidKeyedSession: { home, pid, _ in - QwenRuntimeStatus.session(projectsRoot: home.appending(path: ".qwen/projects"), pid: pid) + pidKeyedSession: { home, pid, processStartedAt in + QwenRuntimeStatus.session( + projectsRoot: home.appending(path: ".qwen/projects"), + pid: pid, + processStartedAt: processStartedAt + ) } ) } diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index e6f0d463..06d75d56 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -165,12 +165,13 @@ struct AgentSessionProfileTests { let chats = projects.appending(path: "-Users-me-App/chats") try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: projects) } + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) let id = "019f4f1b-3650-7661-a56d-351f02f01139" - try #"{"schema_version":1,"pid":555,"session_id":"\#(id)","work_dir":"/Users/me/App"}"# + try sidecarJSON(id: id, pid: 555, startedAt: processStartedAt.timeIntervalSince1970 + 5) .write(to: chats.appending(path: "\(id).runtime.json"), atomically: true, encoding: .utf8) try "{}".write(to: chats.appending(path: "\(id).jsonl"), atomically: true, encoding: .utf8) - let session = QwenRuntimeStatus.session(projectsRoot: projects, pid: 555) + let session = QwenRuntimeStatus.session(projectsRoot: projects, pid: 555, processStartedAt: processStartedAt) #expect(session?.id == id) #expect(session?.source == .processLog) #expect(session?.confidence == .exact) @@ -179,7 +180,44 @@ struct AgentSessionProfileTests { session?.transcriptPath?.resolvingSymlinksInPath() == chats.appending(path: "\(id).jsonl").resolvingSymlinksInPath() ) - #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 556) == nil) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 556, processStartedAt: processStartedAt) == nil) + } + + @Test func qwenRuntimeSidecarRejectsStaleClaimsFromReusedPids() throws { + let projects = FileManager.default.temporaryDirectory + .appending(path: "prowl-qwen-stale-\(UUID().uuidString)", directoryHint: .isDirectory) + let chats = projects.appending(path: "-Users-me-App/chats") + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projects) } + let processStartedAt = Date(timeIntervalSince1970: 1_783_800_000) + + // Sidecars survive quit/crash; a claim older than the live process's start + // time belongs to a previous owner of the reused pid. + let stale = "11111111-2222-3333-4444-555555555555" + try sidecarJSON(id: stale, pid: 555, startedAt: processStartedAt.timeIntervalSince1970 - 3_600) + .write(to: chats.appending(path: "\(stale).runtime.json"), atomically: true, encoding: .utf8) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 555, processStartedAt: processStartedAt) == nil) + + // Unknown schema versions are not ours to interpret. + let futuristic = "66666666-7777-8888-9999-000000000000" + try sidecarJSON(id: futuristic, pid: 555, startedAt: processStartedAt.timeIntervalSince1970 + 5, schema: 2) + .write(to: chats.appending(path: "\(futuristic).runtime.json"), atomically: true, encoding: .utf8) + #expect(QwenRuntimeStatus.session(projectsRoot: projects, pid: 555, processStartedAt: processStartedAt) == nil) + } + + private func sidecarJSON(id: String, pid: Int, startedAt: TimeInterval, schema: Int = 1) -> String { + #"{"schema_version":\#(schema),"pid":\#(pid),"session_id":"\#(id)","work_dir":"/Users/me/App","# + + #""hostname":"test","started_at":\#(startedAt),"qwen_version":"0.23.0"}"# + } + + @Test func qwenRootsNarrowToSanitizedProjectDirectory() { + let cwd = URL(fileURLWithPath: "/private/tmp/prowl_556.enc", isDirectory: true) + let qwen = AgentSessionProfile.profile(for: .qwen) + #expect( + qwen.candidateRoots(home, cwd, now, now).map(\.path) + == ["/Users/me/.qwen/projects/-private-tmp-prowl-556-enc/chats"] + ) + #expect(qwen.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.qwen/projects"]) } // MARK: - Amp thread log parsing -- 2.51.2 From 47ac3f33ceccacee988d3d78075bea80d25261c6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 18:10:34 +0900 Subject: [PATCH 4/8] Address independent review findings on session detection An independent adversarial review (fresh Claude session over the full branch diff, with on-disk verification) surfaced nine actionable findings; all are fixed here with tests: - Sticky sessions now expire after two consecutive ambiguous resolutions on the same process, so an id rotated away by /clear cannot survive indefinitely; probe gaps (presence holds) keep the session and pid without aging instead of flapping to null. - A sole process-lifetime candidate (medium) needs two consecutive agreeing resolutions and must not collide with a session already claimed by another live process, closing the startup race where a new pane briefly sees only its sibling's session file. - Open-descriptor evidence only counts descriptors opened for writing; read-only descriptors from resume pickers or history browsing can no longer claim a session as exact. - Open-descriptor hits get the same per-profile header enrichment as storage scans, so Gemini reports the full uuid instead of the truncated filename prefix; gemini paths also require the session- filename prefix now. - Claude's cwd encoder replaces per UTF-16 code unit (surrogate-pair emoji become two dashes, verified against a live Claude run) and normalizes to NFC first. - Header enrichment is opt-in per profile (only Gemini) instead of generic first-line sniffing that could grab unrelated ids from event-stream layouts like Copilot's events.jsonl. - cwd-keyed lookups try both the logical and symlink-resolved paths (/tmp vs /private/tmp). - Unresolved lookups back off exponentially to a 15 s cap, directory enumeration is capped at 20k entries, and fingerprinting reads at most the 12 freshest candidates. - Codex day-directory formatting pins the POSIX locale and Gregorian calendar; cache pruning validates the process start time so pid reuse cannot keep dead entries alive. --- doc-onevcat/agent-session-detection.md | 27 ++- .../AgentDetection/PaneAgentState.swift | 20 +++ ...WorktreeTerminalState+AgentDetection.swift | 19 +- .../AgentDetection/AgentSessionProfile.swift | 35 +++- .../AgentDetection/AgentSessionResolver.swift | 170 ++++++++++++++---- .../AgentDetection/ProcessDetection.swift | 5 + supacodeTests/AgentSessionProfileTests.swift | 98 ++++++++++ supacodeTests/AgentSessionResolverTests.swift | 40 +++++ 8 files changed, 363 insertions(+), 51 deletions(-) diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md index 48b1cb3c..5fad4594 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/doc-onevcat/agent-session-detection.md @@ -15,9 +15,10 @@ Resolution is anchored to the exact process selected by Active Agents. Evidence 2. **Pid-keyed artifacts** (`exact`, `process_log`): files that name the agent pid directly — Copilot's `logs/process--.log` (containing "Registering foreground session: ") and Qwen's `.runtime.json` sidecar (`{"pid": ..., "session_id": ...}`). -3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts are compared - with the pane's live text. Only a unique match with sufficient score and margin wins; the margin rule applies - between *distinct sessions* — several files of one session (Kimi, Cline, Copilot) reinforce it instead of competing. +3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts (the 12 + most recently modified) are compared with the pane's live text. Only a unique match with sufficient score and + margin wins; the margin rule applies between *distinct sessions* — several files of one session (Kimi, Cline, + Copilot) reinforce it instead of competing. 4. **Sole process-lifetime candidate** (`medium`, `recent_file` / `store_record`): storage roots (or OpenCode's sqlite `session` table) are filtered to entries modified during the process lifetime; a single distinct session id wins. @@ -28,8 +29,8 @@ Supporting rules: edit its profile in place rather than adding version detection. - The resolver never picks a candidate merely because it is the newest file. Ambiguity is a normal outcome. - Results are cached by `(pid, process start time)` so pid reuse cannot inherit an old session; resolved mappings are - revalidated every 5 s (rotation via `/clear` is caught by the transcript match), unresolved ones retried once per - second. + revalidated every 5 s. In-process rotation (`/clear`) converges through re-resolution plus the retention cap: an id + that stays ambiguous for two consecutive resolutions is dropped rather than displayed forever. - Storage scans use narrowed roots first (Codex day directories, Kimi/Cursor `md5(cwd)`, Gemini slug/sha256), then a wider fallback root only when the narrow scan finds nothing (a resumed Codex rollout lives in its original day directory). @@ -120,7 +121,21 @@ Implications: ## Safety and Performance - Darwin inspection uses `proc_pidinfo` / `proc_pidfdinfo`; Prowl never shells out to `lsof`. -- Results are cached per process lifetime. Unresolved processes retry at most once per second. +- Results are cached per process lifetime. Unresolved lookups back off exponentially (1 s doubling to a 15 s cap; + wide-root fallback scans start at 8 s), so a permanently ambiguous pane costs almost nothing. Directory enumeration + is additionally capped at 20 000 entries per scan (truncation logs a warning and degrades to "unresolved"). +- Open-descriptor evidence only counts descriptors opened for WRITING: agents transiently open other sessions + read-only (resume pickers, history browsing) and a read FD must not claim a session. +- A sole process-lifetime candidate (`medium`) is only adopted after two consecutive resolutions agree on it, and + never when another live process already claimed the same session id — this closes the startup race where a new pane + in a shared directory briefly sees only its sibling's session file. +- A previously resolved session is retained through probe gaps, but at most two consecutive ambiguous resolutions on + the same process; after that it is dropped so a rotated-away id (`/clear`) cannot survive indefinitely. +- Header enrichment (replacing a path-derived id with a JSONL first-line field) is opt-in per profile and only Gemini + uses it — its filenames carry a truncated id. Generic header sniffing is forbidden: event-stream layouts like + Copilot's `events.jsonl` may expose unrelated ids at the top level. +- The Claude/Qwen cwd encoder replaces per UTF-16 code unit (emoji → two dashes) and normalizes to NFC first; NFD-named + directories (decomposed accents) stay unresolved rather than mismatched. - Transcript reads are capped at 128 KiB from the tail; Copilot log reads at 256 KiB. - Known storage roots only; narrowed roots first, wider fallback second; no arbitrary home-directory searching. - OpenCode's database is opened read-only with a 50 ms busy timeout; failures degrade to "unresolved". diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index c292259c..922a3329 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -4,6 +4,9 @@ struct PaneAgentState: Equatable, Sendable { var detectedAgent: DetectedAgent? var agentProcessID: pid_t? var session: AgentSession? + /// Consecutive resolver misses while the same process stayed detected; + /// bounds how long a previously resolved session may be retained. + var sessionMissStreak: Int = 0 var iconLookupToken: String? var fallbackState: AgentRawState var state: AgentRawState @@ -30,6 +33,23 @@ struct PaneAgentState: Equatable, Sendable { self.lastChangedAt = lastChangedAt } + /// Sticky-session policy: a fresh resolution always wins; a probe gap + /// (`identifiedPID == nil`, presence hold) keeps the last session without + /// aging it; an ambiguous resolver result on the same process keeps it for + /// at most two misses so a rotated-away session id cannot survive + /// indefinitely. A different pid discards it immediately. + static func retainedSession( + resolved: AgentSession?, + previous: PaneAgentState, + identifiedPID: pid_t? + ) -> (session: AgentSession?, missStreak: Int) { + if let resolved { return (resolved, 0) } + guard let identifiedPID else { return (previous.session, previous.sessionMissStreak) } + guard identifiedPID == previous.agentProcessID else { return (nil, 0) } + let streak = previous.sessionMissStreak + 1 + return (streak >= 3 ? nil : previous.session, streak) + } + var displayState: AgentDisplayState { switch state { case .working: diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index f2aefc65..75fa173f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -122,20 +122,26 @@ extension WorktreeTerminalState { } let iconLookupToken = identified?.name ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) - let session = await resolveAgentSession( + let resolved = await resolveAgentSession( identified: identified, - previous: previous, workingDirectory: workingDirectory, activeText: activeText ) + let (session, sessionMissStreak) = PaneAgentState.retainedSession( + resolved: resolved, + previous: previous, + identifiedPID: identified?.process.pid + ) // Re-check after the suspension: the pane may have been closed and its // agent state cleaned up while the resolver was doing file inspection; // writing below would resurrect a ghost Active Agents entry. guard surfaces[surfaceID] != nil else { return false } let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt - let next = PaneAgentState( + var next = PaneAgentState( detectedAgent: agent, - agentProcessID: identified?.process.pid, + // Presence holds keep the last known pid so a probe gap does not flap + // the session to nil and back (the resolver re-binds on the next hit). + agentProcessID: identified?.process.pid ?? previous.agentProcessID, session: session, iconLookupToken: iconLookupToken, fallbackState: raw, @@ -143,6 +149,7 @@ extension WorktreeTerminalState { seen: seen, lastChangedAt: lastChangedAt ) + next.sessionMissStreak = sessionMissStreak // Limit logging to meaningful transitions - agent identity or // stabilized state changes. Raw oscillation and `seen` flips are // routine and would otherwise dominate the log stream. @@ -170,17 +177,15 @@ extension WorktreeTerminalState { private func resolveAgentSession( identified: IdentifiedAgentProcess?, - previous: PaneAgentState, workingDirectory: URL?, activeText: String ) async -> AgentSession? { guard let identified else { return nil } - let resolved = await AgentSessionResolver.shared.resolve( + return await AgentSessionResolver.shared.resolve( identified: identified, workingDirectory: workingDirectory, activeText: activeText ) - return resolved ?? (previous.agentProcessID == identified.process.pid ? previous.session : nil) } func markAgentSeen(surfaceID: UUID) { diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 88de2af0..a1502c19 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -10,6 +10,12 @@ nonisolated struct AgentSessionProfile: Sendable { /// Parses an absolute file path owned by the agent (open descriptor or /// storage scan hit) into a session. var parsePath: @Sendable (_ path: String) -> AgentSession? = { _ in nil } + /// Top-level JSON keys in a transcript's first line whose value REPLACES the + /// path-derived session id. Opt-in per agent: only meaningful when the + /// filename carries a truncated id (Gemini). Leave empty when the path + /// already holds the full id — generic sniffing can grab an unrelated field + /// from event-stream layouts like Copilot's events.jsonl. + var headerSessionIDKeys: [String] = [] /// Storage roots scanned for session files modified during the process /// lifetime. Narrow these as much as the layout allows. var candidateRoots: @Sendable (_ home: URL, _ cwd: URL?, _ processStartedAt: Date, _ now: Date) -> [URL] = { @@ -93,12 +99,15 @@ nonisolated extension AgentSessionProfile { fileprivate static let gemini = AgentSessionProfile( parsePath: { path in let url = URL(fileURLWithPath: path) - guard path.contains("/.gemini/tmp/"), path.contains("/chats/"), url.pathExtension == "jsonl" else { return nil } + guard path.contains("/.gemini/tmp/"), path.contains("/chats/"), url.pathExtension == "jsonl", + url.lastPathComponent.hasPrefix("session-") + else { return nil } guard let id = url.deletingPathExtension().lastPathComponent.split(separator: "-").last.map(String.init), !id.isEmpty else { return nil } return AgentSession(id: id, transcriptPath: url, source: .recentFile) }, + headerSessionIDKeys: ["sessionId"], candidateRoots: { home, cwd, _, _ in guard let cwd else { return [home.appending(path: ".gemini/tmp")] } let tmp = home.appending(path: ".gemini/tmp") @@ -289,10 +298,22 @@ nonisolated extension AgentSessionProfile { path.replacing("/", with: "-") } - /// Claude Code's project-directory rule: every character outside - /// `[A-Za-z0-9]` becomes `-` (verified: `/a/b_c.d` → `-a-b-c-d`). + /// Claude Code's project-directory rule: every UTF-16 code unit outside + /// `[A-Za-z0-9]` becomes `-`. Code-unit (not Character) semantics match the + /// JavaScript `replace(/[^a-zA-Z0-9]/g, "-")`: a surrogate-pair emoji yields + /// TWO dashes (verified live: `…-🐱-café` → `…----caf-`). File URLs decompose + /// accented characters (NFD) while Node keeps the shell's precomposed form, + /// so normalize to NFC first; NFD-named directories stay unresolved, which + /// is the safe direction. fileprivate static func alphanumericDashed(_ path: String) -> String { - String(path.map { $0.isASCII && ($0.isLetter || $0.isNumber) ? $0 : "-" }) + String( + path.precomposedStringWithCanonicalMapping.utf16.map { unit -> Character in + let isAlphanumeric = + (unit >= 0x30 && unit <= 0x39) || (unit >= 0x41 && unit <= 0x5A) || (unit >= 0x61 && unit <= 0x7A) + guard isAlphanumeric, let scalar = UnicodeScalar(unit) else { return "-" } + return Character(scalar) + } + ) } fileprivate static func md5Hex(_ value: String) -> String { @@ -307,13 +328,17 @@ nonisolated extension AgentSessionProfile { /// start through today. Long-lived processes fall back to the full root /// instead of enumerating an unbounded directory list. fileprivate static func dayDirectories(root: URL, from processStartedAt: Date, to now: Date, cap: Int = 32) -> [URL] { - let calendar = Calendar.current + let calendar = Calendar(identifier: .gregorian) let start = calendar.startOfDay(for: processStartedAt.addingTimeInterval(-86_400)) let end = calendar.startOfDay(for: now) guard let span = calendar.dateComponents([.day], from: start, to: end).day, span >= 0, span < cap else { return [] } let formatter = DateFormatter() + // Codex writes Gregorian day directories; pin locale and calendar so a + // Buddhist or Japanese system calendar cannot derail the narrow scan. + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) formatter.dateFormat = "yyyy/MM/dd" return (0...span).compactMap { offset in calendar.date(byAdding: .day, value: offset, to: start).map { root.appending(path: formatter.string(from: $0)) } diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 224c12ab..19757451 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -1,5 +1,7 @@ import Foundation +private nonisolated let agentSessionLogger = SupaLogger("AgentSession") + nonisolated struct AgentSession: Equatable, Sendable { enum Source: String, Equatable, Sendable { case commandLine = "command_line" @@ -53,6 +55,14 @@ nonisolated struct AgentSessionCandidate: Equatable, Sendable { } } +nonisolated extension [AgentSessionCandidate] { + /// Store rows queried under two cwd variants can duplicate a session. + fileprivate func uniquedBySessionID() -> [AgentSessionCandidate] { + var seen: Set = [] + return filter { seen.insert($0.session.id).inserted } + } +} + /// Compatibility shim over the per-agent profiles; the actual rules live in /// `AgentSessionProfile`. nonisolated enum AgentSessionPathParser { @@ -72,6 +82,34 @@ actor AgentSessionResolver { private struct CachedResult { let resolvedAt: Date let session: AgentSession? + let usedWideScan: Bool + var unresolvedStreak: Int = 0 + var provisionalSoleID: String? + } + + /// Unresolved lookups retry quickly while the narrow scan stays cheap, then + /// back off exponentially while the pane stays ambiguous; wide fallback + /// scans (full history trees) start at the slow end. 15 s cap keeps a + /// permanently ambiguous pane at negligible background cost while still + /// converging after a session rotation. + nonisolated static func cacheLifetime(hasSession: Bool, usedWideScan: Bool, unresolvedStreak: Int) -> TimeInterval { + if hasSession { return 5 } + let base: TimeInterval = usedWideScan ? 8 : 1 + return min(15, base * TimeInterval(1 << min(unresolvedStreak, 4))) + } + + /// A sole process-lifetime candidate is only trusted after two consecutive + /// resolutions agree on it. A pane that starts in a directory where another + /// agent is actively writing can otherwise adopt that agent's session during + /// the sub-second window before its own file lands. + nonisolated static func confirmSole( + _ session: AgentSession?, + previousProvisionalID: String? + ) -> (session: AgentSession?, provisionalID: String?) { + guard let session else { return (nil, nil) } + guard session.confidence == .medium else { return (session, nil) } + guard session.id == previousProvisionalID else { return (nil, session.id) } + return (session, nil) } private var cache: [CacheKey: CachedResult] = [:] @@ -95,47 +133,86 @@ actor AgentSessionResolver { let process = identified.process guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { return nil } let key = CacheKey(pid: process.pid, startedAt: startedAt) - if let cached = cache[key] { - let lifetime: TimeInterval = cached.session == nil ? 1 : 5 + let cached = cache[key] + if let cached { + let lifetime = Self.cacheLifetime( + hasSession: cached.session != nil, + usedWideScan: cached.usedWideScan, + unresolvedStreak: cached.unresolvedStreak + ) if now.timeIntervalSince(cached.resolvedAt) < lifetime { return cached.session } } - let session = resolveUncached( + let (resolved, usedWideScan) = resolveUncached( identified: identified, processStartedAt: startedAt, workingDirectory: workingDirectory, activeText: activeText, now: now ) - cache[key] = CachedResult(resolvedAt: now, session: session) + var session = resolved + var provisionalID: String? + if let candidate = session, candidate.confidence == .medium { + if claimedByAnotherProcess(candidate.id, excluding: key) { + session = nil + } else { + (session, provisionalID) = Self.confirmSole(candidate, previousProvisionalID: cached?.provisionalSoleID) + } + } + cache[key] = CachedResult( + resolvedAt: now, + session: session, + usedWideScan: usedWideScan, + // A pending sole confirmation retries fast instead of backing off. + unresolvedStreak: session == nil && provisionalID == nil ? (cached?.unresolvedStreak ?? 0) + 1 : 0, + provisionalSoleID: provisionalID + ) if cache.count > 128 { - cache = cache.filter { ProcessDetection.processBSDInfo(pid: $0.key.pid) != nil } + cache = cache.filter { entry in + ProcessDetection.processStartDate(pid: entry.key.pid) == entry.key.startedAt + } } return session } + /// A session id already resolved for a different live process cannot also + /// belong to this one; sole-candidate attribution must not steal it. + private func claimedByAnotherProcess(_ id: String, excluding key: CacheKey) -> Bool { + cache.contains { entry in + entry.key != key && entry.value.session?.id == id + && ProcessDetection.processStartDate(pid: entry.key.pid) == entry.key.startedAt + } + } + private func resolveUncached( identified: IdentifiedAgentProcess, processStartedAt: Date, workingDirectory: URL?, activeText: String, now: Date - ) -> AgentSession? { + ) -> (session: AgentSession?, usedWideScan: Bool) { let profile = AgentSessionProfile.profile(for: identified.agent) let openSessions = ProcessDetection.openFilePaths(pid: identified.process.pid) .compactMap { profile.parsePath($0) } + .map { session -> AgentSession in + guard let path = session.transcriptPath, + let fullID = Self.sessionIDFromHeader(at: path, keys: profile.headerSessionIDKeys) + else { return session } + return AgentSession(id: fullID, transcriptPath: path, source: session.source) + } if let session = uniqueSession(openSessions) { - return AgentSession( + let resolved = AgentSession( id: session.id, transcriptPath: session.transcriptPath, source: .openFile, confidence: .exact ) + return (resolved, false) } if let session = profile.pidKeyedSession?(homeDirectory, identified.process.pid, processStartedAt) { - return session + return (session, false) } let openCandidates = openSessions.compactMap { session -> AgentSessionCandidate? in @@ -149,29 +226,32 @@ actor AgentSessionResolver { activeText: activeText, candidates: openCandidates ) { - return AgentSession( + let resolved = AgentSession( id: matched.session.id, transcriptPath: matched.session.transcriptPath, source: .transcriptMatch, confidence: .high ) + return (resolved, false) } - let candidates = recentCandidates( + let (candidates, usedWideScan) = recentCandidates( profile: profile, processStartedAt: processStartedAt, workingDirectory: workingDirectory, now: now ) if let matched = AgentSessionFingerprintMatcher.bestMatch(activeText: activeText, candidates: candidates) { - return AgentSession( + let resolved = AgentSession( id: matched.session.id, transcriptPath: matched.session.transcriptPath, source: .transcriptMatch, confidence: .high ) + return (resolved, usedWideScan) } - return AgentSessionCandidate.uniqueActiveCandidate(candidates, processStartedAt: processStartedAt)?.session + let sole = AgentSessionCandidate.uniqueActiveCandidate(candidates, processStartedAt: processStartedAt)?.session + return (sole, usedWideScan) } private func uniqueSession(_ sessions: [AgentSession]) -> AgentSession? { @@ -184,21 +264,31 @@ actor AgentSessionResolver { processStartedAt: Date, workingDirectory: URL?, now: Date - ) -> [AgentSessionCandidate] { - let stored = - profile.storeCandidates?(homeDirectory, workingDirectory, processStartedAt.addingTimeInterval(-2)) ?? [] - let primary = candidates( - in: profile.candidateRoots(homeDirectory, workingDirectory, processStartedAt, now), - profile: profile, - processStartedAt: processStartedAt - ) - let combined = primary + stored - guard combined.isEmpty else { return combined } - return candidates( - in: profile.fallbackRoots(homeDirectory, workingDirectory), - profile: profile, - processStartedAt: processStartedAt - ) + ) -> (candidates: [AgentSessionCandidate], usedWideScan: Bool) { + let cwdVariants = workingDirectoryVariants(workingDirectory) + let threshold = processStartedAt.addingTimeInterval(-2) + let stored = cwdVariants.flatMap { profile.storeCandidates?(homeDirectory, $0, threshold) ?? [] } + var primaryRoots: [URL] = [] + for cwd in cwdVariants { + for root in profile.candidateRoots(homeDirectory, cwd, processStartedAt, now) + where !primaryRoots.contains(root) { + primaryRoots.append(root) + } + } + let primary = candidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) + let combined = primary + stored.uniquedBySessionID() + guard combined.isEmpty else { return (combined, false) } + let fallbackRoots = profile.fallbackRoots(homeDirectory, workingDirectory) + guard !fallbackRoots.isEmpty else { return ([], false) } + return (candidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt), true) + } + + /// The pane reports the shell's logical `$PWD` while agents usually record + /// the physical path (`/tmp` vs `/private/tmp`); try both encodings. + private func workingDirectoryVariants(_ cwd: URL?) -> [URL?] { + guard let cwd else { return [nil] } + let resolved = cwd.resolvingSymlinksInPath() + return resolved.path == cwd.path ? [cwd] : [cwd, resolved] } private func candidates( @@ -210,7 +300,7 @@ actor AgentSessionResolver { recentFiles(in: root, modifiedAfter: processStartedAt.addingTimeInterval(-2)).compactMap { item in guard let session = profile.parsePath(item.url.path) else { return nil } let enriched = - sessionIDFromHeader(at: item.url).map { + Self.sessionIDFromHeader(at: item.url, keys: profile.headerSessionIDKeys).map { AgentSession(id: $0, transcriptPath: item.url, source: .recentFile) } ?? session return AgentSessionCandidate(session: enriched, modifiedAt: item.modifiedAt) @@ -218,8 +308,8 @@ actor AgentSessionResolver { } } - private func sessionIDFromHeader(at url: URL) -> String? { - guard url.pathExtension == "jsonl", + nonisolated static func sessionIDFromHeader(at url: URL, keys: [String]) -> String? { + guard !keys.isEmpty, url.pathExtension == "jsonl", let handle = try? FileHandle(forReadingFrom: url) else { return nil } defer { try? handle.close() } @@ -227,13 +317,17 @@ actor AgentSessionResolver { let line = data.split(separator: 0x0A).first, let object = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] else { return nil } - for key in ["sessionId", "session_id", "id"] { + for key in keys { if let value = object[key] as? String, !value.isEmpty { return value } } return nil } - private func recentFiles(in root: URL, modifiedAfter threshold: Date) -> [(url: URL, modifiedAt: Date)] { + private func recentFiles( + in root: URL, + modifiedAfter threshold: Date, + visitLimit: Int = 20_000 + ) -> [(url: URL, modifiedAt: Date)] { guard let enumerator = fileManager.enumerator( at: root, @@ -242,8 +336,16 @@ actor AgentSessionResolver { ) else { return [] } + var visited = 0 var result: [(URL, Date)] = [] for case let url as URL in enumerator { + visited += 1 + if visited > visitLimit { + // A pathological tree; missing candidates degrades to "unresolved", + // never to a wrong id. + agentSessionLogger.warning("Agent session scan truncated at \(visitLimit) entries under \(root.path)") + break + } guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), values.isRegularFile == true, let modifiedAt = values.contentModificationDate, @@ -262,7 +364,9 @@ nonisolated enum AgentSessionFingerprintMatcher { ) -> AgentSessionCandidate? { let screen = normalize(activeText) guard screen.count >= 12 else { return nil } - let scored = candidates.compactMap { candidate -> (AgentSessionCandidate, Int)? in + // Cap tail reads: the freshest files carry the on-screen conversation. + let recent = candidates.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(12) + let scored = recent.compactMap { candidate -> (AgentSessionCandidate, Int)? in guard let path = candidate.session.transcriptPath, let data = tailData(at: path), let text = String(data: data, encoding: .utf8) diff --git a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift index d3ae6621..35f86dff 100644 --- a/supacode/Infrastructure/AgentDetection/ProcessDetection.swift +++ b/supacode/Infrastructure/AgentDetection/ProcessDetection.swift @@ -186,6 +186,11 @@ nonisolated enum ProcessDetection { proc_pidfdinfo(pid, descriptor.proc_fd, PROC_PIDFDVNODEPATHINFO, pointer, Int32(size)) } guard result > 0 else { return nil } + // Only writable descriptors identify a session an agent OWNS. Agents + // transiently open other sessions read-only (resume pickers, history + // browsing); every legitimate signal (Codex rollout, Amp thread log, + // Cursor store.db) is open for writing. + guard info.pfi.fi_openflags & UInt32(FWRITE) != 0 else { return nil } return withUnsafeBytes(of: info.pvip.vip_path) { rawBuffer -> String? in let bytes = rawBuffer.bindMemory(to: UInt8.self) let end = bytes.firstIndex(of: 0) ?? bytes.endIndex diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 06d75d56..8c13452f 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -21,6 +21,19 @@ struct AgentSessionProfileTests { #expect(roots.map(\.path) == ["/Users/me/.claude/projects/-private-tmp-prowl-556-enc"]) } + @Test func claudeRootMatchesJavaScriptCodeUnitSemantics() { + // Claude Code replaces per UTF-16 code unit: a surrogate-pair emoji + // becomes TWO dashes. Observed live: /private/tmp/prowl556-review-🐱-café + // → -private-tmp-prowl556-review----caf- + let roots = AgentSessionProfile.profile(for: .claude).candidateRoots( + home, + URL(fileURLWithPath: "/private/tmp/prowl556-review-🐱-café", isDirectory: true), + now, + now + ) + #expect(roots.map(\.path) == ["/Users/me/.claude/projects/-private-tmp-prowl556-review----caf-"]) + } + @Test func piAndDroidRootsKeepDotsAndSpaces() { let cwd = URL(fileURLWithPath: "/Users/me/.prowl/repos/My App", isDirectory: true) let piRoots = AgentSessionProfile.profile(for: .pi).candidateRoots(home, cwd, now, now) @@ -120,6 +133,35 @@ struct AgentSessionProfileTests { #expect(match?.session.id == "same-session") } + // MARK: - Header enrichment stays per-profile + + @Test func headerEnrichmentNeverOverridesDirectoryDerivedIDs() throws { + // Copilot-style layout: the directory name is the session id and the + // event stream is a JSONL whose first line may carry unrelated ids. + // Generic header sniffing once replaced the correct id with such a field. + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-copilot-header-\(UUID().uuidString)", directoryHint: .isDirectory) + let id = "50b5bd49-d8e8-4ee9-9bae-4eaae5c0bdd8" + let sessionDir = root.appending(path: ".copilot/session-state/\(id)") + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try #"{"type":"event","id":"99999999-aaaa-bbbb-cccc-dddddddddddd","sessionId":"not-this-one"}"# + .write(to: sessionDir.appending(path: "events.jsonl"), atomically: true, encoding: .utf8) + + let profile = AgentSessionProfile.profile(for: .copilot) + let parsed = try #require(profile.parsePath(sessionDir.appending(path: "events.jsonl").path)) + #expect(parsed.id == id) + #expect(profile.headerSessionIDKeys.isEmpty) + } + + @Test func geminiHeaderKeysExpandTruncatedFilenameID() { + // Gemini filenames only carry the first 8 id characters; the header's + // sessionId is the full uuid, so gemini opts in to enrichment. + #expect(AgentSessionProfile.profile(for: .gemini).headerSessionIDKeys == ["sessionId"]) + #expect(AgentSessionProfile.profile(for: .claude).headerSessionIDKeys.isEmpty) + #expect(AgentSessionProfile.profile(for: .kimi).headerSessionIDKeys.isEmpty) + } + // MARK: - Pid-keyed artifacts @Test func copilotProcessLogYieldsRegisteredSession() throws { @@ -220,6 +262,62 @@ struct AgentSessionProfileTests { #expect(qwen.fallbackRoots(home, cwd).map(\.path) == ["/Users/me/.qwen/projects"]) } + @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) + #expect(profile.parsePath("/Users/me/.gemini/tmp/prowl/chats/notes-2026-07-11.jsonl") == nil) + } + + @Test func headerEnrichmentUpgradesTruncatedGeminiID() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-header-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let transcript = root.appending(path: "session-2026-07-11T05-41-23ce3e98.jsonl") + try #"{"sessionId":"23ce3e98-af90-4d5c-8b83-ffcc258dff2b","projectHash":"x"}"# + .write(to: transcript, atomically: true, encoding: .utf8) + + #expect( + AgentSessionResolver.sessionIDFromHeader(at: transcript, keys: ["sessionId"]) + == "23ce3e98-af90-4d5c-8b83-ffcc258dff2b" + ) + // Empty key list (every agent except gemini) never reads the file. + #expect(AgentSessionResolver.sessionIDFromHeader(at: transcript, keys: []) == nil) + // Oversized first lines fall back to the filename-derived id. + let huge = root.appending(path: "huge.jsonl") + try (#"{"sessionId":""# + String(repeating: "x", count: 9_000) + #""}"#) + .write(to: huge, atomically: true, encoding: .utf8) + #expect(AgentSessionResolver.sessionIDFromHeader(at: huge, keys: ["sessionId"]) == nil) + } + + // MARK: - Cache pacing + + @Test func unresolvedLookupsBackOffExponentially() { + #expect(AgentSessionResolver.cacheLifetime(hasSession: true, usedWideScan: false, unresolvedStreak: 9) == 5) + #expect(AgentSessionResolver.cacheLifetime(hasSession: false, usedWideScan: false, unresolvedStreak: 0) == 1) + #expect(AgentSessionResolver.cacheLifetime(hasSession: false, usedWideScan: false, unresolvedStreak: 1) == 2) + #expect(AgentSessionResolver.cacheLifetime(hasSession: false, usedWideScan: false, unresolvedStreak: 3) == 8) + #expect(AgentSessionResolver.cacheLifetime(hasSession: false, usedWideScan: false, unresolvedStreak: 8) == 15) + #expect(AgentSessionResolver.cacheLifetime(hasSession: false, usedWideScan: true, unresolvedStreak: 0) == 8) + #expect(AgentSessionResolver.cacheLifetime(hasSession: false, usedWideScan: true, unresolvedStreak: 5) == 15) + } + + @Test func soleCandidateNeedsTwoConsistentSamples() { + let sole = AgentSession(id: "abc", transcriptPath: nil, source: .recentFile) + let first = AgentSessionResolver.confirmSole(sole, previousProvisionalID: nil) + #expect(first.session == nil) + #expect(first.provisionalID == "abc") + let second = AgentSessionResolver.confirmSole(sole, previousProvisionalID: "abc") + #expect(second.session?.id == "abc") + #expect(second.provisionalID == nil) + let changed = AgentSessionResolver.confirmSole(sole, previousProvisionalID: "other") + #expect(changed.session == nil) + #expect(changed.provisionalID == "abc") + // Exact/high evidence is never held back. + let exact = AgentSession(id: "xyz", transcriptPath: nil, source: .openFile, confidence: .exact) + #expect(AgentSessionResolver.confirmSole(exact, previousProvisionalID: nil).session?.id == "xyz") + } + // MARK: - Amp thread log parsing @Test func ampParsesOpenThreadLogPath() throws { diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift index b57e9fab..41818c33 100644 --- a/supacodeTests/AgentSessionResolverTests.swift +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -92,6 +92,46 @@ struct AgentSessionResolverTests { #expect(AgentSessionCandidate.uniqueActiveCandidate([first, second], processStartedAt: start) == nil) } + @Test func staleSessionsExpireAfterConsecutiveMisses() { + let session = AgentSession(id: "old", transcriptPath: nil, source: .recentFile) + var previous = PaneAgentState(agentProcessID: 42, session: session) + previous.sessionMissStreak = 0 + + // Same process, resolver ambiguous: retained for two misses, dropped on the third. + let miss1 = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 42) + #expect(miss1.session?.id == "old") + #expect(miss1.missStreak == 1) + previous.sessionMissStreak = 2 + let miss3 = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 42) + #expect(miss3.session == nil) + + // Presence hold (probe returned no process): keep without aging. + previous.sessionMissStreak = 2 + let held = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: nil) + #expect(held.session?.id == "old") + #expect(held.missStreak == 2) + + // Fresh resolution resets the streak; new pid drops the session. + let fresh = PaneAgentState.retainedSession(resolved: session, previous: previous, identifiedPID: 42) + #expect(fresh.missStreak == 0) + #expect(PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 43).session == nil) + } + + @Test func openFilePathsExcludeReadOnlyDescriptors() throws { + let url = FileManager.default.temporaryDirectory + .appending(path: "prowl-readonly-\(UUID().uuidString).jsonl") + FileManager.default.createFile(atPath: url.path, contents: Data("x".utf8)) + let descriptor = open(url.path, O_RDONLY) + #expect(descriptor >= 0) + defer { + close(descriptor) + try? FileManager.default.removeItem(at: url) + } + + let paths = ProcessDetection.openFilePaths(pid: getpid()) + #expect(!paths.contains { URL(fileURLWithPath: $0).lastPathComponent == url.lastPathComponent }) + } + @Test func readsCurrentProcessOpenFilePaths() throws { let url = FileManager.default.temporaryDirectory .appending(path: "prowl-agent-session-\(UUID().uuidString).jsonl") -- 2.51.2 From 42f335b9400afadc27f077c3e532bf0da02a4e56 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 18:28:50 +0900 Subject: [PATCH 5/8] Close correctness gaps in scan caps and retention pacing Second adversarial review round found the two performance caps punching holes through the "never return a wrong session" invariant, plus two consistency gaps; all four are fixed with regression tests: - A directory enumeration that exceeds the 20k visit limit now voids the entire scan instead of feeding a partial candidate set into uniqueness checks: an unvisited file could hold the real session. - The fingerprint read budget is allocated per session (freshest two files each, at most twelve distinct sessions, refusing uniqueness beyond that) so one chatty multi-file session can no longer evict a competing session from the margin comparison. - resolve() reports whether the result is a fresh computation or a cache replay from backoff; sticky-session aging only counts fresh ambiguous resolutions, restoring the documented "two consecutive resolutions" semantics instead of decaying per UI tick. - Gemini candidates require a successful header read (new requiresHeaderSessionID profile flag, honored by both the storage scan and the open-descriptor path): a corrupt or partially written header drops the candidate rather than surfacing the un-resumable 8-hex filename prefix. - The unresolved backoff streak starts at zero so the first retry keeps the documented 1 s (narrow) / 8 s (wide) pacing. --- doc-onevcat/agent-session-detection.md | 20 +-- .../AgentDetection/PaneAgentState.swift | 10 +- ...WorktreeTerminalState+AgentDetection.swift | 31 +++-- .../AgentDetection/AgentSessionProfile.swift | 5 + .../AgentDetection/AgentSessionResolver.swift | 107 +++++++++++----- supacodeTests/AgentSessionProfileTests.swift | 114 ++++++++++++++++++ supacodeTests/AgentSessionResolverTests.swift | 23 +++- 7 files changed, 253 insertions(+), 57 deletions(-) diff --git a/doc-onevcat/agent-session-detection.md b/doc-onevcat/agent-session-detection.md index 5fad4594..92a6bd06 100644 --- a/doc-onevcat/agent-session-detection.md +++ b/doc-onevcat/agent-session-detection.md @@ -15,10 +15,12 @@ Resolution is anchored to the exact process selected by Active Agents. Evidence 2. **Pid-keyed artifacts** (`exact`, `process_log`): files that name the agent pid directly — Copilot's `logs/process--.log` (containing "Registering foreground session: ") and Qwen's `.runtime.json` sidecar (`{"pid": ..., "session_id": ...}`). -3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts (the 12 - most recently modified) are compared with the pane's live text. Only a unique match with sufficient score and - margin wins; the margin rule applies between *distinct sessions* — several files of one session (Kimi, Cline, - Copilot) reinforce it instead of competing. +3. **Transcript/screen correlation** (`high`, `transcript_match`): bounded tails of candidate transcripts are + compared with the pane's live text. The read budget is per session (freshest 2 files each, at most 12 distinct + sessions — beyond that uniqueness cannot be proven and no match is declared), so one chatty session can never + evict a competing one from the comparison. Only a unique match with sufficient score and margin wins; the margin + rule applies between *distinct sessions* — several files of one session (Kimi, Cline, Copilot) reinforce it + instead of competing. 4. **Sole process-lifetime candidate** (`medium`, `recent_file` / `store_record`): storage roots (or OpenCode's sqlite `session` table) are filtered to entries modified during the process lifetime; a single distinct session id wins. @@ -123,14 +125,18 @@ Implications: - Darwin inspection uses `proc_pidinfo` / `proc_pidfdinfo`; Prowl never shells out to `lsof`. - Results are cached per process lifetime. Unresolved lookups back off exponentially (1 s doubling to a 15 s cap; wide-root fallback scans start at 8 s), so a permanently ambiguous pane costs almost nothing. Directory enumeration - is additionally capped at 20 000 entries per scan (truncation logs a warning and degrades to "unresolved"). + is capped at 20 000 entries per scan; a truncated enumeration voids the whole scan (unresolved) because a partial + view could otherwise declare a false unique candidate. - Open-descriptor evidence only counts descriptors opened for WRITING: agents transiently open other sessions read-only (resume pickers, history browsing) and a read FD must not claim a session. - A sole process-lifetime candidate (`medium`) is only adopted after two consecutive resolutions agree on it, and never when another live process already claimed the same session id — this closes the startup race where a new pane in a shared directory briefly sees only its sibling's session file. -- A previously resolved session is retained through probe gaps, but at most two consecutive ambiguous resolutions on - the same process; after that it is dropped so a rotated-away id (`/clear`) cannot survive indefinitely. +- A previously resolved session is retained through probe gaps and cache replays, but at most two consecutive FRESH + ambiguous resolutions on the same process; after that it is dropped so a rotated-away id (`/clear`) cannot survive + indefinitely. Resolver backoff replays are not new evidence and never age the retained session. +- Gemini candidates additionally require a successful header read: the filename only carries an 8-hex prefix that + cannot be resumed, so a corrupt or partially written header drops the candidate instead of surfacing the prefix. - Header enrichment (replacing a path-derived id with a JSONL first-line field) is opt-in per profile and only Gemini uses it — its filenames carry a truncated id. Generic header sniffing is forbidden: event-stream layouts like Copilot's `events.jsonl` may expose unrelated ids at the top level. diff --git a/supacode/Domain/AgentDetection/PaneAgentState.swift b/supacode/Domain/AgentDetection/PaneAgentState.swift index 922a3329..faad62ec 100644 --- a/supacode/Domain/AgentDetection/PaneAgentState.swift +++ b/supacode/Domain/AgentDetection/PaneAgentState.swift @@ -33,19 +33,23 @@ struct PaneAgentState: Equatable, Sendable { self.lastChangedAt = lastChangedAt } - /// Sticky-session policy: a fresh resolution always wins; a probe gap + /// Sticky-session policy: a resolution always wins; a probe gap /// (`identifiedPID == nil`, presence hold) keeps the last session without - /// aging it; an ambiguous resolver result on the same process keeps it for + /// aging it; a FRESH ambiguous resolution on the same process keeps it for /// at most two misses so a rotated-away session id cannot survive - /// indefinitely. A different pid discards it immediately. + /// indefinitely. Cache replays during resolver backoff (`isFresh == false`) + /// are not new evidence and never age the session. A different pid discards + /// it immediately. static func retainedSession( resolved: AgentSession?, + isFresh: Bool, previous: PaneAgentState, identifiedPID: pid_t? ) -> (session: AgentSession?, missStreak: Int) { if let resolved { return (resolved, 0) } guard let identifiedPID else { return (previous.session, previous.sessionMissStreak) } guard identifiedPID == previous.agentProcessID else { return (nil, 0) } + guard isFresh else { return (previous.session, previous.sessionMissStreak) } let streak = previous.sessionMissStreak + 1 return (streak >= 3 ? nil : previous.session, streak) } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 75fa173f..9b8580be 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -122,16 +122,12 @@ extension WorktreeTerminalState { } let iconLookupToken = identified?.name ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) - let resolved = await resolveAgentSession( + let (session, sessionMissStreak) = await resolveRetainedSession( identified: identified, + previous: previous, workingDirectory: workingDirectory, activeText: activeText ) - let (session, sessionMissStreak) = PaneAgentState.retainedSession( - resolved: resolved, - previous: previous, - identifiedPID: identified?.process.pid - ) // Re-check after the suspension: the pane may have been closed and its // agent state cleaned up while the resolver was doing file inspection; // writing below would resurrect a ghost Active Agents entry. @@ -175,16 +171,25 @@ extension WorktreeTerminalState { return true } - private func resolveAgentSession( + private func resolveRetainedSession( identified: IdentifiedAgentProcess?, + previous: PaneAgentState, workingDirectory: URL?, activeText: String - ) async -> AgentSession? { - guard let identified else { return nil } - return await AgentSessionResolver.shared.resolve( - identified: identified, - workingDirectory: workingDirectory, - activeText: activeText + ) async -> (session: AgentSession?, missStreak: Int) { + var resolution = AgentSessionResolution(session: nil, isFresh: false) + if let identified { + resolution = await AgentSessionResolver.shared.resolve( + identified: identified, + workingDirectory: workingDirectory, + activeText: activeText + ) + } + return PaneAgentState.retainedSession( + resolved: resolution.session, + isFresh: resolution.isFresh, + previous: previous, + identifiedPID: identified?.process.pid ) } diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index a1502c19..e03666fc 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -16,6 +16,10 @@ nonisolated struct AgentSessionProfile: Sendable { /// already holds the full id — generic sniffing can grab an unrelated field /// from event-stream layouts like Copilot's events.jsonl. var headerSessionIDKeys: [String] = [] + /// When true, a candidate whose header lookup fails is DROPPED instead of + /// falling back to the path-derived id (Gemini: the filename only holds an + /// 8-hex prefix that cannot be resumed). + var requiresHeaderSessionID: Bool = false /// Storage roots scanned for session files modified during the process /// lifetime. Narrow these as much as the layout allows. var candidateRoots: @Sendable (_ home: URL, _ cwd: URL?, _ processStartedAt: Date, _ now: Date) -> [URL] = { @@ -108,6 +112,7 @@ nonisolated extension AgentSessionProfile { return AgentSession(id: id, transcriptPath: url, source: .recentFile) }, headerSessionIDKeys: ["sessionId"], + requiresHeaderSessionID: true, candidateRoots: { home, cwd, _, _ in guard let cwd else { return [home.appending(path: ".gemini/tmp")] } let tmp = home.appending(path: ".gemini/tmp") diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 19757451..c3d8465f 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -63,6 +63,13 @@ nonisolated extension [AgentSessionCandidate] { } } +/// Outcome of one resolver call: `isFresh` distinguishes a newly computed +/// resolution from a cache replay during backoff. +nonisolated struct AgentSessionResolution: Sendable { + let session: AgentSession? + let isFresh: Bool +} + /// Compatibility shim over the per-agent profiles; the actual rules live in /// `AgentSessionProfile`. nonisolated enum AgentSessionPathParser { @@ -129,9 +136,11 @@ actor AgentSessionResolver { workingDirectory: URL?, activeText: String, now: Date = Date() - ) -> AgentSession? { + ) -> AgentSessionResolution { let process = identified.process - guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { return nil } + guard let startedAt = ProcessDetection.processStartDate(pid: process.pid) else { + return AgentSessionResolution(session: nil, isFresh: true) + } let key = CacheKey(pid: process.pid, startedAt: startedAt) let cached = cache[key] if let cached { @@ -140,7 +149,11 @@ actor AgentSessionResolver { usedWideScan: cached.usedWideScan, unresolvedStreak: cached.unresolvedStreak ) - if now.timeIntervalSince(cached.resolvedAt) < lifetime { return cached.session } + if now.timeIntervalSince(cached.resolvedAt) < lifetime { + // Replayed cache hits are not new evidence; consumers must not age + // their sticky sessions on them. + return AgentSessionResolution(session: cached.session, isFresh: false) + } } let (resolved, usedWideScan) = resolveUncached( @@ -163,8 +176,10 @@ actor AgentSessionResolver { resolvedAt: now, session: session, usedWideScan: usedWideScan, - // A pending sole confirmation retries fast instead of backing off. - unresolvedStreak: session == nil && provisionalID == nil ? (cached?.unresolvedStreak ?? 0) + 1 : 0, + // A pending sole confirmation retries fast instead of backing off; the + // first unresolved result starts at streak 0 so the initial retry keeps + // the documented 1 s (narrow) / 8 s (wide) pacing. + unresolvedStreak: session == nil && provisionalID == nil ? cached.map { $0.unresolvedStreak + 1 } ?? 0 : 0, provisionalSoleID: provisionalID ) if cache.count > 128 { @@ -172,7 +187,7 @@ actor AgentSessionResolver { ProcessDetection.processStartDate(pid: entry.key.pid) == entry.key.startedAt } } - return session + return AgentSessionResolution(session: session, isFresh: true) } /// A session id already resolved for a different live process cannot also @@ -195,10 +210,12 @@ actor AgentSessionResolver { let openSessions = ProcessDetection.openFilePaths(pid: identified.process.pid) .compactMap { profile.parsePath($0) } - .map { session -> AgentSession in + .compactMap { session -> AgentSession? in guard let path = session.transcriptPath, let fullID = Self.sessionIDFromHeader(at: path, keys: profile.headerSessionIDKeys) - else { return session } + else { + return profile.requiresHeaderSessionID ? nil : session + } return AgentSession(id: fullID, transcriptPath: path, source: session.source) } if let session = uniqueSession(openSessions) { @@ -275,12 +292,13 @@ actor AgentSessionResolver { primaryRoots.append(root) } } - let primary = candidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) + let primary = scanCandidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) ?? [] let combined = primary + stored.uniquedBySessionID() guard combined.isEmpty else { return (combined, false) } let fallbackRoots = profile.fallbackRoots(homeDirectory, workingDirectory) guard !fallbackRoots.isEmpty else { return ([], false) } - return (candidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt), true) + let fallback = scanCandidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt) ?? [] + return (fallback, true) } /// The pane reports the shell's logical `$PWD` while agents usually record @@ -291,21 +309,47 @@ actor AgentSessionResolver { return resolved.path == cwd.path ? [cwd] : [cwd, resolved] } - private func candidates( + /// Scans `roots` for session files modified during the process lifetime. + /// Returns nil when any enumeration was truncated: an incomplete view could + /// declare a false unique candidate, and unresolved is the safe outcome. + func scanCandidates( in roots: [URL], profile: AgentSessionProfile, - processStartedAt: Date - ) -> [AgentSessionCandidate] { - roots.flatMap { root in - recentFiles(in: root, modifiedAfter: processStartedAt.addingTimeInterval(-2)).compactMap { item in - guard let session = profile.parsePath(item.url.path) else { return nil } - let enriched = - Self.sessionIDFromHeader(at: item.url, keys: profile.headerSessionIDKeys).map { - AgentSession(id: $0, transcriptPath: item.url, source: .recentFile) - } ?? session - return AgentSessionCandidate(session: enriched, modifiedAt: item.modifiedAt) + processStartedAt: Date, + visitLimit: Int = 20_000 + ) -> [AgentSessionCandidate]? { + var collected: [AgentSessionCandidate] = [] + for root in roots { + guard + let files = recentFiles( + in: root, + modifiedAfter: processStartedAt.addingTimeInterval(-2), + visitLimit: visitLimit + ) + else { return nil } + for item in files { + guard let candidate = enrichedCandidate(for: item, profile: profile) else { continue } + collected.append(candidate) } } + return collected + } + + private func enrichedCandidate( + for item: (url: URL, modifiedAt: Date), + profile: AgentSessionProfile + ) -> AgentSessionCandidate? { + guard let session = profile.parsePath(item.url.path) else { return nil } + if let fullID = Self.sessionIDFromHeader(at: item.url, keys: profile.headerSessionIDKeys) { + return AgentSessionCandidate( + session: AgentSession(id: fullID, transcriptPath: item.url, source: .recentFile), + modifiedAt: item.modifiedAt + ) + } + // A profile that depends on the header (Gemini's filenames only carry a + // truncated id) must not surface the unusable path-derived id. + guard !profile.requiresHeaderSessionID else { return nil } + return AgentSessionCandidate(session: session, modifiedAt: item.modifiedAt) } nonisolated static func sessionIDFromHeader(at url: URL, keys: [String]) -> String? { @@ -323,11 +367,13 @@ actor AgentSessionResolver { return nil } + /// Returns nil when the enumeration exceeded `visitLimit`: a partial view + /// must void the whole scan rather than feed uniqueness checks. private func recentFiles( in root: URL, modifiedAfter threshold: Date, - visitLimit: Int = 20_000 - ) -> [(url: URL, modifiedAt: Date)] { + visitLimit: Int + ) -> [(url: URL, modifiedAt: Date)]? { guard let enumerator = fileManager.enumerator( at: root, @@ -341,10 +387,8 @@ actor AgentSessionResolver { for case let url as URL in enumerator { visited += 1 if visited > visitLimit { - // A pathological tree; missing candidates degrades to "unresolved", - // never to a wrong id. agentSessionLogger.warning("Agent session scan truncated at \(visitLimit) entries under \(root.path)") - break + return nil } guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), values.isRegularFile == true, @@ -364,8 +408,15 @@ nonisolated enum AgentSessionFingerprintMatcher { ) -> AgentSessionCandidate? { let screen = normalize(activeText) guard screen.count >= 12 else { return nil } - // Cap tail reads: the freshest files carry the on-screen conversation. - let recent = candidates.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(12) + // Bound tail reads WITHOUT evicting whole sessions: cap files per session + // (extra files of one session only reinforce it), and refuse to declare + // uniqueness when there are more sessions than the read budget covers — + // an unexamined session could hold the same text. + let bySession = Dictionary(grouping: candidates) { $0.session.id } + guard bySession.count <= 12 else { return nil } + let recent = bySession.values.flatMap { group in + group.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(2) + } let scored = recent.compactMap { candidate -> (AgentSessionCandidate, Int)? in guard let path = candidate.session.transcriptPath, let data = tailData(at: path), diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 8c13452f..f1803431 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -133,6 +133,76 @@ struct AgentSessionProfileTests { #expect(match?.session.id == "same-session") } + @Test func fingerprintCapCannotEvictACompetingSession() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-cap-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let content = #"{"message":{"content":"Reply exactly READY and wait for further instructions."}}"# + + // Session A floods the recency window with 20 files; session B has one + // older file with the same on-screen text. B must still veto uniqueness. + var candidates: [AgentSessionCandidate] = [] + for index in 0..<20 { + let url = root.appending(path: "a-\(index).jsonl") + try content.write(to: url, atomically: true, encoding: .utf8) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: "session-a", transcriptPath: url, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 2_000 + TimeInterval(index)) + ) + ) + } + let bURL = root.appending(path: "b.jsonl") + try content.write(to: bURL, atomically: true, encoding: .utf8) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: "session-b", transcriptPath: bURL, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 1_000) + ) + ) + + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "Reply exactly READY and wait for further instructions.", + candidates: candidates + ) + #expect(match == nil) + } + + @Test func fingerprintRefusesUniquenessBeyondSessionBudget() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-many-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let unique = root.appending(path: "match.jsonl") + try #"{"message":{"content":"An unmistakably distinctive fingerprint phrase."}}"# + .write(to: unique, atomically: true, encoding: .utf8) + var candidates = [ + AgentSessionCandidate( + session: AgentSession(id: "target", transcriptPath: unique, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 5_000) + ) + ] + for index in 0..<13 { + let url = root.appending(path: "other-\(index).jsonl") + try #"{"message":{"content":"irrelevant"}}"#.write(to: url, atomically: true, encoding: .utf8) + candidates.append( + AgentSessionCandidate( + session: AgentSession(id: "other-\(index)", transcriptPath: url, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 4_000 - TimeInterval(index)) + ) + ) + } + + // 14 distinct sessions exceed the read budget; uniqueness cannot be + // proven, so no match may be declared. + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "An unmistakably distinctive fingerprint phrase.", + candidates: candidates + ) + #expect(match == nil) + } + // MARK: - Header enrichment stays per-profile @Test func headerEnrichmentNeverOverridesDirectoryDerivedIDs() throws { @@ -290,6 +360,50 @@ struct AgentSessionProfileTests { #expect(AgentSessionResolver.sessionIDFromHeader(at: huge, keys: ["sessionId"]) == nil) } + @Test func truncatedScansYieldNoCandidates() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-truncate-\(UUID().uuidString)/.claude/projects/-tmp-x", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for index in 0..<5 { + try "{}".write( + to: root.appending(path: "0000000\(index)-1111-2222-3333-444444444444.jsonl"), + atomically: true, + encoding: .utf8 + ) + } + let resolver = AgentSessionResolver() + let profile = AgentSessionProfile.profile(for: .claude) + let full = await resolver.scanCandidates(in: [root], profile: profile, processStartedAt: .distantPast) + #expect(full?.count == 5) + // A truncated enumeration cannot prove uniqueness; the whole scan is void. + let truncated = await resolver.scanCandidates( + in: [root], + profile: profile, + processStartedAt: .distantPast, + visitLimit: 3 + ) + #expect(truncated == nil) + } + + @Test func geminiCandidatesRequireSuccessfulHeaderEnrichment() async throws { + let chats = FileManager.default.temporaryDirectory + .appending(path: "prowl-gemini-req-\(UUID().uuidString)/.gemini/tmp/proj/chats", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: chats) } + try #"{"sessionId":"23ce3e98-af90-4d5c-8b83-ffcc258dff2b"}"# + .write(to: chats.appending(path: "session-2026-07-11T05-41-23ce3e98.jsonl"), atomically: true, encoding: .utf8) + try "not json at all" + .write(to: chats.appending(path: "session-2026-07-11T05-42-6827d721.jsonl"), atomically: true, encoding: .utf8) + + let resolver = AgentSessionResolver() + let profile = AgentSessionProfile.profile(for: .gemini) + let candidates = await resolver.scanCandidates(in: [chats], profile: profile, processStartedAt: .distantPast) + // The corrupt header must drop its file rather than surface a truncated + // 8-hex id that cannot be resumed. + #expect(candidates?.map(\.session.id) == ["23ce3e98-af90-4d5c-8b83-ffcc258dff2b"]) + } + // MARK: - Cache pacing @Test func unresolvedLookupsBackOffExponentially() { diff --git a/supacodeTests/AgentSessionResolverTests.swift b/supacodeTests/AgentSessionResolverTests.swift index 41818c33..4cfd69d9 100644 --- a/supacodeTests/AgentSessionResolverTests.swift +++ b/supacodeTests/AgentSessionResolverTests.swift @@ -97,24 +97,35 @@ struct AgentSessionResolverTests { var previous = PaneAgentState(agentProcessID: 42, session: session) previous.sessionMissStreak = 0 - // Same process, resolver ambiguous: retained for two misses, dropped on the third. - let miss1 = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 42) + // Same process, FRESH ambiguous resolution: retained for two misses, + // dropped on the third. + let miss1 = PaneAgentState.retainedSession(resolved: nil, isFresh: true, previous: previous, identifiedPID: 42) #expect(miss1.session?.id == "old") #expect(miss1.missStreak == 1) previous.sessionMissStreak = 2 - let miss3 = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 42) + let miss3 = PaneAgentState.retainedSession(resolved: nil, isFresh: true, previous: previous, identifiedPID: 42) #expect(miss3.session == nil) + // Cached nil replayed during resolver backoff must NOT age the session: + // only fresh resolutions count as misses. + previous.sessionMissStreak = 2 + let replay = PaneAgentState.retainedSession(resolved: nil, isFresh: false, previous: previous, identifiedPID: 42) + #expect(replay.session?.id == "old") + #expect(replay.missStreak == 2) + // Presence hold (probe returned no process): keep without aging. previous.sessionMissStreak = 2 - let held = PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: nil) + let held = PaneAgentState.retainedSession(resolved: nil, isFresh: false, previous: previous, identifiedPID: nil) #expect(held.session?.id == "old") #expect(held.missStreak == 2) // Fresh resolution resets the streak; new pid drops the session. - let fresh = PaneAgentState.retainedSession(resolved: session, previous: previous, identifiedPID: 42) + let fresh = PaneAgentState.retainedSession(resolved: session, isFresh: true, previous: previous, identifiedPID: 42) #expect(fresh.missStreak == 0) - #expect(PaneAgentState.retainedSession(resolved: nil, previous: previous, identifiedPID: 43).session == nil) + #expect( + PaneAgentState.retainedSession(resolved: nil, isFresh: true, previous: previous, identifiedPID: 43).session + == nil + ) } @Test func openFilePathsExcludeReadOnlyDescriptors() throws { -- 2.51.2 From 6eca254972882eaba670daae7a56a6c3aad2afdc Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 18:42:45 +0900 Subject: [PATCH 6/8] Stop fallback scans after truncation and fix backoff reset Third review round, two pacing consistency gaps: - A truncated primary scan now voids the whole resolution round instead of proceeding to the fallback root: the fallback tree is a superset of the primary and would only repeat the oversized enumeration (~40k entries per tick). The round reports as a wide scan so retries back off at the slow tier. The regression test uses a synthetic profile whose fallback is small and parsable, so wrongly continuing to the fallback surfaces a candidate and fails the assertion. - The unresolved backoff streak resets to zero when a previously resolved session turns ambiguous (e.g. right after /clear), keeping the first retry at the documented 1 s / 8 s pacing; only consecutive unresolved results escalate. Extracted as a pure function with tests. --- .../AgentDetection/AgentSessionResolver.swift | 50 +++++++++++--- supacodeTests/AgentSessionProfileTests.swift | 68 +++++++++++++++++++ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index c3d8465f..46a8a5be 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -105,6 +105,18 @@ actor AgentSessionResolver { return min(15, base * TimeInterval(1 << min(unresolvedStreak, 4))) } + /// resolved → ambiguous starts a new unresolved episode at streak 0 (the + /// first retry keeps the fast pacing, e.g. right after `/clear`); only + /// consecutive unresolved results escalate the backoff. + nonisolated static func nextUnresolvedStreak( + resolvedNow: Bool, + previousWasUnresolved: Bool, + previousStreak: Int + ) -> Int { + guard !resolvedNow, previousWasUnresolved else { return 0 } + return previousStreak + 1 + } + /// A sole process-lifetime candidate is only trusted after two consecutive /// resolutions agree on it. A pane that starts in a directory where another /// agent is actively writing can otherwise adopt that agent's session during @@ -176,10 +188,12 @@ actor AgentSessionResolver { resolvedAt: now, session: session, usedWideScan: usedWideScan, - // A pending sole confirmation retries fast instead of backing off; the - // first unresolved result starts at streak 0 so the initial retry keeps - // the documented 1 s (narrow) / 8 s (wide) pacing. - unresolvedStreak: session == nil && provisionalID == nil ? cached.map { $0.unresolvedStreak + 1 } ?? 0 : 0, + // A pending sole confirmation retries fast instead of backing off. + unresolvedStreak: Self.nextUnresolvedStreak( + resolvedNow: session != nil || provisionalID != nil, + previousWasUnresolved: cached.map { $0.session == nil && $0.provisionalSoleID == nil } ?? false, + previousStreak: cached?.unresolvedStreak ?? 0 + ), provisionalSoleID: provisionalID ) if cache.count > 128 { @@ -276,11 +290,12 @@ actor AgentSessionResolver { return unique.count == 1 ? unique.values.first : nil } - private func recentCandidates( + func recentCandidates( profile: AgentSessionProfile, processStartedAt: Date, workingDirectory: URL?, - now: Date + now: Date, + visitLimit: Int = 20_000 ) -> (candidates: [AgentSessionCandidate], usedWideScan: Bool) { let cwdVariants = workingDirectoryVariants(workingDirectory) let threshold = processStartedAt.addingTimeInterval(-2) @@ -292,13 +307,30 @@ actor AgentSessionResolver { primaryRoots.append(root) } } - let primary = scanCandidates(in: primaryRoots, profile: profile, processStartedAt: processStartedAt) ?? [] + guard + let primary = scanCandidates( + in: primaryRoots, + profile: profile, + processStartedAt: processStartedAt, + visitLimit: visitLimit + ) + else { + // A truncated primary scan voids this whole round: the fallback tree is + // a superset and would only repeat the oversized enumeration. Report it + // as a wide scan so the retry backs off at the slow tier. + return ([], true) + } let combined = primary + stored.uniquedBySessionID() guard combined.isEmpty else { return (combined, false) } let fallbackRoots = profile.fallbackRoots(homeDirectory, workingDirectory) guard !fallbackRoots.isEmpty else { return ([], false) } - let fallback = scanCandidates(in: fallbackRoots, profile: profile, processStartedAt: processStartedAt) ?? [] - return (fallback, true) + let fallback = scanCandidates( + in: fallbackRoots, + profile: profile, + processStartedAt: processStartedAt, + visitLimit: visitLimit + ) + return (fallback ?? [], true) } /// The pane reports the shell's logical `$PWD` while agents usually record diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index f1803431..0e467b6c 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -404,6 +404,74 @@ struct AgentSessionProfileTests { #expect(candidates?.map(\.session.id) == ["23ce3e98-af90-4d5c-8b83-ffcc258dff2b"]) } + @Test func truncatedPrimaryScanSkipsFallbackEntirely() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-trunc-fb-\(UUID().uuidString)", directoryHint: .isDirectory) + let primaryDir = root.appending(path: "primary") + let fallbackDir = root.appending(path: "fallback") + try FileManager.default.createDirectory(at: primaryDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: fallbackDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + // Primary trips the visit limit; the fallback holds ONE cleanly parsable + // session below the limit. If a truncated primary scan wrongly proceeded + // to the fallback, this candidate would surface — the assertion below + // would catch it. + for index in 0..<4 { + try "{}".write( + to: primaryDir.appending(path: "0000000\(index)-1111-2222-3333-444444444444.jsonl"), + atomically: true, + encoding: .utf8 + ) + } + try "{}".write( + to: fallbackDir.appending(path: "99999999-1111-2222-3333-444444444444.jsonl"), + atomically: true, + encoding: .utf8 + ) + let profile = AgentSessionProfile( + parsePath: { path in + let url = URL(fileURLWithPath: path) + guard url.pathExtension == "jsonl" else { return nil } + return AgentSession( + id: url.deletingPathExtension().lastPathComponent, + transcriptPath: url, + source: .recentFile + ) + }, + candidateRoots: { _, _, _, _ in [primaryDir] }, + fallbackRoots: { _, _ in [fallbackDir] } + ) + + let resolver = AgentSessionResolver() + let result = await resolver.recentCandidates( + profile: profile, + processStartedAt: .distantPast, + workingDirectory: nil, + now: .now, + visitLimit: 3 + ) + #expect(result.candidates.isEmpty) + #expect(result.usedWideScan) + } + + @Test func unresolvedStreakResetsWhenAResolvedSessionTurnsAmbiguous() { + // resolved → ambiguous starts a NEW unresolved episode at streak 0 so the + // first retry keeps the documented 1 s / 8 s pacing (e.g. right after + // /clear); only consecutive unresolved results escalate. + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: true, previousWasUnresolved: false, previousStreak: 5) == 0 + ) + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: false, previousWasUnresolved: false, previousStreak: 0) + == 0) + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: false, previousWasUnresolved: true, previousStreak: 0) == 1 + ) + #expect( + AgentSessionResolver.nextUnresolvedStreak(resolvedNow: false, previousWasUnresolved: true, previousStreak: 3) == 4 + ) + } + // MARK: - Cache pacing @Test func unresolvedLookupsBackOffExponentially() { -- 2.51.2 From 4afe3f3ad5a80b6a0c04b07257e0217ad275c4e7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 19:03:15 +0900 Subject: [PATCH 7/8] Block fingerprint uniqueness over unscoreable sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review round: candidates whose transcript tail yields no comparable text (unreadable data, oversized single-line JSON like Cline's whose tail is a truncated fragment, no supported fields) were silently dropped by compactMap before the margin comparison, so a competing session could win transcript_match/high while the real session never testified. Now every candidate session must produce at least one comparable fragment before uniqueness may be declared; a scoreable session that merely scores zero remains eliminable. Also decode tails lossily: the 128 KiB window can start mid-character in a multi-byte transcript (any long CJK conversation), and the failable conversion voided the entire tail instead of just the cut first line — the dominant source of unscoreable sessions in practice. --- .../AgentDetection/AgentSessionResolver.swift | 39 ++++++++----- supacodeTests/AgentSessionProfileTests.swift | 58 +++++++++++++++++++ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 46a8a5be..1eafa4c9 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -446,22 +446,31 @@ nonisolated enum AgentSessionFingerprintMatcher { // an unexamined session could hold the same text. let bySession = Dictionary(grouping: candidates) { $0.session.id } guard bySession.count <= 12 else { return nil } - let recent = bySession.values.flatMap { group in - group.sorted { $0.modifiedAt > $1.modifiedAt }.prefix(2) - } - let scored = recent.compactMap { candidate -> (AgentSessionCandidate, Int)? in - guard let path = candidate.session.transcriptPath, - let data = tailData(at: path), - let text = String(data: data, encoding: .utf8) - else { return nil } - let score = transcriptStrings(text).reduce(0) { best, fragment in - let normalized = normalize(fragment) - guard normalized.count >= 12 else { return best } - if screen.contains(normalized) { return max(best, min(200, normalized.count + 80)) } - let suffix = String(normalized.suffix(80)) - return suffix.count >= 24 && screen.contains(suffix) ? max(best, suffix.count) : best + var scored: [(AgentSessionCandidate, Int)] = [] + for group in bySession.values { + var sessionScoreable = false + for candidate in group.sorted(by: { $0.modifiedAt > $1.modifiedAt }).prefix(2) { + guard let path = candidate.session.transcriptPath, let data = tailData(at: path) else { continue } + // Lossy decoding is deliberate: the tail window can start mid-character + // in a multi-byte transcript, and a failable conversion would void the + // whole tail instead of just the cut first line. + // swiftlint:disable:next optional_data_string_conversion + let fragments = transcriptStrings(String(decoding: data, as: UTF8.self)) + if !fragments.isEmpty { sessionScoreable = true } + let score = fragments.reduce(0) { best, fragment in + let normalized = normalize(fragment) + guard normalized.count >= 12 else { return best } + if screen.contains(normalized) { return max(best, min(200, normalized.count + 80)) } + let suffix = String(normalized.suffix(80)) + return suffix.count >= 24 && screen.contains(suffix) ? max(best, suffix.count) : best + } + if score > 0 { scored.append((candidate, score)) } } - return score > 0 ? (candidate, score) : nil + // A session that yields no comparable text at all (unreadable tail, + // oversized single-line JSON, no supported fields) might still be the + // real one; uniqueness cannot be declared over its silence. A scoreable + // session that merely scores zero HAS testified — it stays eliminable. + guard sessionScoreable else { return nil } } // The margin rule guards against picking between *sessions* that look diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 0e467b6c..45a06cd5 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -203,6 +203,64 @@ struct AgentSessionProfileTests { #expect(match == nil) } + @Test func fingerprintRefusesWhenACompetitorIsUnscoreable() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-unscoreable-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + // Session A scores against the screen; session B's transcript yields no + // comparable text at all (Cline-style oversized single-line JSON whose + // tail is a truncated fragment). B might be the real session, so no + // high-confidence uniqueness may be declared. + let aURL = root.appending(path: "a.jsonl") + try #"{"message":{"content":"Shared prompt visible on both panes right now."}}"# + .write(to: aURL, atomically: true, encoding: .utf8) + let bURL = root.appending(path: "b.json") + try #""tail_fragment_of_a_huge_single_line_json":true}]}"# + .write(to: bURL, atomically: true, encoding: .utf8) + let candidates = [ + AgentSessionCandidate( + session: AgentSession(id: "session-a", transcriptPath: aURL, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 2_000) + ), + AgentSessionCandidate( + session: AgentSession(id: "session-b", transcriptPath: bURL, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 2_001) + ), + ] + + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "Shared prompt visible on both panes right now.", + candidates: candidates + ) + #expect(match == nil) + } + + @Test func fingerprintSurvivesTailCutInsideMultibyteCharacter() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-utf8-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + // A transcript larger than the 128 KiB tail window whose cut point lands + // inside a multi-byte character: lossy decoding must keep the intact + // trailing lines scoreable instead of voiding the whole tail. + let filler = #"{"message":{"content":"\#(String(repeating: "汉", count: 44_000))"}}"# + let match = #"{"message":{"content":"A perfectly distinctive closing message for this pane."}}"# + let url = root.appending(path: "big.jsonl") + try (filler + "\n" + match).write(to: url, atomically: true, encoding: .utf8) + + let result = AgentSessionFingerprintMatcher.bestMatch( + activeText: "A perfectly distinctive closing message for this pane.", + candidates: [ + AgentSessionCandidate( + session: AgentSession(id: "big", transcriptPath: url, source: .recentFile), + modifiedAt: .now + ) + ] + ) + #expect(result?.session.id == "big") + } + // MARK: - Header enrichment stays per-profile @Test func headerEnrichmentNeverOverridesDirectoryDerivedIDs() throws { -- 2.51.2 From 9d3e87af868c38bcf23899c5b4ecc772745fde9c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 11 Jul 2026 19:25:47 +0900 Subject: [PATCH 8/8] Align the scoreable threshold with the comparison floor Fifth review round: sessionScoreable flipped on any non-empty fragment list while scoring only considers fragments whose normalized length reaches 12 characters. A competing session whose transcript held only short fragments ("OK") therefore counted as having testified without ever entering the comparison, letting another session win high on shared text. Scoreable now requires at least one fragment above the comparison floor, using the same normalized values for scoring. --- .../AgentDetection/AgentSessionResolver.swift | 10 +++--- supacodeTests/AgentSessionProfileTests.swift | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift index 1eafa4c9..05795007 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionResolver.swift @@ -456,10 +456,12 @@ nonisolated enum AgentSessionFingerprintMatcher { // whole tail instead of just the cut first line. // swiftlint:disable:next optional_data_string_conversion let fragments = transcriptStrings(String(decoding: data, as: UTF8.self)) - if !fragments.isEmpty { sessionScoreable = true } - let score = fragments.reduce(0) { best, fragment in - let normalized = normalize(fragment) - guard normalized.count >= 12 else { return best } + // Scoreable means the session produced at least one fragment long + // enough to actually enter the comparison — fragments below the floor + // ("OK") are no testimony at all. + let comparable = fragments.map(normalize).filter { $0.count >= 12 } + if !comparable.isEmpty { sessionScoreable = true } + let score = comparable.reduce(0) { best, normalized in if screen.contains(normalized) { return max(best, min(200, normalized.count + 80)) } let suffix = String(normalized.suffix(80)) return suffix.count >= 24 && screen.contains(suffix) ? max(best, suffix.count) : best diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index 45a06cd5..29ee4a81 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -236,6 +236,37 @@ struct AgentSessionProfileTests { #expect(match == nil) } + @Test func fingerprintRefusesWhenACompetitorHasOnlyShortFragments() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "prowl-short-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + // Session B parses cleanly but only yields fragments below the 12-char + // comparison floor ("OK"): it never actually testified, so it must block + // uniqueness exactly like an unreadable session. + let aURL = root.appending(path: "a.jsonl") + try #"{"message":{"content":"Shared prompt visible on both panes right now."}}"# + .write(to: aURL, atomically: true, encoding: .utf8) + let bURL = root.appending(path: "b.jsonl") + try #"{"message":{"content":"OK"}}"#.write(to: bURL, atomically: true, encoding: .utf8) + let candidates = [ + AgentSessionCandidate( + session: AgentSession(id: "session-a", transcriptPath: aURL, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 2_000) + ), + AgentSessionCandidate( + session: AgentSession(id: "session-b", transcriptPath: bURL, source: .recentFile), + modifiedAt: Date(timeIntervalSince1970: 2_001) + ), + ] + + let match = AgentSessionFingerprintMatcher.bestMatch( + activeText: "Shared prompt visible on both panes right now.", + candidates: candidates + ) + #expect(match == nil) + } + @Test func fingerprintSurvivesTailCutInsideMultibyteCharacter() throws { let root = FileManager.default.temporaryDirectory .appending(path: "prowl-utf8-\(UUID().uuidString)", directoryHint: .isDirectory)