Repository navigation
Hibernation: correct notification lifecycle for all agent types #6721
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 2 commits
fdefc51
abf33a1
57b8c38
9afdd6b
deb7af2
15a274a
1052a1e
56bb289
f5857ff
6d2072c
ed339fb
64a4820
37d6676
e6580b2
cf9ccee
366889c
565e197
fb7c61e
395b5b8
b0d05e7
3d3cc47
157ef0a
81badf1
9de2730
f837b8d
454483b
2ed1744
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 |
|---|---|---|
|
|
@@ -72,6 +72,29 @@ private struct AgentHookNotificationSummary { | |
| let body: String | ||
| let status: AgentHookNotificationStatus? | ||
| let isFallback: Bool | ||
| /// Whether this notification is a genuinely blocking prompt the user must act | ||
| /// on (permission/approval/error), as opposed to a routine waiting/idle | ||
| /// reminder. Only a blocking notification may flip the hibernation lifecycle to | ||
| /// `.needsInput`; a routine reminder must leave the Stop hook's `.idle` intact so | ||
| /// the pane stays hibernation-eligible. See `agentNotificationIsBlockingPrompt`. | ||
| let isBlocking: Bool | ||
| } | ||
|
|
||
| /// Whether a notification's text describes a genuinely blocking prompt (the user | ||
| /// must act now) rather than a routine waiting/idle reminder or a completion. | ||
| /// | ||
| /// Single source of truth for "should this notification keep the pane live", | ||
| /// shared by the dedicated Claude hook lane and the generic agent-hook lane. The | ||
| /// hibernation lifecycle has one authoritative idle source (the Stop hook); a | ||
| /// notification may only *assert* `.needsInput` (never force `.idle` from ambiguous | ||
| /// prose) and only when this returns true. Everything else leaves the lifecycle | ||
| /// untouched so a finished agent stays hibernation-eligible across every agent type. | ||
| func agentNotificationIsBlockingPrompt(signal: String, message: String) -> Bool { | ||
| let lower = "\(signal) \(message)".lowercased() | ||
| return lower.contains("permission") || lower.contains("approve") || lower.contains("approval") | ||
| || lower.contains("permission_prompt") | ||
| || lower.contains("error") || lower.contains("failed") || lower.contains("failure") | ||
| || lower.contains("exception") | ||
| } | ||
|
|
||
|
Contributor
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.
|
||
| #if DEBUG | ||
|
Contributor
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.
Rule Used: Flag new ambient global state in production Swift:... (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
|
|
@@ -23437,7 +23460,13 @@ struct CMUXCLI { | |
| if let mappedSession, | ||
| let savedBody = mappedSession.lastBody, !savedBody.isEmpty, | ||
| summary.body.contains("needs your attention") || summary.body.contains("needs your input") { | ||
| summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody) | ||
| // Reuse the specific body an earlier hook saved for display only; the | ||
| // blocking classification stays the freshly computed value. | ||
| summary = ( | ||
| subtitle: mappedSession.lastSubtitle ?? summary.subtitle, | ||
| body: savedBody, | ||
| isBlocking: summary.isBlocking | ||
| ) | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| let title = String( | ||
|
|
@@ -23446,26 +23475,36 @@ struct CMUXCLI { | |
| ) | ||
| let payload = notificationPayload(title: title, subtitle: summary.subtitle, body: summary.body) | ||
|
|
||
| // Only a genuinely blocking prompt (permission/approval/error) flips the | ||
| // hibernation lifecycle to `.needsInput`. Claude fires a Notification after | ||
| // every turn (a routine "waiting for your input" reminder once idle); that | ||
| // must NOT clobber the `.idle` the Stop hook recorded, or the pane never | ||
| // hibernates. `nil` preserves the stored lifecycle and the live mutation is | ||
| // skipped; the bell/status below are user-facing and unchanged. | ||
| let blockingLifecycle: AgentHibernationLifecycleState? = summary.isBlocking ? .needsInput : nil | ||
|
|
||
| if let sessionId = parsedInput.sessionId { | ||
| try? sessionStore.upsert( | ||
| sessionId: sessionId, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId, | ||
| cwd: parsedInput.cwd, | ||
| transcriptPath: parsedInput.transcriptPath, | ||
| agentLifecycle: .needsInput, | ||
| agentLifecycle: blockingLifecycle, | ||
| lastSubtitle: summary.subtitle, | ||
| lastBody: summary.body | ||
| ) | ||
| } | ||
|
|
||
| setAgentLifecycle( | ||
| client: client, | ||
| key: Self.claudeCodeStatusKey, | ||
| lifecycle: .needsInput, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) | ||
| if let blockingLifecycle { | ||
| setAgentLifecycle( | ||
| client: client, | ||
| key: Self.claudeCodeStatusKey, | ||
| lifecycle: blockingLifecycle, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) | ||
| } | ||
| _ = try? setClaudeStatus( | ||
| client: client, | ||
| workspaceId: workspaceId, | ||
|
|
@@ -25783,12 +25822,20 @@ struct CMUXCLI { | |
| return nil | ||
| } | ||
|
|
||
| private func summarizeClaudeHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) { | ||
| private func summarizeClaudeHookNotification( | ||
| parsedInput: ClaudeHookParsedInput | ||
| ) -> (subtitle: String, body: String, isBlocking: Bool) { | ||
| guard let object = parsedInput.object else { | ||
| if let fallback = parsedInput.rawFallback, !fallback.isEmpty { | ||
| return classifyClaudeNotification(signal: fallback, message: fallback) | ||
| let classified = classifyClaudeNotification(signal: fallback, message: fallback) | ||
| return ( | ||
| classified.subtitle, | ||
| classified.body, | ||
| agentNotificationIsBlockingPrompt(signal: fallback, message: fallback) | ||
| ) | ||
| } | ||
| return ("Waiting", "Claude is waiting for your input") | ||
| // A bare "waiting for input" reminder is an idle agent, not a blocking prompt. | ||
| return ("Waiting", "Claude is waiting for your input", false) | ||
| } | ||
|
|
||
| let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:] | ||
|
|
@@ -25807,7 +25854,11 @@ struct CMUXCLI { | |
| var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) | ||
|
|
||
| classified.body = truncate(classified.body, maxLength: 180) | ||
| return classified | ||
| return ( | ||
| classified.subtitle, | ||
| classified.body, | ||
| agentNotificationIsBlockingPrompt(signal: signal, message: normalizedMessage) | ||
| ) | ||
| } | ||
|
|
||
| private func summarizeAgentHookNotification( | ||
|
|
@@ -25875,7 +25926,8 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.completed", defaultValue: "Completed"), | ||
| body: String(localized: "agent.generic.notification.body.taskCompleted", defaultValue: "Task completed"), | ||
| status: .idle, | ||
| isFallback: false | ||
| isFallback: false, | ||
| isBlocking: false | ||
| ) | ||
| } | ||
| return classifyAgentHookNotification( | ||
|
|
@@ -25907,7 +25959,8 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.completed", defaultValue: "Completed"), | ||
| body: truncate(normalizedSingleLine(body), maxLength: 180), | ||
| status: .idle, | ||
| isFallback: false | ||
| isFallback: false, | ||
| isBlocking: false | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -26110,6 +26163,11 @@ struct CMUXCLI { | |
| isFallback: Bool | ||
| ) -> AgentHookNotificationSummary { | ||
| let lower = "\(signal) \(message)".lowercased() | ||
| // Genuinely blocking == permission/approval/error. A routine waiting/idle | ||
| // reminder, a completion, or generic attention is NOT blocking and must not | ||
| // flip the hibernation lifecycle to `.needsInput` (which would clobber the | ||
| // Stop hook's `.idle` and keep the pane from ever hibernating). | ||
| let blocking = agentNotificationIsBlockingPrompt(signal: signal, message: message) | ||
| if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") { | ||
| let body = message.isEmpty | ||
| ? String(localized: "agent.generic.notification.body.approvalNeeded", defaultValue: "Approval needed") | ||
|
|
@@ -26118,7 +26176,8 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.permission", defaultValue: "Permission"), | ||
| body: truncate(body, maxLength: 180), | ||
| status: .needsInput, | ||
| isFallback: isFallback | ||
| isFallback: isFallback, | ||
| isBlocking: blocking | ||
| ) | ||
| } | ||
| if lower.contains("error") || lower.contains("failed") || lower.contains("failure") || lower.contains("exception") { | ||
|
|
@@ -26132,7 +26191,8 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.error", defaultValue: "Error"), | ||
| body: truncate(body, maxLength: 180), | ||
| status: .error, | ||
| isFallback: isFallback | ||
| isFallback: isFallback, | ||
| isBlocking: blocking | ||
| ) | ||
| } | ||
| if containsCompletionCue(lower) { | ||
|
|
@@ -26143,7 +26203,8 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.completed", defaultValue: "Completed"), | ||
| body: truncate(body, maxLength: 180), | ||
| status: .idle, | ||
| isFallback: isFallback | ||
| isFallback: isFallback, | ||
| isBlocking: blocking | ||
| ) | ||
| } | ||
| if containsWaitingCue(lower) { | ||
|
|
@@ -26154,15 +26215,17 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.waiting", defaultValue: "Waiting"), | ||
| body: truncate(body, maxLength: 180), | ||
| status: .needsInput, | ||
| isFallback: isFallback | ||
| isFallback: isFallback, | ||
| isBlocking: blocking | ||
| ) | ||
| } | ||
| if !message.isEmpty { | ||
| return AgentHookNotificationSummary( | ||
| subtitle: String(localized: "agent.generic.notification.subtitle.attention", defaultValue: "Attention"), | ||
| body: truncate(message, maxLength: 180), | ||
| status: nil, | ||
| isFallback: isFallback | ||
| isFallback: isFallback, | ||
| isBlocking: blocking | ||
| ) | ||
| } | ||
| let body = String.localizedStringWithFormat( | ||
|
|
@@ -26173,7 +26236,8 @@ struct CMUXCLI { | |
| subtitle: String(localized: "agent.generic.notification.subtitle.attention", defaultValue: "Attention"), | ||
| body: body, | ||
| status: .needsInput, | ||
| isFallback: true | ||
| isFallback: true, | ||
| isBlocking: blocking | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -30710,7 +30774,8 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { | |
| subtitle: mapped?.lastSubtitle ?? summary.subtitle, | ||
| body: savedBody, | ||
| status: mapped?.lastNotificationStatus, | ||
| isFallback: false | ||
| isFallback: false, | ||
| isBlocking: summary.isBlocking | ||
| ) | ||
| } | ||
| let antigravitySuppressDuplicateIdleWhileBackgroundWork = def.name == "antigravity" | ||
|
|
@@ -30864,13 +30929,21 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { | |
|
|
||
| switch summary.status { | ||
| case .needsInput?: | ||
| setAgentLifecycle( | ||
| client: client, | ||
| key: def.statusKey, | ||
| lifecycle: .needsInput, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) | ||
| // Only a genuinely blocking prompt (permission/approval) flips the | ||
| // hibernation lifecycle to `.needsInput`. A routine "waiting for input" | ||
| // reminder (which also classifies as `.needsInput` for display) must | ||
| // leave the Stop hook's `.idle` intact so the pane stays | ||
| // hibernation-eligible. The bell/status below are user-facing and | ||
| // unchanged either way. | ||
| if summary.isBlocking { | ||
| setAgentLifecycle( | ||
| client: client, | ||
| key: def.statusKey, | ||
| lifecycle: .needsInput, | ||
| workspaceId: workspaceId, | ||
| surfaceId: surfaceId | ||
| ) | ||
| } | ||
| let statusValue = String.localizedStringWithFormat( | ||
| String(localized: "agent.generic.notification.status.needsInput", defaultValue: "%@ needs input"), | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| def.displayName | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1258,14 +1258,33 @@ struct RestorableAgentSessionIndex: Sendable { | |
| guard kind == .claude else { | ||
| return record.isRestorable != false | ||
| } | ||
| // A located transcript is the strongest signal the conversation can replay. | ||
| if let transcriptPath = normalizedNonEmptyValue(record.transcriptPath), | ||
| regularNonEmptyFileExists( | ||
| atPath: (transcriptPath as NSString).expandingTildeInPath, | ||
| fileManager: fileManager | ||
| ) { | ||
| return true | ||
| } | ||
| return claudeTranscriptExists(for: record, fileManager: fileManager, lookup: claudeTranscriptLookup) | ||
| if claudeTranscriptExists(for: record, fileManager: fileManager, lookup: claudeTranscriptLookup) { | ||
| return true | ||
| } | ||
| // Fall back to the authoritative signal: the claude Stop hook sets | ||
| // `isRestorable == true` only for a genuinely resumable session. Transcript | ||
| // resolution legitimately misses (custom CLAUDE_CONFIG_DIR, account-scoped or | ||
| // forked roots, project-dir encoding drift); discarding a would-be-restorable | ||
| // session there made claude silently never hibernate while codex did (the | ||
| // classic "only codex hibernates" report). Trust the hook's explicit flag; | ||
| // `claude --resume` errors cleanly if the transcript is truly gone, which beats | ||
| // permanent invisibility. Log the gap so it stays diagnosable, not silent. | ||
| if record.isRestorable == true { | ||
| cmuxDebugLog( | ||
| "agentHib.restorable.claudeTranscriptMissing session=\(record.sessionId.prefix(8)) " | ||
| + "transcriptPath=\(record.transcriptPath ?? "<nil>") — trusting isRestorable=true" | ||
|
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. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Redact the transcript path from the debug log.
🛡️ Proposed fix if record.isRestorable == true {
+ let hasTranscriptPath = normalizedNonEmptyValue(record.transcriptPath) != nil
+ let transcriptPathBytes = record.transcriptPath?.utf8.count ?? 0
cmuxDebugLog(
"agentHib.restorable.claudeTranscriptMissing session=\(record.sessionId.prefix(8)) "
- + "transcriptPath=\(record.transcriptPath ?? "<nil>") — trusting isRestorable=true"
+ + "hasTranscriptPath=\(hasTranscriptPath ? 1 : 0) "
+ + "transcriptPathBytes=\(transcriptPathBytes) — trusting isRestorable=true"
)
return true
}🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| ) | ||
|
Comment on lines
+1305
to
+1308
Contributor
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.
Rule Used: Flag production Swift diagnostics that bypass unif... (source) |
||
| return true | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| private static func resolvedClaudeWorkflowRecord( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.