From 7acfc5152a62cf92e78a023f62c36761fe7ca688 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 10:39:12 -0300 Subject: [PATCH 1/3] test: relaunch and programa resume skip a Claude session with no transcript (INC-404) The transcript check and its tests; the resume paths do not use it yet, so the relaunch and CLI tests fail until the fix commit. --- Sources/AgentResumeCommand.swift | 63 +++++++++++ Sources/Workspace+Persistence.swift | 6 +- programaTests/SessionPersistenceTests.swift | 111 +++++++++++++++++++- tests/test_cli_agent_resume.py | 18 ++++ 4 files changed, 194 insertions(+), 4 deletions(-) diff --git a/Sources/AgentResumeCommand.swift b/Sources/AgentResumeCommand.swift index 9ab41799b..268044c06 100644 --- a/Sources/AgentResumeCommand.swift +++ b/Sources/AgentResumeCommand.swift @@ -56,6 +56,69 @@ enum AgentResumeCommand { return "cd \(shellQuoted(cwd)) && \(command)" } + /// Claude Code writes a session's transcript only after its first prompt, so a session quit + /// before one has nothing to resume and `claude --resume ` fails with "No conversation + /// found". Other providers are never reported missing. + static func claudeTranscriptIsMissing( + for target: AgentResumeTarget, + transcriptExists: (String) -> Bool = { claudeTranscriptExists(sessionId: $0) } + ) -> Bool { + (target.provider == "claude-code" || target.provider == "claude") && !transcriptExists(target.sessionId) + } + + /// `$CLAUDE_CONFIG_DIR/projects` when set, then `~/.claude/projects`. + static func claudeProjectsRoots( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: String = NSHomeDirectory() + ) -> [URL] { + var roots: [URL] = [] + if let configured = environment["CLAUDE_CONFIG_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !configured.isEmpty { + roots.append(URL(fileURLWithPath: NSString(string: configured).expandingTildeInPath, isDirectory: true) + .appendingPathComponent("projects", isDirectory: true)) + } + let defaultRoot = URL(fileURLWithPath: homeDirectory, isDirectory: true) + .appendingPathComponent(".claude/projects", isDirectory: true) + if !roots.contains(where: { $0.standardizedFileURL == defaultRoot.standardizedFileURL }) { + roots.append(defaultRoot) + } + return roots + } + + /// Whether Claude Code has a non-empty transcript for the session: `/.jsonl`, or + /// `//messages/.jsonl`, in any project under any root. Every project + /// directory is searched instead of deriving Claude's directory name from a cwd, because the + /// directory a pane reports can differ from the one Claude started in. + /// + /// True when no root is a readable directory: an app launched from Finder does not see a + /// `CLAUDE_CONFIG_DIR` set only in the shell, and an unreadable root is no evidence that the + /// transcript is missing. + static func claudeTranscriptExists(sessionId: String, projectsRoots: [URL] = claudeProjectsRoots()) -> Bool { + guard !sessionId.isEmpty, sessionId != ".", sessionId != "..", + !sessionId.contains("/"), !sessionId.contains("\\") else { return false } + var sawReadableRoot = false + for root in projectsRoots { + guard let projects = try? FileManager.default.contentsOfDirectory(atPath: root.path) else { continue } + sawReadableRoot = true + for project in projects { + let projectURL = root.appendingPathComponent(project, isDirectory: true) + let direct = projectURL.appendingPathComponent("\(sessionId).jsonl") + let nested = projectURL.appendingPathComponent(sessionId, isDirectory: true) + .appendingPathComponent("messages", isDirectory: true) + .appendingPathComponent("\(sessionId).jsonl") + if isNonEmptyRegularFile(atPath: direct.path) || isNonEmptyRegularFile(atPath: nested.path) { + return true + } + } + } + return !sawReadableRoot + } + + static func isNonEmptyRegularFile(atPath path: String) -> Bool { + var info = stat() + return stat(path, &info) == 0 && info.st_mode & S_IFMT == S_IFREG && info.st_size > 0 + } + static func isValidSessionId(_ value: String) -> Bool { guard !value.isEmpty, value.utf8.count <= 128 else { return false } return value.unicodeScalars.allSatisfy { scalar in diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index df3c32f9b..df252db0c 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -346,7 +346,8 @@ extension Workspace { } /// The text typed into a freshly spawned restored shell to resume the agent that was running - /// in it at quit, or nil when nothing should be resumed. + /// in it at quit, or nil when nothing should be resumed. A Claude session quit before its + /// first prompt has no transcript, so its pane reopens as a plain shell. nonisolated static func autoResumeInput( for snapshot: SessionPanelSnapshot, paneDirectory: String?, @@ -354,7 +355,8 @@ extension Workspace { directoryExists: (String) -> Bool = { path in var isDirectory: ObjCBool = false return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue - } + }, + claudeTranscriptExists: (String) -> Bool = { AgentResumeCommand.claudeTranscriptExists(sessionId: $0) } ) -> String? { guard isEnabled, snapshot.type == .terminal, diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 51160ae54..4e7d25cfc 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -805,7 +805,10 @@ final class SessionPersistenceTests: XCTestCase { func testAutoResumeInputTypesResumeLineForRunningClaudeSession() throws { let panel = try makeAutoResumePanel(wasRunning: true, sessionId: "5f0c2a9e-1b2d-4c3e-8f4a-6b7c8d9e0f1a") XCTAssertEqual( - Workspace.autoResumeInput(for: panel, paneDirectory: "/tmp/project", isEnabled: true, directoryExists: { _ in true }), + Workspace.autoResumeInput( + for: panel, paneDirectory: "/tmp/project", isEnabled: true, + directoryExists: { _ in true }, claudeTranscriptExists: { _ in true } + ), "claude --resume '5f0c2a9e-1b2d-4c3e-8f4a-6b7c8d9e0f1a'\n" ) } @@ -813,11 +816,102 @@ final class SessionPersistenceTests: XCTestCase { func testAutoResumeInputChangesDirectoryWhenPaneIsElsewhere() throws { let panel = try makeAutoResumePanel(wasRunning: true, cwd: "/tmp/project") XCTAssertEqual( - Workspace.autoResumeInput(for: panel, paneDirectory: "/tmp/other", isEnabled: true, directoryExists: { _ in true }), + Workspace.autoResumeInput( + for: panel, paneDirectory: "/tmp/other", isEnabled: true, + directoryExists: { _ in true }, claudeTranscriptExists: { _ in true } + ), "cd '/tmp/project' && claude --resume 'abc-123'\n" ) } + func testAutoResumeInputResumesClaudeSessionOnlyWhenItsTranscriptExists() throws { + let projects = try makeClaudeProjectsRoot() + let panel = try makeAutoResumePanel(wasRunning: true, sessionId: "abc-123") + let exists: (String) -> Bool = { AgentResumeCommand.claudeTranscriptExists(sessionId: $0, projectsRoots: [projects]) } + XCTAssertNil( + Workspace.autoResumeInput(for: panel, paneDirectory: "/tmp/project", isEnabled: true, claudeTranscriptExists: exists), + "a Claude session quit before its first prompt has no transcript and must reopen as a plain shell" + ) + + try writeTranscript("{}\n", to: projects.appendingPathComponent("-tmp-project/abc-123.jsonl")) + XCTAssertEqual( + Workspace.autoResumeInput(for: panel, paneDirectory: "/tmp/project", isEnabled: true, claudeTranscriptExists: exists), + "claude --resume 'abc-123'\n" + ) + } + + func testAutoResumeInputIgnoresClaudeTranscriptsForOtherProviders() throws { + let projects = try makeClaudeProjectsRoot() + let panel = try makeAutoResumePanel(wasRunning: true, provider: "codex", sessionId: "abc-123") + XCTAssertEqual( + Workspace.autoResumeInput( + for: panel, paneDirectory: "/tmp/project", isEnabled: true, + claudeTranscriptExists: { AgentResumeCommand.claudeTranscriptExists(sessionId: $0, projectsRoots: [projects]) } + ), + "codex resume 'abc-123'\n" + ) + } + + func testClaudeTranscriptExistsFindsTranscriptInAnyProjectDirectory() throws { + let projects = try makeClaudeProjectsRoot() + try FileManager.default.createDirectory( + at: projects.appendingPathComponent("-tmp-unrelated"), withIntermediateDirectories: true + ) + XCTAssertFalse(AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [projects])) + + try writeTranscript("{}\n", to: projects.appendingPathComponent("-Users-someone-elsewhere/abc-123.jsonl")) + XCTAssertTrue(AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [projects])) + XCTAssertFalse(AgentResumeCommand.claudeTranscriptExists(sessionId: "other-id", projectsRoots: [projects])) + } + + func testClaudeTranscriptExistsAcceptsNestedMessagesTranscript() throws { + let projects = try makeClaudeProjectsRoot() + try writeTranscript("{}\n", to: projects.appendingPathComponent("-tmp-project/abc-123/messages/abc-123.jsonl")) + XCTAssertTrue(AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [projects])) + } + + func testClaudeTranscriptExistsRejectsEmptyTranscriptAndDirectory() throws { + let projects = try makeClaudeProjectsRoot() + try writeTranscript("", to: projects.appendingPathComponent("-tmp-project/abc-123.jsonl")) + try FileManager.default.createDirectory( + at: projects.appendingPathComponent("-tmp-other/abc-123.jsonl"), withIntermediateDirectories: true + ) + XCTAssertFalse(AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [projects])) + } + + func testClaudeTranscriptExistsSearchesEveryRootAndAssumesPresentWhenNoneIsReadable() throws { + let configured = try makeClaudeProjectsRoot() + let fallback = try makeClaudeProjectsRoot() + let unreadable = configured.appendingPathComponent("does-not-exist", isDirectory: true) + try writeTranscript("{}\n", to: fallback.appendingPathComponent("-tmp-project/abc-123.jsonl")) + XCTAssertTrue(AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [configured, fallback])) + XCTAssertFalse(AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [configured, unreadable])) + XCTAssertTrue( + AgentResumeCommand.claudeTranscriptExists(sessionId: "abc-123", projectsRoots: [unreadable]), + "with no readable projects root there is no evidence the transcript is missing" + ) + XCTAssertFalse(AgentResumeCommand.claudeTranscriptExists(sessionId: "..", projectsRoots: [unreadable])) + } + + func testClaudeProjectsRootsPutsConfiguredDirectoryFirst() { + XCTAssertEqual( + AgentResumeCommand.claudeProjectsRoots(environment: [:], homeDirectory: "/Users/someone").map(\.path), + ["/Users/someone/.claude/projects"] + ) + XCTAssertEqual( + AgentResumeCommand.claudeProjectsRoots( + environment: ["CLAUDE_CONFIG_DIR": "/opt/claude-work"], homeDirectory: "/Users/someone" + ).map(\.path), + ["/opt/claude-work/projects", "/Users/someone/.claude/projects"] + ) + XCTAssertEqual( + AgentResumeCommand.claudeProjectsRoots( + environment: ["CLAUDE_CONFIG_DIR": "/Users/someone/.claude/"], homeDirectory: "/Users/someone" + ).map(\.path), + ["/Users/someone/.claude/projects"] + ) + } + func testAutoResumeInputIsNilForUnsupportedProvider() throws { let panel = try makeAutoResumePanel(wasRunning: true, provider: "unknown-agent") XCTAssertNil(Workspace.autoResumeInput(for: panel, paneDirectory: "/tmp/project", isEnabled: true)) @@ -839,6 +933,19 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertTrue(AgentAutoResumeSettings.isEnabled(defaults: defaults)) } + private func makeClaudeProjectsRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("programa-claude-projects-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return root + } + + private func writeTranscript(_ contents: String, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: url) + } + private func makeAutoResumePanel( wasRunning: Bool?, provider: String = "claude-code", diff --git a/tests/test_cli_agent_resume.py b/tests/test_cli_agent_resume.py index 94e43a42b..2e8ac3154 100644 --- a/tests/test_cli_agent_resume.py +++ b/tests/test_cli_agent_resume.py @@ -219,6 +219,24 @@ def test_missing_or_unsupported_session_fails_with_a_clear_error(self) -> None: self.assertIn(message, merged_output(proc)) self.assertEqual(sent(rec), []) + def test_claude_session_without_its_reported_transcript_is_not_resumed(self) -> None: + # Claude writes the transcript only after the first prompt; resuming before that fails + # with "No conversation found". + transcript = self.box.home / ".claude" / "projects" / "-project" / f"{SESSION_ID}.jsonl" + transcript.parent.mkdir(parents=True) + self.box.write_last_sessions(self.box.entry(transcriptPath=str(transcript))) + with self.recorder() as rec: + missing = self.box.cli(rec, ["resume"]) + self.assertNotEqual(missing.returncode, 0, merged_output(missing)) + self.assertIn("has no saved conversation to resume", merged_output(missing)) + self.assertEqual(sent(rec), []) + + transcript.write_text('{"type":"user"}\n', encoding="utf-8") + with self.recorder() as rec: + present = self.box.cli(rec, ["resume", "--print"]) + self.assertEqual(present.returncode, 0, merged_output(present)) + self.assertEqual(present.stdout.strip(), f"claude --resume '{SESSION_ID}'") + def test_explicit_session_uses_its_recorded_provider_or_the_provider_flag(self) -> None: self.box.write_last_sessions(self.box.entry(sessionId="codex-thread-1", provider="codex")) with self.recorder() as rec: From 00bd5ac28bdc97984a5f770d020cfd7fbd75e6e4 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 10:39:12 -0300 Subject: [PATCH 2/3] fix: skip resuming a Claude session that ended before its first prompt (INC-404) Claude writes no transcript until the first prompt, so claude --resume fails with "No conversation found". Relaunch now reopens that pane as a plain shell, the palette command beeps, and programa resume explains why. --- CLI/CLI+AgentResume.swift | 11 +++++++++++ Sources/CommandPaletteView+Commands.swift | 3 ++- Sources/Workspace+Persistence.swift | 4 +++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CLI/CLI+AgentResume.swift b/CLI/CLI+AgentResume.swift index cde0f7365..5cf1a743d 100644 --- a/CLI/CLI+AgentResume.swift +++ b/CLI/CLI+AgentResume.swift @@ -187,12 +187,14 @@ extension ProgramaCLI { let entries = AgentLastSessionStore().entries() let target: AgentResumeTarget + let transcriptPath: String? if let sessionArg { let recorded = entries.first { $0.sessionId == sessionArg } guard let provider = providerArg ?? recorded?.provider else { throw CLIError(message: "resume: session \(sessionArg) isn't recorded; pass --provider") } target = AgentResumeTarget(provider: provider, sessionId: sessionArg, cwd: recorded?.cwd) + transcriptPath = recorded?.transcriptPath } else { let paneKey = paneDirectory.map(AgentResumeCommand.standardizedPath) guard let entry = entries.first(where: { $0.surfaceId == surfaceId }) @@ -200,6 +202,7 @@ extension ProgramaCLI { throw CLIError(message: "resume: no ended agent session recorded for \(surfaceLabel) or its directory") } target = AgentResumeTarget(provider: entry.provider, sessionId: entry.sessionId, cwd: entry.cwd) + transcriptPath = entry.transcriptPath } guard AgentResumeCommand.isSupported(provider: target.provider) else { throw CLIError(message: "resume: \(target.provider) sessions cannot be resumed (supported: \(AgentResumeCommand.supportedProviders.joined(separator: ", ")))") @@ -207,6 +210,14 @@ extension ProgramaCLI { guard let line = AgentResumeCommand.line(for: target, paneDirectory: paneDirectory) else { throw CLIError(message: "resume: invalid session id: \(target.sessionId)") } + // Only the transcript path Claude reported is evidence: this process may not see the + // CLAUDE_CONFIG_DIR the session ran with. + if let transcriptPath = transcriptPath?.trimmingCharacters(in: .whitespacesAndNewlines), !transcriptPath.isEmpty, + AgentResumeCommand.claudeTranscriptIsMissing(for: target, transcriptExists: { _ in + AgentResumeCommand.isNonEmptyRegularFile(atPath: transcriptPath) + }) { + throw CLIError(message: "resume: Claude session \(target.sessionId) has no saved conversation to resume (it ended before its first prompt)") + } if printOnly { print(ctx.jsonOutput ? jsonString(["command": line, "provider": target.provider, "session_id": target.sessionId]) : line) diff --git a/Sources/CommandPaletteView+Commands.swift b/Sources/CommandPaletteView+Commands.swift index 7f894dae7..ecd3e15bb 100644 --- a/Sources/CommandPaletteView+Commands.swift +++ b/Sources/CommandPaletteView+Commands.swift @@ -712,7 +712,8 @@ extension CommandPaletteView { let target = AgentResumeStore.shared.target(panelId: context.panelId), let line = AgentResumeCommand.line( for: target, paneDirectory: context.workspace.panelDirectories[context.panelId] - ) else { + ), + !AgentResumeCommand.claudeTranscriptIsMissing(for: target) else { NSSound.beep() return } diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index df252db0c..58bf04309 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -364,7 +364,9 @@ extension Workspace { let target = snapshot.agentResume, let line = AgentResumeCommand.line( for: target, paneDirectory: paneDirectory, directoryExists: directoryExists - ) else { return nil } + ), + !AgentResumeCommand.claudeTranscriptIsMissing(for: target, transcriptExists: claudeTranscriptExists) + else { return nil } return line + "\n" } From 4bec8d43d3fe5b3b539efffbbcca517dfdddac00 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 10:49:45 -0300 Subject: [PATCH 3/3] fix: check the transcript path Claude's hooks report before skipping a resume --- CLI/CLI+AgentEventAdapters.swift | 14 +++++-- CLI/CLI+Hooks.swift | 6 +-- Sources/AgentResumeCommand.swift | 16 ++++++-- Sources/AgentResumeStore.swift | 12 ++++-- Sources/TabManager.swift | 4 +- .../TerminalController+SocketContext.swift | 2 +- Sources/TerminalController+Telemetry.swift | 7 +++- Sources/Workspace+SidebarTelemetry.swift | 4 +- contracts/v2/methods.json | 3 ++ docs/socket-api.md | 1 + programaTests/AgentActivityStateTests.swift | 16 ++++++++ programaTests/SessionPersistenceTests.swift | 38 +++++++++++++++++++ tests/test_cli_agent_resume.py | 13 +++++++ 13 files changed, 120 insertions(+), 16 deletions(-) diff --git a/CLI/CLI+AgentEventAdapters.swift b/CLI/CLI+AgentEventAdapters.swift index e48bd361f..b3cebdec3 100644 --- a/CLI/CLI+AgentEventAdapters.swift +++ b/CLI/CLI+AgentEventAdapters.swift @@ -14,7 +14,8 @@ extension ProgramaCLI { workspaceId: String, surfaceId: String, sessionId: String? = nil, - pid: Int? = nil + pid: Int? = nil, + transcriptPath: String? = nil ) { var params: [String: Any] = [ "provider": provider, @@ -28,6 +29,9 @@ extension ProgramaCLI { if let pid { params["pid"] = pid } + if let transcriptPath, !transcriptPath.isEmpty { + params["transcript_path"] = transcriptPath + } _ = try? client.sendV2(method: V2MethodNames.agentEvent, params: params) } @@ -43,7 +47,8 @@ extension ProgramaCLI { surfaceId: String, state: CLIAgentActivityState, sessionId: String? = nil, - pid: Int? = nil + pid: Int? = nil, + transcriptPath: String? = nil ) { switch eventType { case "turn.started", "item.started": emitProgramStatus(state: "working", app: provider) @@ -51,7 +56,10 @@ extension ProgramaCLI { default: break } reportAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId, state: state, provider: provider, sessionId: sessionId, pid: pid) - reportAgentEvent(client: client, provider: provider, eventType: eventType, workspaceId: workspaceId, surfaceId: surfaceId, sessionId: sessionId, pid: pid) + reportAgentEvent( + client: client, provider: provider, eventType: eventType, workspaceId: workspaceId, surfaceId: surfaceId, + sessionId: sessionId, pid: pid, transcriptPath: transcriptPath + ) } /// Sends `agent.needs_input` (docs/plans/agent-state-unification.md T2): one atomic diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 1953819c3..d3781fe83 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -578,7 +578,7 @@ extension ProgramaCLI { if let pid { setAgentPid(hook, ctx, workspaceId: workspaceId, sessionId: ctx.parsed.sessionId, pid: pid) } - reportAgentEvent(client: ctx.client, provider: hook.provider, eventType: "session.started", workspaceId: workspaceId, surfaceId: surfaceId, sessionId: ctx.parsed.sessionId) + reportAgentEvent(client: ctx.client, provider: hook.provider, eventType: "session.started", workspaceId: workspaceId, surfaceId: surfaceId, sessionId: ctx.parsed.sessionId, transcriptPath: ctx.parsed.transcriptPath) } private func agentHookPromptSubmit(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws { @@ -607,7 +607,7 @@ extension ProgramaCLI { let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: mapped?.surfaceId, fallback: ctx.surfaceArg, workspaceId: workspaceId, client: ctx.client ) - reportAgentStateAndEvent(client: ctx.client, provider: hook.provider, eventType: "turn.started", workspaceId: workspaceId, surfaceId: surfaceId, state: .working, sessionId: ctx.parsed.sessionId) + reportAgentStateAndEvent(client: ctx.client, provider: hook.provider, eventType: "turn.started", workspaceId: workspaceId, surfaceId: surfaceId, state: .working, sessionId: ctx.parsed.sessionId, transcriptPath: ctx.parsed.transcriptPath) } private func agentHookStop(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws { @@ -652,7 +652,7 @@ extension ProgramaCLI { client: ctx.client, key: hook.statusKey, workspaceId: workspaceId, value: "Idle", icon: "pause.circle.fill", color: "#8E8E93" ) - reportAgentStateAndEvent(client: ctx.client, provider: hook.provider, eventType: "turn.completed", workspaceId: workspaceId, surfaceId: surfaceId, state: .idle, sessionId: ctx.parsed.sessionId) + reportAgentStateAndEvent(client: ctx.client, provider: hook.provider, eventType: "turn.completed", workspaceId: workspaceId, surfaceId: surfaceId, state: .idle, sessionId: ctx.parsed.sessionId, transcriptPath: ctx.parsed.transcriptPath) } private func agentHookNotification(_ hook: AgentHookProvider, _ ctx: AgentHookContext) throws { diff --git a/Sources/AgentResumeCommand.swift b/Sources/AgentResumeCommand.swift index 268044c06..a44f61d20 100644 --- a/Sources/AgentResumeCommand.swift +++ b/Sources/AgentResumeCommand.swift @@ -1,12 +1,14 @@ import Foundation /// An agent session a pane can resume: the provider name as the agent hooks report it -/// (`claude-code`, `codex`, ...), the provider's session id, and the directory it ran in. -/// Persisted in session snapshots, so field names are a stored contract. +/// (`claude-code`, `codex`, ...), the provider's session id, the directory it ran in, and the +/// transcript path its hooks reported. Persisted in session snapshots, so field names are a +/// stored contract; a field added later must stay optional so older snapshots still decode. struct AgentResumeTarget: Codable, Equatable, Sendable { var provider: String var sessionId: String var cwd: String? + var transcriptPath: String? = nil } /// Builds the shell line that resumes an agent session. Compiled into both the app and the @@ -59,11 +61,19 @@ enum AgentResumeCommand { /// Claude Code writes a session's transcript only after its first prompt, so a session quit /// before one has nothing to resume and `claude --resume ` fails with "No conversation /// found". Other providers are never reported missing. + /// + /// The transcript path Claude's hooks reported is checked exactly, because Claude may have + /// run with a `CLAUDE_CONFIG_DIR` this process cannot see. Without one (a target saved + /// before hooks reported it), `transcriptExists` searches the projects roots. static func claudeTranscriptIsMissing( for target: AgentResumeTarget, transcriptExists: (String) -> Bool = { claudeTranscriptExists(sessionId: $0) } ) -> Bool { - (target.provider == "claude-code" || target.provider == "claude") && !transcriptExists(target.sessionId) + guard target.provider == "claude-code" || target.provider == "claude" else { return false } + if let path = target.transcriptPath?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty { + return !isNonEmptyRegularFile(atPath: path) + } + return !transcriptExists(target.sessionId) } /// `$CLAUDE_CONFIG_DIR/projects` when set, then `~/.claude/projects`. diff --git a/Sources/AgentResumeStore.swift b/Sources/AgentResumeStore.swift index ea42f2c1e..ba244999b 100644 --- a/Sources/AgentResumeStore.swift +++ b/Sources/AgentResumeStore.swift @@ -16,12 +16,18 @@ final class AgentResumeStore { /// Records the session a hook reported for the panel. A repeat report of the same session /// keeps the directory first seen: the agent's own working directory never reaches the - /// panel while it runs, and its provider resolves a session id from where it started. - func note(panelId: UUID, provider: String, sessionId: String, cwd: String?) { + /// panel while it runs, and its provider resolves a session id from where it started. A + /// transcript path is kept from whichever report first carries one. + func note(panelId: UUID, provider: String, sessionId: String, cwd: String?, transcriptPath: String? = nil) { if let existing = targets[panelId], existing.provider == provider, existing.sessionId == sessionId { + if existing.transcriptPath == nil, let transcriptPath { + targets[panelId]?.transcriptPath = transcriptPath + } return } - targets[panelId] = AgentResumeTarget(provider: provider, sessionId: sessionId, cwd: cwd) + targets[panelId] = AgentResumeTarget( + provider: provider, sessionId: sessionId, cwd: cwd, transcriptPath: transcriptPath + ) } func restore(_ target: AgentResumeTarget, panelId: UUID) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ce8c46f45..aa2f3efc7 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1621,11 +1621,13 @@ class TabManager: ObservableObject { state: AgentActivityState, source: AgentStateSource = .hooks, sessionKey: AgentSessionKey? = nil, + transcriptPath: String? = nil, programState: ProgramStatusState? = nil ) -> Bool { guard let tab = workspace(withId: tabId), tab.panels[surfaceId] != nil else { return false } tab.updatePanelAgentState( - panelId: surfaceId, state: state, source: source, sessionKey: sessionKey, programState: programState + panelId: surfaceId, state: state, source: source, sessionKey: sessionKey, + transcriptPath: transcriptPath, programState: programState ) return true } diff --git a/Sources/TerminalController+SocketContext.swift b/Sources/TerminalController+SocketContext.swift index f195148f5..68e2d8ac5 100644 --- a/Sources/TerminalController+SocketContext.swift +++ b/Sources/TerminalController+SocketContext.swift @@ -67,7 +67,7 @@ extension TerminalController { ] nonisolated static let v2AgentIdentityLimits: [(key: String, maxBytes: Int)] = [ "provider", "session_id", "turn_id", "item_id", "label", "resolution", - ].map { ($0, SidebarTelemetryLimits.maxKeyBytes) } + ].map { ($0, SidebarTelemetryLimits.maxKeyBytes) } + [("transcript_path", SidebarTelemetryLimits.maxDirectoryBytes)] nonisolated func v2TextLimitError(_ params: [String: Any], _ limits: [(key: String, maxBytes: Int)]) -> V2CallResult? { for limit in limits { diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index fd20e92fb..d61a67ac5 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -403,6 +403,8 @@ extension TerminalController { ) } let eventType = rawEventType.lowercased() + let rawTranscriptPath = v2RawString(params, "transcript_path")?.trimmingCharacters(in: .whitespacesAndNewlines) + let transcriptPath = rawTranscriptPath?.isEmpty == false ? rawTranscriptPath : nil let provider: String? let reportedPid: pid_t? @@ -425,7 +427,10 @@ extension TerminalController { switch outcome { case .applyState(let state): v2ScheduleSurfaceTelemetryMutation(workspaceId: workspaceId, surfaceId: surfaceId) { [weak self] tabManager, tab, sid in - tabManager.updateSurfaceAgentState(tabId: workspaceId, surfaceId: sid, state: state, source: .hooks, sessionKey: sessionKey) + tabManager.updateSurfaceAgentState( + tabId: workspaceId, surfaceId: sid, state: state, source: .hooks, sessionKey: sessionKey, + transcriptPath: transcriptPath + ) _ = try? AgentSupervisionRegistry.shared.updateActiveSurface( workspaceId: workspaceId, surfaceId: sid, diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index a0e24007b..57ceb3b4e 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -242,6 +242,7 @@ extension Workspace { state: AgentActivityState, source: AgentStateSource = .hooks, sessionKey: AgentSessionKey? = nil, + transcriptPath: String? = nil, inferredAgent: String? = nil, programState: ProgramStatusState? = nil, at now: Date = Date() @@ -252,7 +253,8 @@ extension Workspace { // session runs in the panel, and Programa's own hooks emit OSC 7501 ahead of this report. if source == .hooks, let sessionKey, let sessionId = sessionKey.sessionId { AgentResumeStore.shared.note( - panelId: panelId, provider: sessionKey.provider, sessionId: sessionId, cwd: panelDirectories[panelId] + panelId: panelId, provider: sessionKey.provider, sessionId: sessionId, cwd: panelDirectories[panelId], + transcriptPath: transcriptPath ) } diff --git a/contracts/v2/methods.json b/contracts/v2/methods.json index cdcef94d3..82ebc361a 100644 --- a/contracts/v2/methods.json +++ b/contracts/v2/methods.json @@ -118,6 +118,9 @@ "pid": { "type": "integer" }, + "transcript_path": { + "type": "string" + }, "turn_id": { "type": "string" }, diff --git a/docs/socket-api.md b/docs/socket-api.md index fc35e6619..83c427e89 100644 --- a/docs/socket-api.md +++ b/docs/socket-api.md @@ -881,6 +881,7 @@ tri-state model, not a new wire value. | `workspace_id` / `surface_id` | uuid | yes | Same resolution as other `surface.*` telemetry calls. | | `event_type` | string | yes | One of: `session.started`, `session.exited`, `turn.started`, `turn.completed`, `turn.aborted`, `request.opened`, `request.resolved`, `user-input.requested`, `user-input.resolved`, `item.started`, `item.completed`. Case/whitespace-insensitive. Unrecognized values return `invalid_params`. | | `provider` / `session_id` / `turn_id` / `item_id` / `label` / `resolution` | string | no | Passed through for future richer consumers (e.g. a turn-progress UI); not used for state classification in this pass. | +| `transcript_path` | string | no | The transcript path the provider's hook reported (Claude Code sends it). Kept with the pane's resumable session: a Claude session whose transcript file is missing or empty is not resumed on relaunch. | `event_type` maps to a tri-state write via `AgentEventNormalizer.classify` — see docs/plans/agent-events.md's mapping table for the full list, and its "Provider coverage" table diff --git a/programaTests/AgentActivityStateTests.swift b/programaTests/AgentActivityStateTests.swift index c2d56e529..6c836445d 100644 --- a/programaTests/AgentActivityStateTests.swift +++ b/programaTests/AgentActivityStateTests.swift @@ -593,6 +593,22 @@ final class AgentActivityStateTests: XCTestCase { ) } + func testHookTranscriptPathIsKeptOnResumeTargetFromFirstReportCarryingIt() { + let workspace = Workspace(title: "Test") + let panel = UUID() + defer { AgentResumeStore.shared.forget(panelId: panel) } + let key = AgentSessionKey(provider: "claude-code", sessionId: "session-1", pid: nil) + workspace.updatePanelAgentState(panelId: panel, state: .idle, source: .hooks, sessionKey: key) + XCTAssertNil(AgentResumeStore.shared.target(panelId: panel)?.transcriptPath) + workspace.updatePanelAgentState( + panelId: panel, state: .working, source: .hooks, sessionKey: key, transcriptPath: "/tmp/a/session-1.jsonl" + ) + workspace.updatePanelAgentState( + panelId: panel, state: .idle, source: .hooks, sessionKey: key, transcriptPath: "/tmp/b/session-1.jsonl" + ) + XCTAssertEqual(AgentResumeStore.shared.target(panelId: panel)?.transcriptPath, "/tmp/a/session-1.jsonl") + } + func testRestingProgramAcceptsOnlyActiveHookClaims() { let workspace = Workspace(title: "Test") let panel = UUID() diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 4e7d25cfc..53a170e1b 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -840,6 +840,44 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testAutoResumeInputUsesReportedTranscriptPathOverRootSearch() throws { + let projects = try makeClaudeProjectsRoot() + let transcript = projects.appendingPathComponent("custom-config/projects/-tmp-project/abc-123.jsonl") + try writeTranscript("{}\n", to: transcript) + var panel = try makeAutoResumePanel(wasRunning: true, sessionId: "abc-123") + panel.agentResume?.transcriptPath = transcript.path + XCTAssertEqual( + Workspace.autoResumeInput( + for: panel, paneDirectory: "/tmp/project", isEnabled: true, claudeTranscriptExists: { _ in false } + ), + "claude --resume 'abc-123'\n", + "a session run with a CLAUDE_CONFIG_DIR the app cannot see still resumes from its reported transcript" + ) + } + + func testAutoResumeInputIsNilWhenReportedTranscriptIsMissing() throws { + let projects = try makeClaudeProjectsRoot() + var panel = try makeAutoResumePanel(wasRunning: true, sessionId: "abc-123") + panel.agentResume?.transcriptPath = projects.appendingPathComponent("-tmp-project/abc-123.jsonl").path + XCTAssertNil( + Workspace.autoResumeInput( + for: panel, paneDirectory: "/tmp/project", isEnabled: true, claudeTranscriptExists: { _ in true } + ) + ) + } + + func testAgentResumeTargetDecodesWithoutTranscriptPath() throws { + let saved = #"{"provider":"claude-code","sessionId":"abc-123","cwd":"/tmp/project"}"# + let decoded = try JSONDecoder().decode(AgentResumeTarget.self, from: Data(saved.utf8)) + XCTAssertEqual(decoded, AgentResumeTarget(provider: "claude-code", sessionId: "abc-123", cwd: "/tmp/project")) + XCTAssertNil(decoded.transcriptPath) + + let withPath = AgentResumeTarget( + provider: "claude-code", sessionId: "abc-123", cwd: nil, transcriptPath: "/tmp/abc-123.jsonl" + ) + XCTAssertEqual(try JSONDecoder().decode(AgentResumeTarget.self, from: JSONEncoder().encode(withPath)), withPath) + } + func testAutoResumeInputIgnoresClaudeTranscriptsForOtherProviders() throws { let projects = try makeClaudeProjectsRoot() let panel = try makeAutoResumePanel(wasRunning: true, provider: "codex", sessionId: "abc-123") diff --git a/tests/test_cli_agent_resume.py b/tests/test_cli_agent_resume.py index 2e8ac3154..bcfe6b07c 100644 --- a/tests/test_cli_agent_resume.py +++ b/tests/test_cli_agent_resume.py @@ -128,6 +128,19 @@ def test_session_end_keeps_the_session_and_print_shows_the_resume_command(self) self.assertEqual(printed.stdout.strip(), f"claude --resume '{SESSION_ID}'") self.assertEqual(sent(rec), [], "--print must not type anything") + def test_session_start_reports_the_transcript_path_to_the_app(self) -> None: + # The app keeps it with the pane's resume target, so relaunch can tell whether Claude + # wrote the transcript even under a CLAUDE_CONFIG_DIR the app cannot see. + transcript = str(self.box.root / "custom-config" / "projects" / "-project" / f"{SESSION_ID}.jsonl") + payload = json.dumps({"session_id": SESSION_ID, "cwd": str(self.box.project), "transcript_path": transcript}) + with self.recorder() as rec: + started = self.box.cli(rec, ["claude-hook", "session-start"], input_text=payload) + self.assertEqual(started.returncode, 0, merged_output(started)) + events = [f["params"] for f in rec.frames if f.get("method") == "agent.event"] + self.assertEqual([e.get("event_type") for e in events], ["session.started"], rec.frames) + self.assertEqual(events[0].get("session_id"), SESSION_ID) + self.assertEqual(events[0].get("transcript_path"), transcript) + def test_resume_types_the_command_then_enter_into_the_pane(self) -> None: self.box.write_last_sessions(self.box.entry()) with self.recorder() as rec: