diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index 21b719a2..b87352cf 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -57,6 +57,12 @@ Rows appear in the order agents are first detected. (See switching to it. - **Copy Path** / **Reveal in Finder** — the agent's working directory (or its owning worktree's directory when the agent hasn't reported one). + - **Copy Session Path** / **Reveal Session in Finder** — the on-disk session + log of the agent's native session (e.g. Claude Code's + `~/.claude/projects//.jsonl`). Shown only when + Prowl has resolved the session to a file (see + [agent-detection](agent-detection.md)); agents with server-side or + store-only sessions (e.g. Amp threads) don't offer it. - **Keyboard navigation:** `⌥⌃↓` next agent, `⌥⌃↑` previous agent (wraps). - **Resize** the panel by dragging its top edge (height is remembered). - **Auto-show:** if `autoShowActiveAgentsPanel` is on and the panel is hidden, a diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 56df370f..7755b9f8 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -146,6 +146,21 @@ struct ActiveAgentsPanel: View { } .help("Reveal the agent's working directory in Finder") } + if let transcriptPath = entry.session?.transcriptPath { + Divider() + Button("Copy Session Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(transcriptPath.path, forType: .string) + } + .help("Copy the on-disk path of this agent's session log") + Button("Reveal Session in Finder") { + NSWorkspace.shared.selectFile( + transcriptPath.path, + inFileViewerRootedAtPath: transcriptPath.deletingLastPathComponent().path + ) + } + .help("Select this agent's session log in Finder") + } } private func repositoryName(for entry: ActiveAgentEntry) -> String { -- 2.51.2 From b7eda9e99a1514aa835e1a04b950a237d0c479d5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 27 Jul 2026 23:28:00 +0900 Subject: [PATCH 2/2] Fix Grok session log paths --- .../AgentDetection/AgentSessionProfile.swift | 19 ++++++++++++------- supacodeTests/AgentSessionProfileTests.swift | 16 ++++++++++------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift index 03b0a5aa..1bb1814d 100644 --- a/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift +++ b/supacode/Infrastructure/AgentDetection/AgentSessionProfile.swift @@ -294,18 +294,23 @@ nonisolated extension AgentSessionProfile { guard uuid(in: id) != nil else { return nil } // Walk up from the open path to the session root // (…/sessions///). Nested paths like `terminal/.log` - // still resolve. The transcript is always canonicalized to - // chat_history.jsonl (the conversation log): the process also holds - // events.jsonl open, which only logs MCP/infrastructure events. + // still resolve. var dir = url.deletingLastPathComponent() while dir.lastPathComponent != id, dir.pathComponents.count > grokIndex + 3 { dir = dir.deletingLastPathComponent() } guard dir.lastPathComponent == id else { return nil } - let transcript: URL = - url.lastPathComponent == "chat_history.jsonl" - ? url - : dir.appending(path: "chat_history.jsonl") + // Prefer the conversation transcript. If it has not been created yet, + // retain the opened events log rather than claiming a nonexistent path. + let chatHistory = dir.appending(path: "chat_history.jsonl") + let transcript: URL? + if url.lastPathComponent == "chat_history.jsonl" || FileManager.default.fileExists(atPath: chatHistory.path) { + transcript = chatHistory + } else if url.lastPathComponent == "events.jsonl" { + transcript = url + } else { + transcript = nil + } return AgentSession(id: id, transcriptPath: transcript, source: .recentFile) }, candidateRoots: { home, cwd, _, _ in diff --git a/supacodeTests/AgentSessionProfileTests.swift b/supacodeTests/AgentSessionProfileTests.swift index c9f14051..75dde87c 100644 --- a/supacodeTests/AgentSessionProfileTests.swift +++ b/supacodeTests/AgentSessionProfileTests.swift @@ -556,7 +556,7 @@ struct AgentSessionProfileTests { @Test func grokParsePathResolvesSessionDirectoryFromNestedFiles() { let profile = AgentSessionProfile.profile(for: .grok) let id = "019f5e7e-4269-7e33-9eaf-d535ff8ebafb" - // Create a temp tree so transcript resolution can succeed for events. + // The parser must never claim a transcript path that does not exist. let root = FileManager.default.temporaryDirectory .appending(path: "prowl-grok-parse-\(UUID().uuidString)", directoryHint: .isDirectory) let sessionDir = root.appending(path: ".grok/sessions/%2FUsers%2Fme%2FApp/\(id)") @@ -566,8 +566,8 @@ struct AgentSessionProfileTests { let parsedEvents = profile.parsePath(sessionDir.appending(path: "events.jsonl").path) #expect(parsedEvents?.id == id) - // Every non-chat open file canonicalizes to the conversation log. - #expect(parsedEvents?.transcriptPath?.lastPathComponent == "chat_history.jsonl") + // Before chat history exists, the opened events log is the only known log. + #expect(parsedEvents?.transcriptPath?.lastPathComponent == "events.jsonl") try? FileManager.default.createDirectory( at: sessionDir.appending(path: "terminal"), @@ -577,9 +577,13 @@ struct AgentSessionProfileTests { FileManager.default.createFile(atPath: nestedPath, contents: Data()) let parsedNested = profile.parsePath(nestedPath) #expect(parsedNested?.id == id) - // Non-transcript files resolve to the conversation log at the root. - #expect(parsedNested?.transcriptPath?.lastPathComponent == "chat_history.jsonl") - #expect(parsedNested?.transcriptPath?.deletingLastPathComponent().lastPathComponent == id) + // An arbitrary nested file identifies the session but is not itself a session log. + #expect(parsedNested?.transcriptPath == nil) + + let chatHistory = sessionDir.appending(path: "chat_history.jsonl") + try? "{}".write(to: chatHistory, atomically: true, encoding: .utf8) + let parsedAfterChat = profile.parsePath(nestedPath) + #expect(parsedAfterChat?.transcriptPath?.path == chatHistory.path) #expect(profile.parsePath("/Users/me/.grok/sessions/%2FUsers%2Fme%2FApp/not-a-uuid/events.jsonl") == nil) }