Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fdefc51
Hibernation: make notification lifecycle correct for all agent types …
lawrencecchen Jun 24, 2026
abf33a1
Hibernation: per-evaluation observability so non-hibernation is never…
lawrencecchen Jun 24, 2026
57b8c38
Hibernation: gate persisted lifecycle for nonblocking notifications
lawrencecchen Jun 24, 2026
9afdd6b
Hibernation: route copilot/codebuddy/factory Notification to notifica…
lawrencecchen Jun 24, 2026
deb7af2
Hibernation: make classifier package-testable + guard DEBUG-only logs
lawrencecchen Jun 24, 2026
15a274a
Hibernation: classifier as package type + Swift Testing suite
lawrencecchen Jun 24, 2026
1052a1e
Hibernation: classifier as instantiable value type (package-conventio…
lawrencecchen Jun 24, 2026
56bb289
Hibernation: require a recorded transcript path for the claude restor…
lawrencecchen Jun 24, 2026
f5857ff
Hibernation: keep claude restorability fail-closed (diagnostic only)
lawrencecchen Jun 24, 2026
6d2072c
Hibernation: deterministic planner unit test + all-agent-types coverage
lawrencecchen Jun 24, 2026
ed339fb
Merge remote-tracking branch 'origin/main' into feat-hibernation-all-…
lawrencecchen Jul 1, 2026
64a4820
ci: isolate heavy WKWebView BrowserSessionHistoryRestoreTests as a fo…
lawrencecchen Jul 1, 2026
37d6676
ci: focused-gate AgentHibernationPlannerTests to keep shard compositi…
lawrencecchen Jul 1, 2026
e6580b2
test: pin claude background-work detection (red)
lawrencecchen Jul 1, 2026
cf9ccee
fix: keep claude panes with live background work out of hibernation
lawrencecchen Jul 1, 2026
366889c
Merge remote-tracking branch 'origin/main' into feat-hibernation-all-…
lawrencecchen Jul 1, 2026
565e197
fix: treat genuine question prompts as hibernation-blocking
lawrencecchen Jul 1, 2026
fb7c61e
chore: satisfy Aziz package policy (docs + file-scope helpers)
lawrencecchen Jul 1, 2026
395b5b8
Merge remote-tracking branch 'origin/main' into feat-hibernation-all-…
lawrencecchen Jul 2, 2026
b0d05e7
Merge remote-tracking branch 'origin/main' into feat-hibernation-all-…
lawrencecchen Jul 2, 2026
3d3cc47
fix: claude Stop status pill shows Running while background work is live
lawrencecchen Jul 2, 2026
157ef0a
fix: blocking questions beat completion cues; malformed payloads fail…
lawrencecchen Jul 2, 2026
81badf1
chore: fold field-parse helper struct into a file-scope function
lawrencecchen Jul 2, 2026
9de2730
Merge origin/main (PR #7129 notify gating) into feat-hibernation-all-…
lawrencecchen Jul 3, 2026
f837b8d
Merge remote-tracking branch 'origin/main' into feat-hibernation-all-…
lawrencecchen Jul 3, 2026
454483b
Merge remote-tracking branch 'origin/main' into feat-hibernation-all-…
lawrencecchen Jul 4, 2026
2ed1744
perf: gate the per-record hibernation restorability debug log
lawrencecchen Jul 4, 2026
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
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34590 CLI/cmux.swift
34663 CLI/cmux.swift
17841 Sources/AppDelegate.swift
16132 Sources/ContentView.swift
13832 Sources/TerminalController.swift
Expand Down Expand Up @@ -48,7 +48,7 @@
2078 Sources/SessionPersistence.swift
1952 Sources/KeyboardShortcutSettingsFileStore.swift
1949 Sources/Panels/BrowserWebAuthnSupport.swift
1945 Sources/RestorableAgentSession.swift
1964 Sources/RestorableAgentSession.swift
1900 cmuxTests/NotificationAndMenuBarTests.swift
1810 Sources/SessionIndexStore.swift
1748 Sources/WindowDragHandleView.swift
Expand Down
131 changes: 102 additions & 29 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Substring "error" match risks false-positives that keep panes live when they should hibernate

lower.contains("error") will match benign informational messages an agent might send — e.g. "0 errors found", "error handling complete", "no error occurred" — and classify them as isBlocking: true. For agents (grok, antigravity) that use completion notifications as turn boundaries, a final "task finished with 0 errors" message would cause setAgentLifecycle(.needsInput) to fire after the Stop hook already wrote .idle, blocking hibernation. Tighter matching — anchoring on structured signal values or word-boundary matching for "error" — would eliminate the false positives without losing the real blocking signal.

#if DEBUG

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Top-level free function used as shared API

agentNotificationIsBlockingPrompt is a file-scope free function called by both the Claude hook lane and the generic agent-hook lane, and imported directly by the test suite via @testable import. The cmux no-ambient-global-state rule flags new top-level funcs used as API — the correct shape is either a private/fileprivate file-scope helper (passing to the rule) or a static func on a named classifier type (e.g., AgentNotificationClassifier) that owns the signal set and can be tested at the type boundary without widening the whole-module surface.

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!

Expand Down Expand Up @@ -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
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

let title = String(
Expand All @@ -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,
Expand Down Expand Up @@ -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]) ?? [:]
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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")
Expand All @@ -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") {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"),
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
def.displayName
Expand Down
17 changes: 17 additions & 0 deletions Sources/App/AgentHibernationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,23 @@ final class AgentHibernationController {
settings: settings,
now: nowTime
)
// Observability: hibernation used to fail silently — a restorable agent that
// never hibernated gave zero signal, making "why won't X hibernate" a manual
// spelunk. Emit one concise line per evaluation whenever there is anything to
// hibernate, broken down by the planner's exclusion reasons so the gap is
// diagnosable from the debug log. Only logged when restorable records exist,
// so it stays quiet on idle systems.
if !records.isEmpty {
let idleCount = plannerInputs.filter { $0.lifecycle.allowsHibernation }.count
let unconfirmedInput = plannerInputs.filter { $0.hasUnconfirmedTerminalInput }.count
let protectedCount = plannerInputs.filter { $0.isProtected }.count
cmuxDebugLog(
"agentHib.evaluate restorable=\(records.count) live=\(liveRestorableCount) "
+ "maxLive=\(settings.maxLiveTerminals) idle=\(idleCount) protected=\(protectedCount) "
+ "unconfirmedInput=\(unconfirmedInput) idleSeconds=\(Int(settings.idleSeconds)) "
+ "selected=\(selectedKeys.count)"
)
}
let currentKeys = Set(records.map(\.key))
pruneTrackingState(currentKeys: currentKeys, selectedKeys: selectedKeys)

Expand Down
21 changes: 20 additions & 1 deletion Sources/RestorableAgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

record.transcriptPath can include the user’s home/account config root and encoded project names; keep the breadcrumb but log only non-sensitive metadata.

🛡️ 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 Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RestorableAgentSession.swift` around lines 1281 - 1283, In the
cmuxDebugLog call for the agentHib.restorable.claudeTranscriptMissing message,
redact the sensitive transcript path information from record.transcriptPath.
Instead of logging the actual path value which may contain the user's home
directory or account config details, replace the transcriptPath parameter with a
non-sensitive indicator such as whether the path is nil or simply a generic
placeholder that preserves the log's usefulness without exposing sensitive file
system information. Keep the rest of the log message structure intact including
the session ID prefix and the isRestorable indicator.

Source: Coding guidelines

)
Comment on lines +1305 to +1308

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Full transcriptPath in debug log may include macOS username

record.transcriptPath expands to an absolute path under /Users/<username>/…. Logging it raw through cmuxDebugLog risks leaking a personally identifiable filesystem path to the unified log where it can be captured by Console.app or crash reporters. The session ID prefix already provides enough correlation signal; transcriptPath could be replaced with a redacted form (e.g., the last 2 path components) or a component count.

Rule Used: Flag production Swift diagnostics that bypass unif... (source)

return true
}
return false
}

private static func resolvedClaudeWorkflowRecord(
Expand Down
Loading
Loading