fix(agent-status): resume finished agents after macOS logout instead of a bare shell (#9454)

After the daemon-retirement fix (#9826) recovers terminals across a macOS
logout, a running Claude Code (or other resumable TUI agent) that had
finished its turn came back as a BARE SHELL — the user had to open a new
tab and `claude --resume` in every worktree to get logged back in.

Root cause: setAgentStatus writes a persisted 'live recovery' sleeping
record (the cold-restore --resume anchor) for a live resumable agent, but
drops it once the turn is 'done' — except for Pi, which was special-cased
(retainsPiRecoveryIdentity). A finished Claude/Codex turn leaves the TUI
alive and resumable at its prompt just like Pi, so an idle agent lost its
anchor; a cold restore after an abrupt app death (logout) then found
nothing to resume and dropped to a bare shell.

Fix: generalize the Pi-only recovery-identity retention to every resumable
TUI agent (isResumableTuiAgent), gated on a valid resume argv, so a finished
agent keeps its persisted providerSession anchor and the already-shipped
cold-restore --resume path brings it back logged in. Extends an existing
pattern; no new subsystem.

Reproduced + validated live in Electron via the login-session-death seam:
idle Claude -> daemon retire -> app relaunch previously bare-shelled; now
spawns 'claude --resume <session>' and restores the conversation logged in.
This commit is contained in:
Brennan Benson
2026-07-23 16:08:32 -07:00
parent 06f6e3bed2
commit 4a604d70c9
2 changed files with 51 additions and 41 deletions
@@ -686,42 +686,48 @@ describe('captureAllSleepingAgentSessions', () => {
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(firstRecord)
})
it('clears the live checkpoint when the agent finishes', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
// Why: a finished resumable-agent turn leaves the TUI alive at its prompt, so the persisted
// recovery anchor must survive `done` (state remapped to 'working' to stay cold-restore
// eligible) — else logout→relaunch cold-restores to a bare shell instead of `--resume` (#9454).
// Covers claude (the reported agent) and codex; previously this was Pi-only.
it.each([
['claude', 'Claude'],
['codex', 'Codex']
] as const)(
'retains the recovery anchor when a finished %s session stays resumable (#9454)',
(agentType, title) => {
const store = createTestStore()
store.setState({
tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }
} as Partial<AppState>)
const providerSession = { key: 'session_id', id: `${agentType}-session-1` } as const
for (const [state, updatedAt] of [
['working', 10],
['done', 20]
] as const) {
store
.getState()
.setAgentStatus(
'tab-1:leaf-1',
{ state, prompt: 'finish the task', agentType },
title,
{ updatedAt, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession }
)
}
} as Partial<AppState>)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'finish the task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'done',
prompt: 'finish the task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 20, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
agent: agentType,
providerSession,
origin: 'live',
state: 'working'
})
// Launch config is still cleared on done: tokens must no longer authorize config reuse.
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
}
)
it('does not reuse launch config from a completed same-pane agent', () => {
const store = createTestStore()
+11 -7
View File
@@ -1801,13 +1801,17 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
? registryEntry?.launchConfig
: undefined
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
const retainsPiRecoveryIdentity =
// Why: a completed turn leaves the TUI session alive and resumable at its prompt for any
// resumable agent (Claude/Codex/Pi/…), not just Pi — so keep its persisted recovery anchor
// even when done. Else a cold restore after an abrupt app death (macOS logout, #9454) drops
// the pane to a bare shell instead of `--resume`-ing the agent logged in.
const retainsResumableRecoveryIdentity =
payload.state === 'done' &&
identity.agentType === 'pi' &&
isResumableTuiAgent(identity.agentType) &&
providerSession !== undefined &&
getAgentResumeArgv('pi', providerSession) !== null
getAgentResumeArgv(identity.agentType, providerSession) !== null
const matchedSleepingLaunchConfig =
(payload.state !== 'done' || retainsPiRecoveryIdentity) &&
(payload.state !== 'done' || retainsResumableRecoveryIdentity) &&
existingSleepingRecord?.launchConfig &&
existingSleepingRecord.agent === identity.agentType &&
providerSession &&
@@ -1935,14 +1939,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
(entry) => entry.paneKey === paneKey
)
const liveRecoveryWorktreeId =
entry.state === 'done' && !retainsPiRecoveryIdentity
entry.state === 'done' && !retainsResumableRecoveryIdentity
? null
: (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey))
const liveRecoveryRecord = liveRecoveryWorktreeId
? sleepingRecordFromEntry({
state: s,
// Why: a completed Pi turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
entry: retainsPiRecoveryIdentity
// Why: a completed resumable-agent turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
entry: retainsResumableRecoveryIdentity
? { ...entry, state: 'working', prompt: '', lastAssistantMessage: undefined }
: entry,
worktreeId: liveRecoveryWorktreeId,