Skip to content
Closed
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
20 changes: 20 additions & 0 deletions Sources/SessionIndexStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,26 @@ final class SessionIndexStore: ObservableObject {
}
}

// MARK: - Agent resume command lookup

/// Find the most recent session entries for a specific agent and cwd.
/// Runs the same loaders used by the sidebar scan but scoped to a single
/// agent + cwd, so it's cheap. Called from Workspace when an agent PID is
/// registered to cache the resume command off the autosave hot path.
nonisolated static func latestEntries(
agent: SessionAgent, cwd: String, limit: Int = 1
) async -> [SessionEntry] {
let bag = ErrorBag()
switch agent {
case .claude:
return await loadClaudeEntries(needle: "", cwdFilter: cwd, offset: 0, limit: limit)
case .codex:
return await loadCodexEntries(needle: "", cwdFilter: cwd, offset: 0, limit: limit, errorBag: bag)
case .opencode:
return loadOpenCodeEntries(needle: "", cwdFilter: cwd, offset: 0, limit: limit, errorBag: bag)
Comment thread
yourconscience marked this conversation as resolved.
}
}
Comment thread
yourconscience marked this conversation as resolved.

// MARK: - Directory snapshot cache

private var directorySnapshotCache: [String: DirectorySnapshot] = [:]
Expand Down
4 changes: 4 additions & 0 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ struct SessionGitBranchSnapshot: Codable, Sendable {
struct SessionTerminalPanelSnapshot: Codable, Sendable {
var workingDirectory: String?
var scrollback: String?
/// Shell command to resume the agent session that was running in this terminal
/// (e.g. `claude --resume <id>`). Populated at snapshot time by matching agent
/// PIDs to terminal TTYs, then looking up the session entry on disk.
var agentResumeCommand: String?
Comment thread
yourconscience marked this conversation as resolved.
}

struct SessionBrowserPanelSnapshot: Codable, Sendable {
Expand Down
3 changes: 3 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14692,6 +14692,7 @@ class TerminalController {
if let pidValue {
tab.agentPIDs[key] = pidValue
controller.refreshTrackedAgentPorts(for: tab)
tab.resolveAndCacheResumeCommand(agentKey: key, pid: pidValue)
}
return
}
Expand All @@ -14708,6 +14709,7 @@ class TerminalController {
if let pidValue {
tab.agentPIDs[key] = pidValue
controller.refreshTrackedAgentPorts(for: tab)
tab.resolveAndCacheResumeCommand(agentKey: key, pid: pidValue)
}
}
return "OK"
Expand Down Expand Up @@ -14749,6 +14751,7 @@ class TerminalController {
scheduleSidebarMutation(target: target) { controller, tab in
tab.agentPIDs[key] = pid
controller.refreshTrackedAgentPorts(for: tab)
tab.resolveAndCacheResumeCommand(agentKey: key, pid: pid)
}
return "OK"
}
Expand Down
63 changes: 62 additions & 1 deletion Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ extension Workspace {
statusEntries.removeAll()
agentPIDs.removeAll()
agentListeningPorts.removeAll()
cachedAgentResumeCommands.removeAll()
logEntries = snapshot.logEntries.map { entry in
SidebarLogEntry(
message: entry.message,
Expand Down Expand Up @@ -468,9 +469,11 @@ extension Workspace {
includeScrollback: includeScrollback,
allowFallbackScrollback: shouldPersistScrollback
)
let isRemoteBacked = remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId)
terminalSnapshot = SessionTerminalPanelSnapshot(
workingDirectory: panelDirectories[panelId],
scrollback: resolvedScrollback
scrollback: resolvedScrollback,
agentResumeCommand: isRemoteBacked ? nil : agentResumeCommand(forPanelId: panelId)
)
browserSnapshot = nil
markdownSnapshot = nil
Expand Down Expand Up @@ -545,6 +548,58 @@ extension Workspace {
return resolved
}

// MARK: - Agent resume command cache

/// Returns the cached resume command for a panel, if one was resolved.
private func agentResumeCommand(forPanelId panelId: UUID) -> String? {
cachedAgentResumeCommands[panelId]
}

/// Map agent status keys to session agent types.
static func sessionAgentForKey(_ key: String) -> SessionAgent? {
switch key {
case "claude_code": return .claude
case "codex": return .codex
case "opencode": return .opencode
default: return nil
}
}

/// Get the controlling TTY for a PID via sysctl.
nonisolated static func ttyForPID(_ pid: pid_t) -> String? {
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.size
guard sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) == 0 else { return nil }
let devNum = info.kp_eproc.e_tdev
guard devNum != 0, devNum != UInt32.max else { return nil }
return "ttys\(String(format: "%03d", devNum & 0xFFFFFF))"
}

/// Resolve the resume command for an agent PID and cache it for the matching panel.
/// Called from TerminalController when agent PIDs are registered. Runs the
/// SessionIndexStore lookup off-main, then stores the result on the main actor.
func resolveAndCacheResumeCommand(agentKey: String, pid: pid_t) {
guard !isRemoteWorkspace else { return }
guard let agent = Self.sessionAgentForKey(agentKey) else { return }
guard pid > 0 else { return }

// Match PID to panel via TTY
guard let pidTTY = Self.ttyForPID(pid) else { return }
let matchedPanelId: UUID? = surfaceTTYNames.first(where: { $0.value == pidTTY })?.key
guard let panelId = matchedPanelId else { return }
let panelCwd = panelDirectories[panelId] ?? currentDirectory
Comment thread
yourconscience marked this conversation as resolved.
guard !panelCwd.isEmpty else { return }

Task {
let entries = await SessionIndexStore.latestEntries(agent: agent, cwd: panelCwd, limit: 1)
guard let entry = entries.first else { return }
await MainActor.run {
self.cachedAgentResumeCommands[panelId] = entry.resumeCommand
Comment thread
yourconscience marked this conversation as resolved.
}
}
}
Comment thread
yourconscience marked this conversation as resolved.

private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] {
guard let rootPaneId = bonsplitController.allPaneIds.first else {
return []
Expand Down Expand Up @@ -645,10 +700,12 @@ extension Workspace {
let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment(
for: snapshot.terminal?.scrollback
)
let resumeInput = snapshot.terminal?.agentResumeCommand.map { $0 + "\n" }
Comment thread
yourconscience marked this conversation as resolved.
guard let terminalPanel = newTerminalSurface(
inPane: paneId,
focus: false,
workingDirectory: workingDirectory,
initialInput: resumeInput,
startupEnvironment: replayEnvironment
) else {
return nil
Expand Down Expand Up @@ -6619,6 +6676,7 @@ final class Workspace: Identifiable, ObservableObject {
/// PIDs associated with agent status entries (e.g. claude_code), keyed by status key.
/// Used for stale-session detection: if the PID is dead, the status entry is cleared.
var agentPIDs: [String: pid_t] = [:]
var cachedAgentResumeCommands: [UUID: String] = [:]
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]

private func sidebarObservationSignal<Value: Equatable>(
Expand Down Expand Up @@ -7711,6 +7769,7 @@ final class Workspace: Identifiable, ObservableObject {
statusEntries.removeAll()
agentPIDs.removeAll()
agentListeningPorts.removeAll()
cachedAgentResumeCommands.removeAll()
logEntries.removeAll()
progress = nil
gitBranch = nil
Expand Down Expand Up @@ -11910,6 +11969,7 @@ extension Workspace: BonsplitDelegate {
surfaceTTYNames.removeValue(forKey: panelId)
syncRemotePortScanTTYs()
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
cachedAgentResumeCommands.removeValue(forKey: panelId)
Comment thread
yourconscience marked this conversation as resolved.
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
if lastTerminalConfigInheritancePanelId == panelId {
Expand Down Expand Up @@ -12063,6 +12123,7 @@ extension Workspace: BonsplitDelegate {
surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
cachedAgentResumeCommands.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
}

Expand Down