Repository navigation
Fix Agent Hibernation lifecycle for non-codex agents #5500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0f8cbcf
a6cb2d7
dca4feb
bee77a9
f271fee
8d52999
4b3e386
8af0503
53666c9
0cff7a1
ee65659
17fdfd2
cf4876c
32641f6
90fbf01
3bcf06b
2fc6456
e07eb36
23b1859
cec7573
20022bd
944e660
996a5e1
83a5ec7
cc0e5c9
64c1b6b
a0edb86
6e56614
6f57578
004e297
b10ae36
7eac4ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -453,6 +453,9 @@ private struct ClaudeHookSessionRecord: Codable { | |
| var terminalPromptTurnIds: [String]? | ||
| var startedAt: TimeInterval | ||
| var updatedAt: TimeInterval | ||
| // Only advances when agentLifecycle is set to a definitive value (.idle/.running/.needsInput). | ||
| // Unlike updatedAt, does NOT advance on .unknown SessionStart upserts. | ||
| var lifecycleUpdatedAt: TimeInterval? = nil | ||
| } | ||
|
|
||
| private struct ClaudeHookActiveSessionRecord: Codable { | ||
|
|
@@ -1087,7 +1090,20 @@ private final class ClaudeHookSessionStore { | |
| record.isRestorable = isRestorable || record.isRestorable == true | ||
| } | ||
| if let agentLifecycle { | ||
| record.agentLifecycle = agentLifecycle | ||
| // A SessionStart on resume/relaunch reports `.unknown`; never let it | ||
| // erase a previously-proven definitive lifecycle, or a quiescent | ||
| // resumed agent stays stuck at `.unknown` and never re-hibernates. | ||
| record.agentLifecycle = AgentHibernationLifecycleState.preservingDefinitive( | ||
| existing: record.agentLifecycle, | ||
| incoming: agentLifecycle | ||
| ) | ||
| // Only advance lifecycleUpdatedAt for definitive updates. An .unknown | ||
| // SessionStart must not push this timestamp past a terminal-input | ||
| // timestamp recorded after the last idle notification (which would | ||
| // incorrectly clear the mid-turn input guard after a restart). | ||
| if agentLifecycle != .unknown { | ||
| record.lifecycleUpdatedAt = now | ||
| } | ||
| } | ||
| if let subtitle = normalizeOptional(lastSubtitle) { | ||
| record.lastSubtitle = subtitle | ||
|
|
@@ -1097,6 +1113,14 @@ private final class ClaudeHookSessionStore { | |
| } | ||
| if updateLastNotificationStatus { | ||
| record.lastNotificationStatus = lastNotificationStatus | ||
| // A notification with idle status is a lifecycle event for agents that | ||
| // do not emit set_agent_lifecycle (e.g., opencode). Advance lifecycleUpdatedAt | ||
| // so the durable hasUnconfirmedTerminalInput guard has a valid baseline after | ||
| // restart; without this, lifecycleUpdatedAt stays nil and any persisted | ||
| // terminal-input timestamp permanently blocks hibernation for those agents. | ||
| if lastNotificationStatus == .idle { | ||
| record.lifecycleUpdatedAt = now | ||
| } | ||
| } | ||
| if updateRuntimeStatus { | ||
| record.runtimeStatus = runtimeStatus | ||
|
|
@@ -22293,6 +22317,12 @@ struct CMUXCLI { | |
| agentLifecycle: .idle, | ||
| lastSubtitle: completion?.subtitle, | ||
| lastBody: completion?.body, | ||
| // Persist the idle notification status so claude's index | ||
| // fallback (effectiveHibernationLifecycle) resolves to idle | ||
| // even if agentLifecycle is later read as nil/unknown, | ||
| // matching the generic stop handler's semantics exactly. | ||
| lastNotificationStatus: .idle, | ||
| updateLastNotificationStatus: true, | ||
| markActive: true, | ||
| allowsNewSessionReplacement: true | ||
| ) | ||
|
|
@@ -22495,26 +22525,57 @@ struct CMUXCLI { | |
| ) | ||
| let payload = notificationPayload(title: title, subtitle: summary.subtitle, body: summary.body) | ||
|
|
||
| // Hibernation lifecycle only (the user-facing notification and sidebar | ||
| // status below are unchanged). Classification mirrors the generic path | ||
| // (classifyAgentHookNotification): | ||
| // - Permission/approval → .needsInput (blocked mid-tool, must stay live) | ||
| // - "Error" subtitle → .needsInput (mirrors generic .error → .needsInput) | ||
| // - "Attention" with a specific message → nil (informational; don't change | ||
| // lifecycle so an in-flight turn isn't prematurely marked idle) | ||
| // - Everything else ("Completed", "Waiting", generic attention fallback) → | ||
| // .idle (turn finished, safe to hibernate) | ||
| let classifiedLifecycle: AgentHibernationLifecycleState? | ||
| let notifSubtitle = summary.subtitle | ||
| if notifSubtitle == "Attention" && summary.body != "Claude needs your attention" { | ||
| classifiedLifecycle = nil | ||
| } else if notifSubtitle == "Error" | ||
| || AgentHibernationLifecycleState.notificationIndicatesBlocked( | ||
| subtitle: notifSubtitle, | ||
| body: summary.body | ||
| ) { | ||
| classifiedLifecycle = .needsInput | ||
| } else { | ||
| classifiedLifecycle = .idle | ||
| } | ||
| // A plain notification must not downgrade .needsInput set by a preceding | ||
| // AskUserQuestion PreToolUse: the agent is still blocked waiting for the | ||
| // user's answer, so hibernate eligibility must not change. | ||
| let hibernationLifecycle: AgentHibernationLifecycleState? = classifiedLifecycle.map { | ||
| $0 == .idle && mappedSession?.agentLifecycle == .needsInput ? .needsInput : $0 | ||
| } | ||
|
|
||
| if let sessionId = parsedInput.sessionId { | ||
| try? sessionStore.upsert( | ||
| sessionId: sessionId, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId, | ||
| cwd: parsedInput.cwd, | ||
| transcriptPath: parsedInput.transcriptPath, | ||
| agentLifecycle: .needsInput, | ||
| agentLifecycle: hibernationLifecycle, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude idle skips running guardMedium Severity Claude hibernation lifecycle treats completion-style notifications as Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here. |
||
| lastSubtitle: summary.subtitle, | ||
| lastBody: summary.body | ||
| ) | ||
| } | ||
|
|
||
| setAgentLifecycle( | ||
| client: client, | ||
| key: Self.claudeCodeStatusKey, | ||
| lifecycle: .needsInput, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) | ||
| if let hibernationLifecycle { | ||
| setAgentLifecycle( | ||
| client: client, | ||
| key: Self.claudeCodeStatusKey, | ||
| lifecycle: hibernationLifecycle, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude lacks newer-session guardMedium Severity When Claude’s notification handler sets hibernation lifecycle to Additional Locations (1)Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here. |
||
| } | ||
| _ = try? setClaudeStatus( | ||
| client: client, | ||
| workspaceId: workspaceId, | ||
|
|
@@ -22826,15 +22887,17 @@ struct CMUXCLI { | |
| key: String, | ||
| lifecycle: AgentHibernationLifecycleState, | ||
| workspaceId: String, | ||
| surfaceId: String? | ||
| surfaceId: String?, | ||
| preserveIdle: Bool = false | ||
| ) { | ||
| guard Self.allowedAgentLifecycleStatusKeys.contains(key) else { | ||
| fputs("Warning: unsupported agent lifecycle key\n", stderr) | ||
| return | ||
| } | ||
| do { | ||
| let preserveFlag = preserveIdle ? " --preserve-idle" : "" | ||
| _ = try sendV1Command( | ||
| "set_agent_lifecycle \(key) \(lifecycle.rawValue) --tab=\(workspaceId)\(socketPanelOption(surfaceId))", | ||
| "set_agent_lifecycle \(key) \(lifecycle.rawValue) --tab=\(workspaceId)\(socketPanelOption(surfaceId))\(preserveFlag)", | ||
| client: client | ||
| ) | ||
| } catch { | ||
|
|
@@ -28987,13 +29050,33 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { | |
| client: client | ||
| ) | ||
| } | ||
| setAgentLifecycle( | ||
| client: client, | ||
| key: def.statusKey, | ||
| lifecycle: .unknown, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| // SessionStart `.unknown` handling: | ||
| // - When the prior record proves a definitive lifecycle (e.g. `.idle` | ||
| // from a same-session resume): skip the write entirely, keeping the | ||
| // live map empty so Workspace.agentHibernationLifecycleState falls | ||
| // through to the persisted fallback. | ||
| // - Otherwise (new session, fresh agent, or prior record indeterminate): | ||
| // send `.unknown --preserve-idle` so Workspace's preservingDefinitive | ||
| // keeps any resume-seeded `.idle` alive even when the new session ID | ||
| // has no prior store record (mapped == nil). Without --preserve-idle, | ||
| // the write would overwrite the seeded `.idle` with `.unknown` and | ||
| // re-introduce the hibernation-is-one-shot bug for new-session resumes. | ||
| // Use effective() so a record with only lastNotificationStatus=idle | ||
| // (no explicit agentLifecycle) is also treated as proven-definitive. | ||
| let mappedEffectiveLifecycle = AgentHibernationLifecycleState.effective( | ||
| agentLifecycle: mapped?.agentLifecycle, | ||
| lastNotificationStatus: mapped?.lastNotificationStatus?.rawValue | ||
| ) | ||
| if !(mappedEffectiveLifecycle.map { $0 != .unknown } ?? false) { | ||
| setAgentLifecycle( | ||
| client: client, | ||
| key: def.statusKey, | ||
| lifecycle: .unknown, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId, | ||
| preserveIdle: true | ||
| ) | ||
| } | ||
|
|
||
| case .promptSubmit: | ||
| let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.