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
6 changes: 3 additions & 3 deletions CLI/CLI+AgentResume.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ private struct AgentLastSessionsFile: Codable, Equatable {
}

/// `~/.programa/agent-last-sessions.json`: newest first, at most one entry per surface and
/// per cwd, entries older than 7 days dropped. Shared by every agent provider's hook.
/// provider, entries older than 7 days dropped. Shared by every agent provider's hook.
final class AgentLastSessionStore {
private static let defaultPath = "~/.programa/agent-last-sessions.json"
private static let maxAgeSeconds: TimeInterval = 60 * 60 * 24 * 7
Expand Down Expand Up @@ -50,10 +50,10 @@ final class AgentLastSessionStore {
defer { _ = flock(fd, LOCK_UN) }

// An unreadable file is rebuilt: it only caches ids for `programa resume`.
// Not keyed by cwd: two panes in one directory must keep each other's sessions.
let others = (load()?.sessions ?? []).filter { existing in
existing.sessionId != entry.sessionId
&& (entry.surfaceId == nil || existing.surfaceId != entry.surfaceId)
&& (entry.cwd == nil || existing.cwd != entry.cwd)
&& (entry.surfaceId == nil || existing.surfaceId != entry.surfaceId || existing.provider != entry.provider)
}
let file = AgentLastSessionsFile(sessions: Array(pruned([entry] + others).prefix(Self.maxEntries)))
let encoder = JSONEncoder()
Expand Down
8 changes: 4 additions & 4 deletions CLI/CLI+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ final class ClaudeHookSessionStore {
let normalizedWorkspace = normalizeOptional(workspaceId)
let normalizedSurface = normalizeOptional(surfaceId)
return try withLockedState { state in
if let normalizedSessionId,
let removed = state.sessions.removeValue(forKey: normalizedSessionId) {
return removed
// An unrecorded id must not fall back: the surface's newest record may be a newer
// live session (Claude /clear ends the old id after the new one started).
if let normalizedSessionId {
return state.sessions.removeValue(forKey: normalizedSessionId)
}

guard let fallback = fallbackRecord(
sessions: Array(state.sessions.values),
workspaceId: normalizedWorkspace,
Expand Down
7 changes: 7 additions & 0 deletions Sources/AgentInterruptedBanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,18 @@ extension Workspace {
return line
}

/// Whether a resume line may be typed into the panel: no agent runs there and shell
/// integration reports the shell at its prompt, so the line cannot land in vim or ssh.
func panelCanTypeAgentResume(panelId: UUID) -> Bool {
panelAgentPresence[panelId] == nil && panelShellActivityStates[panelId] == .promptIdle
}

func handleInterruptedAgentAction(_ action: AgentInterruptedBannerAction, panelId: UUID) {
guard let state = interruptedAgentPanels[panelId] else { return }
switch (action, state) {
case (.resume, .shellAlive):
guard let terminalPanel = terminalPanel(for: panelId),
panelCanTypeAgentResume(panelId: panelId),
let line = agentResumeLine(panelId: panelId) else {
NSSound.beep()
return
Expand Down
3 changes: 1 addition & 2 deletions Sources/CommandPaletteView+Commands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -707,8 +707,7 @@ extension CommandPaletteView {
// Re-checked here: the agent may have started since the palette opened.
guard let context = Self.focusedPanelContext(in: tabManager),
let terminalPanel = context.panel as? TerminalPanel,
context.workspace.panelAgentPresence[context.panelId] == nil,
context.workspace.panelShellActivityStates[context.panelId] == .promptIdle,
context.workspace.panelCanTypeAgentResume(panelId: context.panelId),
let line = context.workspace.agentResumeLine(panelId: context.panelId) else {
NSSound.beep()
return
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The `claude-hook` and `codex-hook` commands keep one record per live session in
`session_id`, with `workspaceId`, `surfaceId`, `cwd` and `transcriptPath`. Records expire 7 days
after their last update. `session-end` removes the live record and adds the session to
`~/.programa/agent-last-sessions.json`, which keeps at most one ended session per pane and per
directory, 50 entries, for 7 days. Ended sessions that a newer one replaced are no longer known.
agent, 50 entries, for 7 days. Ended sessions that a newer one replaced are no longer known.
OpenCode hooks pass no transcript, so OpenCode sessions have no token counts.

## `programa usage`
Expand Down
2 changes: 1 addition & 1 deletion docs/workspace-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ and the panel says why.
## Resume an agent session

When an agent exits in a pane, its session-end hook keeps the session id in
`~/.programa/agent-last-sessions.json`, one entry per pane and per directory, for 7 days.
`~/.programa/agent-last-sessions.json`, one entry per pane and per agent, for 7 days.
`programa resume` types that agent's resume command into the pane's shell and presses Enter:

```sh
Expand Down
43 changes: 43 additions & 0 deletions tests/test_cli_agent_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,49 @@ def test_opencode_session_end_without_an_id_keeps_the_pane_session(self) -> None
self.assertEqual(printed.stdout.strip(), f"opencode --session '{OPENCODE_SESSION_ID}'")
self.assertEqual(sent(rec), [], "--print must not type anything")

def write_hook_sessions(self, *records: tuple[str, str]) -> None:
now = time.time()
self.box.hook_state.write_text(json.dumps({"version": 1, "sessions": {session_id: {
"sessionId": session_id, "workspaceId": WORKSPACE_ID, "surfaceId": surface_id,
"cwd": str(self.box.project), "startedAt": now, "updatedAt": now,
} for session_id, surface_id in records}}), encoding="utf-8")

def test_panes_in_the_same_directory_keep_each_others_ended_sessions(self) -> None:
# Pane A's session ends, then pane B's in the same directory; A must still resume its own.
other_session = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
self.write_hook_sessions((SESSION_ID, SURFACE_ID), (other_session, OTHER_SURFACE_ID))
with self.recorder() as rec:
for session_id in (SESSION_ID, other_session):
payload = json.dumps({"session_id": session_id, "cwd": str(self.box.project)})
ended = self.box.cli(rec, ["claude-hook", "session-end"], input_text=payload)
self.assertEqual(ended.returncode, 0, merged_output(ended))
printed = self.box.cli(rec, ["resume", "--print", "--surface", SURFACE_ID])
kept = json.loads(self.box.last_sessions.read_text(encoding="utf-8"))["sessions"]
self.assertEqual(
sorted((e["surfaceId"], e["sessionId"]) for e in kept),
sorted([(SURFACE_ID, SESSION_ID), (OTHER_SURFACE_ID, other_session)]),
kept,
)
self.assertEqual(printed.returncode, 0, merged_output(printed))
self.assertEqual(printed.stdout.strip(), f"claude --resume '{SESSION_ID}'")

def test_ending_an_unrecorded_session_leaves_the_panes_live_session_alone(self) -> None:
# Claude /clear starts a new session in the pane before the old one's SessionEnd runs;
# an old id with no live record must not consume the new session's record.
live_session = "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f"
self.write_hook_sessions((live_session, SURFACE_ID))
payload = json.dumps({"session_id": SESSION_ID, "cwd": str(self.box.project)})
with self.recorder() as rec:
ended = self.box.cli(rec, ["claude-hook", "session-end"], input_text=payload)
self.assertEqual(ended.returncode, 0, merged_output(ended))
live = json.loads(self.box.hook_state.read_text(encoding="utf-8"))["sessions"]
self.assertIn(live_session, live, "the newer live session must keep its record")
exits = [
f for f in rec.frames
if f.get("method") == "agent.event" and f.get("params", {}).get("event_type") == "session.exited"
]
self.assertEqual(exits, [], "the live session's agent state must not be cleared")

def test_refuses_while_an_agent_runs_in_the_pane(self) -> None:
self.box.write_last_sessions(self.box.entry())
for state in ("working", "idle"):
Expand Down
Loading