Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CLI/CLI+AgentEventAdapters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}

Expand All @@ -43,15 +47,19 @@ 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)
case "turn.completed": emitProgramStatus(state: "done", app: provider)
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
Expand Down
11 changes: 11 additions & 0 deletions CLI/CLI+AgentResume.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,26 +187,37 @@ 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 })
?? entries.first(where: { $0.cwd.map(AgentResumeCommand.standardizedPath) == paneKey && paneKey != nil }) else {
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: ", ")))")
}
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)
Expand Down
6 changes: 3 additions & 3 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
77 changes: 75 additions & 2 deletions Sources/AgentResumeCommand.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -56,6 +58,77 @@ 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 <id>` 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 {
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`.
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: `<project>/<id>.jsonl`, or
/// `<project>/<id>/messages/<id>.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
Expand Down
12 changes: 9 additions & 3 deletions Sources/AgentResumeStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion Sources/CommandPaletteView+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/TerminalController+SocketContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion Sources/TerminalController+Telemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions Sources/Workspace+Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,23 +346,27 @@ 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?,
isEnabled: Bool,
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,
snapshot.agentWasRunning == true,
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"
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/Workspace+SidebarTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
)
}

Expand Down
3 changes: 3 additions & 0 deletions contracts/v2/methods.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@
"pid": {
"type": "integer"
},
"transcript_path": {
"type": "string"
},
"turn_id": {
"type": "string"
},
Expand Down
1 change: 1 addition & 0 deletions docs/socket-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading