diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts
index 79d58cbee..75d9ea474 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts
@@ -183,7 +183,10 @@ export function resolveDisabledCreatePrHeaderAction(
{ value0: copy.reviewLabel }
)
break
- default:
+ case 'existing_review':
+ case 'fork_head_unsupported':
+ case 'unsupported_provider':
+ case null:
title = translate(
'auto.components.right.sidebar.source.control.primary.action.f0c6e2a581',
'This branch is not ready for a {{value0}} yet.',
diff --git a/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx b/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx
index f20d726c7..a0b6f40ef 100644
--- a/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control-pull-policy-error-notice.tsx
@@ -3,23 +3,30 @@ import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
const PULL_POLICY_ERROR_PREFIX = 'Pull needs a Git pull policy for divergent branches.'
const PULL_POLICY_OPTIONS = [
{
- label: 'Merge',
- description: 'Create a merge commit when local and remote both changed.',
+ labelKey: 'auto.components.right.sidebar.pull.policy.notice.merge',
+ labelFallback: 'Merge',
+ descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.mergeDescription',
+ descriptionFallback: 'Create a merge commit when local and remote both changed.',
command: 'git config pull.rebase false'
},
{
- label: 'Rebase',
- description: 'Replay local commits on top of the remote branch.',
+ labelKey: 'auto.components.right.sidebar.pull.policy.notice.rebase',
+ labelFallback: 'Rebase',
+ descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.rebaseDescription',
+ descriptionFallback: 'Replay local commits on top of the remote branch.',
command: 'git config pull.rebase true'
},
{
- label: 'Fast-forward only',
- description: 'Only pull when no merge or rebase is needed.',
+ labelKey: 'auto.components.right.sidebar.pull.policy.notice.fastForwardOnly',
+ labelFallback: 'Fast-forward only',
+ descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.fastForwardOnlyDescription',
+ descriptionFallback: 'Only pull when no merge or rebase is needed.',
command: 'git config pull.ff only'
}
] as const
@@ -65,20 +72,29 @@ export function PullPolicyRemoteActionNotice({
{PULL_POLICY_OPTIONS.map((option) => {
const copied = copiedCommand === option.command
+ const label = translate(option.labelKey, option.labelFallback)
+ const description = translate(option.descriptionKey, option.descriptionFallback)
return (
- {option.label}
+ {label}
-
- {option.description}
-
+
{description}
@@ -100,7 +114,11 @@ export function PullPolicyRemoteActionNotice({
variant="ghost"
size="icon-xs"
className="mt-0.5 shrink-0"
- aria-label={`Copy ${option.label.toLowerCase()} pull policy command`}
+ aria-label={translate(
+ 'auto.components.right.sidebar.pull.policy.notice.copyAria',
+ 'Copy {{value0}} pull policy command',
+ { value0: label.toLowerCase() }
+ )}
onClick={() => handleCopyCommand(option.command)}
>
{copied ? (
@@ -111,7 +129,15 @@ export function PullPolicyRemoteActionNotice({
- {copied ? 'Copied' : 'Copy command'}
+ {copied
+ ? translate(
+ 'auto.components.right.sidebar.pull.policy.notice.copied',
+ 'Copied'
+ )
+ : translate(
+ 'auto.components.right.sidebar.pull.policy.notice.copyCommand',
+ 'Copy command'
+ )}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 016f2b323..7890c4397 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -8736,7 +8736,19 @@
"c39d0c75c3": "Linked review branch target is unavailable.",
"8c6d15a07d": "Create PR",
"d37e68f61d": "Preparing branch for review…",
- "c72e5e65d1": "Prepare this branch and create a {{value0}}"
+ "c72e5e65d1": "Prepare this branch and create a {{value0}}",
+ "b8e4f2a901": "Wait for the remote operation to finish.",
+ "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
+ "d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
+ "e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
+ "f4c0e6a925": "Commit changes before creating a {{value0}}.",
+ "a5d1f7b036": "Publish commits before creating a {{value0}}.",
+ "b6e2a8c147": "Push commits before creating a {{value0}}.",
+ "c7f3b9d258": "Sync this branch before creating a {{value0}}.",
+ "d8a4c0e369": "Authenticate before creating a {{value0}}.",
+ "e9b5d1f470": "Check out a branch before creating a {{value0}}.",
+ "f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
+ "h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@@ -8911,6 +8923,24 @@
"8c2d5fab13": "Copy commit hash",
"9d3e60bc24": "Copy commit message",
"ae4f71cd35": "Explain changes"
+ },
+ "pull": {
+ "policy": {
+ "notice": {
+ "merge": "Merge",
+ "mergeDescription": "Create a merge commit when local and remote both changed.",
+ "rebase": "Rebase",
+ "rebaseDescription": "Replay local commits on top of the remote branch.",
+ "fastForwardOnly": "Fast-forward only",
+ "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
+ "title": "Pull needs a policy",
+ "diverged": "Diverged",
+ "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
+ "copyAria": "Copy {{value0}} pull policy command",
+ "copied": "Copied",
+ "copyCommand": "Copy command"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 69a3c1c10..3111229e0 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -8736,7 +8736,19 @@
"c39d0c75c3": "El destino de la rama de revisión vinculada no está disponible.",
"8c6d15a07d": "Crear PR",
"d37e68f61d": "Preparando la rama para revisión…",
- "c72e5e65d1": "Prepara esta rama y crea un {{value0}}"
+ "c72e5e65d1": "Prepara esta rama y crea un {{value0}}",
+ "b8e4f2a901": "Wait for the remote operation to finish.",
+ "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
+ "d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
+ "e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
+ "f4c0e6a925": "Commit changes before creating a {{value0}}.",
+ "a5d1f7b036": "Publish commits before creating a {{value0}}.",
+ "b6e2a8c147": "Push commits before creating a {{value0}}.",
+ "c7f3b9d258": "Sync this branch before creating a {{value0}}.",
+ "d8a4c0e369": "Authenticate before creating a {{value0}}.",
+ "e9b5d1f470": "Check out a branch before creating a {{value0}}.",
+ "f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
+ "h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@@ -8911,6 +8923,24 @@
"8c2d5fab13": "Copiar hash del commit",
"9d3e60bc24": "Copiar mensaje del commit",
"ae4f71cd35": "Explicar los cambios"
+ },
+ "pull": {
+ "policy": {
+ "notice": {
+ "merge": "Merge",
+ "mergeDescription": "Create a merge commit when local and remote both changed.",
+ "rebase": "Rebase",
+ "rebaseDescription": "Replay local commits on top of the remote branch.",
+ "fastForwardOnly": "Fast-forward only",
+ "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
+ "title": "Pull needs a policy",
+ "diverged": "Diverged",
+ "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
+ "copyAria": "Copy {{value0}} pull policy command",
+ "copied": "Copied",
+ "copyCommand": "Copy command"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 930a0b270..8733797b5 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -8736,7 +8736,19 @@
"c39d0c75c3": "リンクされたレビュー ブランチのターゲットを利用できません。",
"8c6d15a07d": "PR を作成",
"d37e68f61d": "レビュー用にブランチを準備中…",
- "c72e5e65d1": "このブランチを準備して {{value0}} を作成"
+ "c72e5e65d1": "このブランチを準備して {{value0}} を作成",
+ "b8e4f2a901": "Wait for the remote operation to finish.",
+ "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
+ "d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
+ "e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
+ "f4c0e6a925": "Commit changes before creating a {{value0}}.",
+ "a5d1f7b036": "Publish commits before creating a {{value0}}.",
+ "b6e2a8c147": "Push commits before creating a {{value0}}.",
+ "c7f3b9d258": "Sync this branch before creating a {{value0}}.",
+ "d8a4c0e369": "Authenticate before creating a {{value0}}.",
+ "e9b5d1f470": "Check out a branch before creating a {{value0}}.",
+ "f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
+ "h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@@ -8911,6 +8923,24 @@
"8c2d5fab13": "コミットハッシュをコピー",
"9d3e60bc24": "コミットメッセージをコピー",
"ae4f71cd35": "変更を説明"
+ },
+ "pull": {
+ "policy": {
+ "notice": {
+ "merge": "Merge",
+ "mergeDescription": "Create a merge commit when local and remote both changed.",
+ "rebase": "Rebase",
+ "rebaseDescription": "Replay local commits on top of the remote branch.",
+ "fastForwardOnly": "Fast-forward only",
+ "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
+ "title": "Pull needs a policy",
+ "diverged": "Diverged",
+ "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
+ "copyAria": "Copy {{value0}} pull policy command",
+ "copied": "Copied",
+ "copyCommand": "Copy command"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 7986a8e9d..fd3c67ad4 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -8736,7 +8736,19 @@
"c39d0c75c3": "연결된 검토 브랜치 대상을 사용할 수 없습니다.",
"8c6d15a07d": "PR 생성",
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
- "c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성"
+ "c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성",
+ "b8e4f2a901": "Wait for the remote operation to finish.",
+ "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
+ "d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
+ "e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
+ "f4c0e6a925": "Commit changes before creating a {{value0}}.",
+ "a5d1f7b036": "Publish commits before creating a {{value0}}.",
+ "b6e2a8c147": "Push commits before creating a {{value0}}.",
+ "c7f3b9d258": "Sync this branch before creating a {{value0}}.",
+ "d8a4c0e369": "Authenticate before creating a {{value0}}.",
+ "e9b5d1f470": "Check out a branch before creating a {{value0}}.",
+ "f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
+ "h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@@ -8911,6 +8923,24 @@
"8c2d5fab13": "커밋 해시 복사",
"9d3e60bc24": "커밋 메시지 복사",
"ae4f71cd35": "변경 사항 설명"
+ },
+ "pull": {
+ "policy": {
+ "notice": {
+ "merge": "Merge",
+ "mergeDescription": "Create a merge commit when local and remote both changed.",
+ "rebase": "Rebase",
+ "rebaseDescription": "Replay local commits on top of the remote branch.",
+ "fastForwardOnly": "Fast-forward only",
+ "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
+ "title": "Pull needs a policy",
+ "diverged": "Diverged",
+ "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
+ "copyAria": "Copy {{value0}} pull policy command",
+ "copied": "Copied",
+ "copyCommand": "Copy command"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index b446cb3c5..09099aa45 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -8736,7 +8736,19 @@
"c39d0c75c3": "已关联的评审分支目标不可用。",
"8c6d15a07d": "创建 PR",
"d37e68f61d": "正在准备分支以供评审…",
- "c72e5e65d1": "准备此分支并创建 {{value0}}"
+ "c72e5e65d1": "准备此分支并创建 {{value0}}",
+ "b8e4f2a901": "Wait for the remote operation to finish.",
+ "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
+ "d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
+ "e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
+ "f4c0e6a925": "Commit changes before creating a {{value0}}.",
+ "a5d1f7b036": "Publish commits before creating a {{value0}}.",
+ "b6e2a8c147": "Push commits before creating a {{value0}}.",
+ "c7f3b9d258": "Sync this branch before creating a {{value0}}.",
+ "d8a4c0e369": "Authenticate before creating a {{value0}}.",
+ "e9b5d1f470": "Check out a branch before creating a {{value0}}.",
+ "f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
+ "h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@@ -8911,6 +8923,24 @@
"8c2d5fab13": "复制提交哈希",
"9d3e60bc24": "复制提交信息",
"ae4f71cd35": "解释更改"
+ },
+ "pull": {
+ "policy": {
+ "notice": {
+ "merge": "Merge",
+ "mergeDescription": "Create a merge commit when local and remote both changed.",
+ "rebase": "Rebase",
+ "rebaseDescription": "Replay local commits on top of the remote branch.",
+ "fastForwardOnly": "Fast-forward only",
+ "fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
+ "title": "Pull needs a policy",
+ "diverged": "Diverged",
+ "body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
+ "copyAria": "Copy {{value0}} pull policy command",
+ "copied": "Copied",
+ "copyCommand": "Copy command"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts
index 3ae6f8e37..1d3913d94 100644
--- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts
+++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts
@@ -58,6 +58,20 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
+ it('skips live-checkpoint records — their restored pane owns recovery', () => {
+ const record = makeRecord({ origin: 'live' })
+ useAppStore.setState({
+ tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
+ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
+ } as never)
+
+ const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
+
+ expect(launched).toBe(0)
+ expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
+ expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
+ })
+
it('resumes legacy sleep records without an origin even when their tab still exists', () => {
const record = makeRecord()
useAppStore.setState({
diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts
index 2128eb078..8dc7e99cf 100644
--- a/src/renderer/src/lib/resume-sleeping-agent-session.ts
+++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts
@@ -81,11 +81,10 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number {
const records = Object.values(useAppStore.getState().sleepingAgentSessionsByPaneKey)
.filter((record) => record.worktreeId === worktreeId)
- // Why: quit-time captures (#5232) cover panes that still exist in the
- // restored session. Those panes own their own recovery — warm reattach
- // when the daemon kept the agent alive, or the pane-level cold-restore
- // resume — so launching a separate tab here would duplicate the session.
- .filter((record) => record.origin !== 'quit')
+ // Why: pane-owned captures (#5232/#5626) cover panes that still exist in
+ // the restored session. Those panes own their own recovery — warm reattach
+ // when the daemon kept the agent alive, or pane-level cold-restore resume.
+ .filter((record) => record.origin !== 'quit' && record.origin !== 'live')
.sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)
let launched = 0
diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts
index 98135b398..703c54058 100644
--- a/src/renderer/src/lib/session-write-subscriber.test.ts
+++ b/src/renderer/src/lib/session-write-subscriber.test.ts
@@ -135,6 +135,58 @@ describe('createSessionWriteSubscriber', () => {
cleanup()
})
+ it('writes a live agent recovery checkpoint when provider session metadata arrives', () => {
+ const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>()
+ const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist })
+
+ useAppStore.setState({
+ workspaceSessionReady: true,
+ hydrationSucceeded: true,
+ tabsByWorktree: {
+ 'wt-1': [
+ {
+ id: 'tab-1',
+ ptyId: null,
+ worktreeId: 'wt-1',
+ title: 'Codex',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ }
+ } as never)
+ vi.advanceTimersByTime(200)
+ persist.mockClear()
+
+ useAppStore.getState().setAgentStatus(
+ 'tab-1:leaf-1',
+ {
+ state: 'working',
+ prompt: 'Fix tests',
+ agentType: 'codex'
+ },
+ 'Codex',
+ { updatedAt: 10, stateStartedAt: 10 },
+ { tabId: 'tab-1', worktreeId: 'wt-1' },
+ { providerSession: { key: 'session_id', id: 'codex-session-1' } }
+ )
+ vi.advanceTimersByTime(200)
+
+ expect(persist).toHaveBeenCalledWith({
+ patch: {
+ sleepingAgentSessionsByPaneKey: {
+ 'tab-1:leaf-1': expect.objectContaining({
+ providerSession: { key: 'session_id', id: 'codex-session-1' },
+ origin: 'live'
+ })
+ }
+ }
+ })
+ cleanup()
+ })
+
it('writes exactly once when a relevant field changes', () => {
const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>()
const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist })
diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts
index a49fd479d..97addb030 100644
--- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts
+++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts
@@ -24,6 +24,112 @@ function makeAgentEntry(overrides: {
}
describe('captureAllSleepingAgentSessions', () => {
+ it('checkpoints a live resumable provider session before quit-time capture', () => {
+ const store = createTestStore()
+ store.setState({
+ tabsByWorktree: {
+ 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
+ }
+ } as Partial
)
+
+ 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' } }
+ )
+
+ // Why: Windows update/reboot exits can miss beforeunload; the provider
+ // session handle must already be durable for pane-level cold restore.
+ expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
+ agent: 'codex',
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ providerSession: { key: 'session_id', id: 'codex-session-1' },
+ origin: 'live'
+ })
+ })
+
+ it('does not rewrite the live checkpoint for same-session status ticks', () => {
+ const store = createTestStore()
+ store.setState({
+ tabsByWorktree: {
+ 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
+ }
+ } as Partial)
+
+ store.getState().setAgentStatus(
+ 'tab-1:leaf-1',
+ {
+ state: 'working',
+ prompt: 'first prompt',
+ agentType: 'codex'
+ },
+ 'Codex',
+ { updatedAt: 10, stateStartedAt: 10 },
+ { tabId: 'tab-1', worktreeId: 'wt-1' },
+ { providerSession: { key: 'session_id', id: 'codex-session-1' } }
+ )
+ const firstRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']
+
+ store.getState().setAgentStatus(
+ 'tab-1:leaf-1',
+ {
+ state: 'working',
+ prompt: 'second prompt',
+ 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']).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' })]
+ }
+ } as Partial)
+
+ 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()
+ })
+
it('captures resumable agents across every worktree, not just one', () => {
const store = createTestStore()
store.setState({
diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts
index c484bcb2c..0fd7871c9 100644
--- a/src/renderer/src/store/slices/agent-status.ts
+++ b/src/renderer/src/store/slices/agent-status.ts
@@ -274,6 +274,23 @@ export function collectSleepingAgentSessionRecordsForWorktree(
return records
}
+function recoveryRecordMatches(
+ existing: SleepingAgentSessionRecord | undefined,
+ next: SleepingAgentSessionRecord
+): boolean {
+ if (!existing) {
+ return false
+ }
+ return (
+ existing.origin === next.origin &&
+ existing.agent === next.agent &&
+ existing.worktreeId === next.worktreeId &&
+ existing.tabId === next.tabId &&
+ existing.providerSession.key === next.providerSession.key &&
+ existing.providerSession.id === next.providerSession.id
+ )
+}
+
function pruneMigrationUnsupportedEntries(
entries: Record,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
@@ -611,11 +628,30 @@ export const createAgentStatusSlice: StateCreator entry.paneKey === paneKey
)
- const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey
- const nextSleepingAgentSessions = hasSleepingRecord
- ? { ...s.sleepingAgentSessionsByPaneKey }
- : s.sleepingAgentSessionsByPaneKey
- if (hasSleepingRecord) {
+ const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
+ const liveRecoveryWorktreeId =
+ entry.state === 'done'
+ ? null
+ : (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey))
+ const liveRecoveryRecord = liveRecoveryWorktreeId
+ ? sleepingRecordFromEntry({
+ state: s,
+ entry,
+ worktreeId: liveRecoveryWorktreeId,
+ capturedAt: updatedAt,
+ origin: 'live'
+ })
+ : null
+ let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey
+ if (liveRecoveryRecord) {
+ if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) {
+ nextSleepingAgentSessions = {
+ ...s.sleepingAgentSessionsByPaneKey,
+ [paneKey]: liveRecoveryRecord
+ }
+ }
+ } else if (existingSleepingRecord) {
+ nextSleepingAgentSessions = { ...s.sleepingAgentSessionsByPaneKey }
delete nextSleepingAgentSessions[paneKey]
}
return {
diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts
index f5ba56529..d092a20aa 100644
--- a/src/shared/agent-session-resume.ts
+++ b/src/shared/agent-session-resume.ts
@@ -36,10 +36,10 @@ export type SleepingAgentSessionRecord = {
connectionId?: string | null
/** How the record was captured. Worktree-sleep records (legacy records have
* no origin) are consumed by worktree activation, which opens a fresh tab.
- * Quit records describe panes that still exist in the restored session, so
- * only the pane's own cold-restore path may consume them — activation
+ * Quit/live records describe panes that still exist in the restored session,
+ * so only the pane's own cold-restore path may consume them — activation
* launching a tab too would duplicate a warm-reattached session (#5232). */
- origin?: 'worktree-sleep' | 'quit'
+ origin?: 'worktree-sleep' | 'quit' | 'live'
}
const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS)
diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts
index 879a996ab..800b28e0b 100644
--- a/src/shared/workspace-session-schema.test.ts
+++ b/src/shared/workspace-session-schema.test.ts
@@ -126,13 +126,15 @@ describe('parseWorkspaceSession', () => {
capturedAt: 10,
updatedAt: 9,
terminalTitle: 'Codex',
- lastAssistantMessage: 'done'
+ lastAssistantMessage: 'done',
+ origin: 'live'
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex')
+ expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live')
}
})
diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts
index b21c2a801..a8556f180 100644
--- a/src/shared/workspace-session-schema.ts
+++ b/src/shared/workspace-session-schema.ts
@@ -109,7 +109,8 @@ const sleepingAgentSessionRecordSchema = z.object({
updatedAt: z.number().finite().positive(),
terminalTitle: z.string().optional(),
lastAssistantMessage: z.string().optional(),
- connectionId: z.string().nullable().optional()
+ connectionId: z.string().nullable().optional(),
+ origin: z.enum(['worktree-sleep', 'quit', 'live']).optional()
})
const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => {