Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0f8cbcf
Fix Agent Hibernation lifecycle for non-codex agents
lawrencecchen Jun 6, 2026
a6cb2d7
Fix test module import (cmux_DEV/cmux conditional)
lawrencecchen Jun 6, 2026
dca4feb
Fix hibernation one-shot: preserve agent lifecycle across resume/restart
lawrencecchen Jun 6, 2026
bee77a9
Fix test compile error: TimeInterval literal in planner test
lawrencecchen Jun 6, 2026
f271fee
tests: add Gemini lifecycle parity tests for hibernation one-shot fix
lawrencecchen Jun 10, 2026
8d52999
ci: trigger CI on latest Gemini test commit
lawrencecchen Jun 10, 2026
4b3e386
tests: P1+P2 regression tests for agent hibernation lifecycle clobber…
lawrencecchen Jun 10, 2026
8af0503
fix(hibernation): P1+P2 lifecycle clobber bugs for Claude and generic…
lawrencecchen Jun 10, 2026
53666c9
fix(hibernation): only advance lifecycleChangeAt when stored lifecycl…
lawrencecchen Jun 10, 2026
0cff7a1
fix: use lifecycleStatusKey instead of rawValue in resumeAgentHiberna…
lawrencecchen Jun 11, 2026
ee65659
fix: guard idle-seed against queuedStartupInput timing race; update s…
lawrencecchen Jun 11, 2026
17fdfd2
fix: advance lifecycleChangeAt for repeated definitive lifecycle updates
lawrencecchen Jun 11, 2026
cf4876c
test: Claude error/attention notifications must not set idle lifecycle
lawrencecchen Jun 11, 2026
32641f6
fix: classify Claude error notifications as needsInput, attention as …
lawrencecchen Jun 11, 2026
90fbf01
fix: durable terminal-input guard prevents stale-idle hibernation aft…
lawrencecchen Jun 11, 2026
3bcf06b
AgentHibernation: fix mid-turn guard cleared by SessionStart upsert
lawrencecchen Jun 11, 2026
2fc6456
AgentHibernation: resolved() blocks on unknown, clarify setAgentLifec…
lawrencecchen Jun 11, 2026
e07eb36
AgentHibernation: fix lifecycleUpdatedAt default, align resolved() test
lawrencecchen Jun 11, 2026
23b1859
AgentHibernation: replace asyncAfter with DispatchSourceTimer for dur…
lawrencecchen Jun 11, 2026
cec7573
setAgentLifecycle: scope preservingDefinitive to hook store only; unk…
lawrencecchen Jun 11, 2026
20022bd
Hibernation: fix notification-derived idle baseline + durable-store m…
lawrencecchen Jun 11, 2026
944e660
Hibernation: scope durable input store per bundle ID, not shared file
lawrencecchen Jun 11, 2026
996a5e1
Hibernation: create ~/.cmuxterm before writing durable input store
lawrencecchen Jun 11, 2026
83a5ec7
fix: scope preserveIdle to SessionStart and make startup-window input…
lawrencecchen Jun 12, 2026
cc0e5c9
test: migrate AgentHibernationTests to Swift Testing
lawrencecchen Jun 12, 2026
64c1b6b
fix: preserve-idle SessionStart must not advance in-memory lifecycleC…
lawrencecchen Jun 12, 2026
a0edb86
test: serialize AgentHibernationTests suite to match XCTestCase seria…
lawrencecchen Jun 12, 2026
6e56614
fix: synchronously flush durable input store on stop() to survive nor…
lawrencecchen Jun 12, 2026
6f57578
fix: add concurrency policy justification for timerQueue.sync in stop()
lawrencecchen Jun 12, 2026
004e297
Merge remote-tracking branch 'origin/main' into feat-hibernate-all-ag…
lawrencecchen Jun 12, 2026
b10ae36
Fix pbxproj UUID collision from merge (AgentHibernationLifecycleResol…
lawrencecchen Jun 12, 2026
7eac4ce
Refresh Swift file-length budget for hibernation files after main merge
lawrencecchen Jun 12, 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
11 changes: 6 additions & 5 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 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.
33285 CLI/cmux.swift
19985 Sources/Workspace.swift
33368 CLI/cmux.swift
20028 Sources/Workspace.swift
19265 Sources/ContentView.swift
18118 Sources/AppDelegate.swift
16674 Sources/GhosttyTerminalView.swift
Expand All @@ -22,13 +22,13 @@
5482 cmuxTests/BrowserConfigTests.swift
5462 Sources/cmuxApp.swift
4827 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
4532 cmuxTests/CLIGenericHookPersistenceTests.swift
4460 Sources/Panels/FilePreviewPanel.swift
4400 cmuxTests/BrowserPanelTests.swift
4227 Sources/BrowserWindowPortal.swift
4009 cmuxTests/WindowAndDragTests.swift
3937 Sources/Feed/FeedPanelView.swift
3760 cmuxTests/TabManagerUnitTests.swift
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
3665 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3396 Sources/CmuxConfig.swift
3316 cmuxTests/TabManagerSessionSnapshotTests.swift
Expand All @@ -51,8 +51,8 @@
1949 Sources/Panels/BrowserWebAuthnSupport.swift
1860 cmuxTests/NotificationAndMenuBarTests.swift
1793 Sources/SessionIndexStore.swift
1771 Sources/RestorableAgentSession.swift
1751 Sources/WindowDragHandleView.swift
1744 Sources/RestorableAgentSession.swift
1721 cmuxTests/TerminalControllerSocketSecurityTests.swift
1693 cmuxTests/WorkspacePullRequestSidebarTests.swift
1677 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
Expand All @@ -66,6 +66,7 @@
1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift
1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift
1372 cmuxTests/AppDelegateIssue2907RoutingTests.swift
1367 cmuxTests/AgentHibernationTests.swift
1365 Sources/Feed/FeedButtonStyleDebugWindowController.swift
1362 Sources/CMUXInstalledExtensionSidebarHostView.swift
1313 cmuxTests/MobileHostAuthorizationTests.swift
Expand All @@ -80,7 +81,6 @@
1107 Sources/AppDelegate+CmuxSSHURL.swift
1096 Sources/GhosttyConfig.swift
1093 cmuxUITests/BonsplitTabDragUITests.swift
1084 cmuxTests/AgentHibernationTests.swift
1084 cmuxTests/RestorableAgentSessionIndexTests.swift
1021 cmuxUITests/TerminalCmdClickUITests.swift
1006 cmuxTests/CmuxSSHURLRequestTests.swift
Expand Down Expand Up @@ -153,6 +153,7 @@
588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift
586 Sources/JSONCParser.swift
585 Sources/Cloud/VMClient.swift
582 Sources/App/AgentHibernationController.swift
580 Packages/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift
580 cmuxTests/CLIHookNoResponseTests.swift
578 cmuxUITests/FeedSidebarUITests.swift
Expand Down
117 changes: 100 additions & 17 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
)
// 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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude idle skips running guard

Medium Severity

Claude hibernation lifecycle treats completion-style notifications as idle but only refuses to downgrade an existing stored needsInput. If the hook store still has running (e.g. notification before Stop), the upsert can persist idle and push live idle, making a mid-turn agent hibernation-eligible.

Fix in Cursor Fix in Web

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
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude lacks newer-session guard

Medium Severity

When Claude’s notification handler sets hibernation lifecycle to idle, it does not use the generic hook’s hasNewerRunningSession checks. A stale idle-classified notification for an older session can persist and publish idle while another session on the same surface is still running, incorrectly allowing hibernation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7eac4ce. Configure here.

}
_ = try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ extension ControlCommandCoordinator {
/// `set_agent_lifecycle` — record a restorable agent session's lifecycle.
func sidebarSetAgentLifecycle(_ args: String) -> String {
let parsed = sidebarParseOptions(args)
let usage = "set_agent_lifecycle <key> <unknown|running|idle|needsInput> [--tab=<id>] [--panel=<id>]"
let usage = "set_agent_lifecycle <key> <unknown|running|idle|needsInput> [--tab=<id>] [--panel=<id>] [--preserve-idle]"
guard parsed.positional.count >= 2 else {
return "ERROR: Usage: \(usage)"
}
Expand All @@ -283,11 +283,16 @@ extension ControlCommandCoordinator {
) ?? false else {
return "ERROR: Unsupported agent lifecycle key '\(key)'"
}
// --preserve-idle: apply preservingDefinitive so a `.unknown` SessionStart
// cannot overwrite a resume-seeded `.idle`. Only the SessionStart hook path
// in the CLI sets this flag; direct callers omit it and get the raw value.
let preserveIdle = parsed.options["preserve-idle"] != nil
sidebarContext?.controlSidebarScheduleAgentLifecycle(
target: target,
key: key,
lifecycleRawValue: lifecycleRawValue,
panelID: panelResolution.panelId
panelID: panelResolution.panelId,
preserveIdle: preserveIdle
)
return "OK"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,15 @@ public protocol ControlSidebarContext: AnyObject {
panelID: UUID?
) -> Bool

/// Enqueues the `set_agent_lifecycle` mutation.
/// Enqueues the `set_agent_lifecycle` mutation. `preserveIdle` mirrors the
/// `--preserve-idle` flag: when true the app applies `preservingDefinitive`
/// so a `.unknown` SessionStart cannot clobber a resume-seeded `.idle`.
func controlSidebarScheduleAgentLifecycle(
target: ControlSidebarTabTarget,
key: String,
lifecycleRawValue: String,
panelID: UUID?
panelID: UUID?,
preserveIdle: Bool
)

/// Applies the `agent_hibernation` global toggle.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ extension ControlSidebarContext {
target: ControlSidebarTabTarget,
key: String,
lifecycleRawValue: String,
panelID: UUID?
panelID: UUID?,
preserveIdle: Bool
) {}

func controlSidebarSetAgentHibernation(enabled: Bool) {}
Expand Down
Loading
Loading