Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
172 changes: 136 additions & 36 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,46 @@ private final class ClaudeHookSessionStore {
}
}

/// Gate for Claude CLEARING transitions (stop->idle, prompt-submit->running,
/// pre-tool-use->running). Equivalent to `isCurrent`, except that when an event matches the
/// active session but its `turnId` has drifted (a Notification leaves the active turn pointing
/// at the prior prompt), it still returns true IF that session is currently stuck on
/// `.needsInput` -- so a follow-up clearing event can drop a stale "Needs input" badge. A
/// DIFFERENT-session event always fails closed (`active.sessionId == sessionId` is required).
/// The whole decision is taken under a SINGLE `withLockedState` (the `isCurrent` predicate is
/// inlined) so the turn-identity check and the `needsInput` check can't race a concurrent
/// session update across two lock windows. Fixes https://github.com/manaflow-ai/cmux/issues/1027.
func isCurrentOrClearsStaleNeedsInput(
sessionId: String?,
workspaceId: String,
turnId: String? = nil
) throws -> Bool {
guard let normalizedSessionId = normalizeOptional(sessionId),
let normalizedWorkspace = normalizeOptional(workspaceId) else {
// Matches isCurrent's fail-open: an event that can't identify a session/workspace is
// treated as current.
return true
}
let normalizedTurnId = normalizeOptional(turnId)
return try withLockedState { state in
guard let active = state.activeSessionsByWorkspace[normalizedWorkspace] else {
return true
}
guard active.sessionId == normalizedSessionId else {
return false
Comment thread
RubiconPerform marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
RubiconPerform marked this conversation as resolved.
guard let activeTurnId = normalizeOptional(active.turnId),
let normalizedTurnId else {
Comment thread
RubiconPerform marked this conversation as resolved.
return true
}
if activeTurnId == normalizedTurnId {
return true
}
// Same session, but the turnId drifted: still clear a stale "Needs input" badge.
return state.sessions[normalizedSessionId]?.agentLifecycle == .needsInput
Comment on lines +1255 to +1256

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve turn guard for older same-session events

When the current Claude session is waiting for input on a newer active turn, a late hook from an older turn in the same session now passes this relaxed gate solely because the session lifecycle is .needsInput. For example, with active turn-2 showing a permission prompt, a delayed Stop for turn-1 will be treated as current and the stop handler will write idle/mark the session active, clearing the real current prompt; the previous strict isCurrent check was the protection against this same-session stale-turn case. The relaxation needs some way to distinguish the intended newer drift from older stale turns before ignoring the turn mismatch.

Useful? React with 👍 / 👎.

}
}

func canReplaceActiveSession(sessionId: String?, workspaceId: String) throws -> Bool {
guard let normalizedSessionId = normalizeOptional(sessionId),
let normalizedWorkspace = normalizeOptional(workspaceId) else {
Expand Down Expand Up @@ -21727,7 +21767,11 @@ struct CMUXCLI {
)
sendClaudeFeedTelemetry(workspaceId: workspaceId)

guard shouldApplyClaudeHookVisibleMutation(
// Stop is a CLEARING transition (-> idle). Use the relaxed gate so a stale
// same-session "Needs input" is cleared even when this Stop's turnId drifted from
// the active turn (https://github.com/manaflow-ai/cmux/issues/1027). A
// different-session Stop still fails closed.
guard shouldApplyClaudeHookClearingMutation(
sessionStore: sessionStore,
parsedInput: parsedInput,
workspaceId: workspaceId,
Expand Down Expand Up @@ -21828,8 +21872,12 @@ struct CMUXCLI {
env: ProcessInfo.processInfo.environment
)
sendClaudeFeedTelemetry(workspaceId: workspaceId)
// prompt-submit is a CLEARING transition (-> running). Use the relaxed gate so a stale
// same-session "Needs input" is cleared even when this prompt's turnId drifted from the
// active turn (https://github.com/manaflow-ai/cmux/issues/1027). A different-session
// event still fails closed (and the stopped-session replacement path is unchanged).
let shouldApplyPromptSubmit =
shouldApplyClaudeHookVisibleMutation(
shouldApplyClaudeHookClearingMutation(
sessionStore: sessionStore,
parsedInput: parsedInput,
workspaceId: workspaceId,
Expand Down Expand Up @@ -22070,7 +22118,60 @@ struct CMUXCLI {
currentAgentPID: claudePid,
env: ProcessInfo.processInfo.environment
)
guard shouldApplyClaudeHookVisibleMutation(
// AskUserQuestion means Claude is about to ask the user something: it is a needsInput
// SETTING event, not a clearing one. Handle it BEFORE the relaxed clearing gate and
// always return, so a drifted/stale AskUserQuestion can never fall through to the
// running path below and wipe a live "Needs input"
// (https://github.com/manaflow-ai/cmux/issues/1027). Raising needsInput stays on the
// STRICT gate: only a current event may set it; a stale one simply leaves state as-is.
if let toolName = parsedInput.object?["tool_name"] as? String,
toolName == "AskUserQuestion" {
if !suppressVisibleMutations,
let question = describeAskUserQuestion(parsedInput.object),
let sessionId = parsedInput.sessionId,
shouldApplyClaudeHookVisibleMutation(
sessionStore: sessionStore,
parsedInput: parsedInput,
workspaceId: workspaceId,
telemetry: telemetry
) {
// Save question text in session so the Notification handler can use it
// instead of the generic "Claude Code needs your attention".
// Preserve a non-empty surfaceId from SessionStart; passing ""
// would overwrite it and cause notifications to target the wrong workspace.
let existingSurfaceId = nonEmptyClaudeHookIdentifier(mappedSession?.surfaceId) ?? surfaceId
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
surfaceId: existingSurfaceId,
cwd: parsedInput.cwd,
transcriptPath: parsedInput.transcriptPath,
agentLifecycle: .needsInput,
lastSubtitle: String(localized: "agent.claude.input.subtitle.waiting", defaultValue: "Waiting"),
lastBody: question
)
setAgentLifecycle(
client: client,
key: Self.claudeCodeStatusKey,
lifecycle: .needsInput,
workspaceId: workspaceId,
surfaceId: existingSurfaceId
)
// Don't clear notifications or set status here.
// The Notification hook fires right after and will use the saved question.
} else {
telemetry.breadcrumb("claude-hook.pre-tool-use.ask-user-question.skipped")
}
print("OK")
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update active turn after drifted pre-tool-use

When this relaxed gate admits a same-session pre-tool-use whose turnId drifted, the handler clears the lifecycle to running but never advances activeSessionsByWorkspace to parsedInput.turnId (unlike prompt-submit, which upserts with markActive: true and the new turn). In the repaired flow prompt-submit(turn-1) -> Notification(needsInput) -> pre-tool-use(turn-2), the active turn remains turn-1, so a later strict-gated Notification or AskUserQuestion for turn-2 is treated as stale and the next permission/question prompt will not surface.

Useful? React with 👍 / 👎.


// A non-AskUserQuestion pre-tool-use means Claude resumed work, so this is a CLEARING
// transition (-> running). Use the relaxed gate so a stale same-session "Needs input"
// is cleared even when this event's turnId drifted from the active turn
// (https://github.com/manaflow-ai/cmux/issues/1027). A different-session event still
// fails closed.
guard shouldApplyClaudeHookClearingMutation(
sessionStore: sessionStore,
parsedInput: parsedInput,
workspaceId: workspaceId,
Expand All @@ -22086,39 +22187,6 @@ struct CMUXCLI {
return
}

// AskUserQuestion means Claude is about to ask the user something.
// Save question text in session so the Notification handler can use it
// instead of the generic "Claude Code needs your attention".
if let toolName = parsedInput.object?["tool_name"] as? String,
toolName == "AskUserQuestion",
let question = describeAskUserQuestion(parsedInput.object),
let sessionId = parsedInput.sessionId {
// Preserve a non-empty surfaceId from SessionStart; passing ""
// would overwrite it and cause notifications to target the wrong workspace.
let existingSurfaceId = nonEmptyClaudeHookIdentifier(mappedSession?.surfaceId) ?? surfaceId
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: existingSurfaceId,
cwd: parsedInput.cwd,
transcriptPath: parsedInput.transcriptPath,
agentLifecycle: .needsInput,
lastSubtitle: "Waiting",
lastBody: question
)
setAgentLifecycle(
client: client,
key: Self.claudeCodeStatusKey,
lifecycle: .needsInput,
workspaceId: workspaceId,
surfaceId: existingSurfaceId
)
// Don't clear notifications or set status here.
// The Notification hook fires right after and will use the saved question.
print("OK")
return
}

if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
Expand Down Expand Up @@ -22313,6 +22381,38 @@ struct CMUXCLI {
}
}

/// Gate for Claude CLEARING transitions only. Identical to
/// `shouldApplyClaudeHookVisibleMutation` except it also applies when the SAME active session
/// is stuck on `.needsInput` after the follow-up event's `turnId` drifted, so a stale
/// "Needs input" badge is cleared instead of being stranded. A different-session event still
/// fails closed via `isCurrentOrClearsStaleNeedsInput`. See
/// https://github.com/manaflow-ai/cmux/issues/1027.
private func shouldApplyClaudeHookClearingMutation(
sessionStore: ClaudeHookSessionStore,
parsedInput: ClaudeHookParsedInput,
workspaceId: String,
telemetry: CLISocketSentryTelemetry
) -> Bool {
do {
return try sessionStore.isCurrentOrClearsStaleNeedsInput(
Comment thread
RubiconPerform marked this conversation as resolved.
sessionId: parsedInput.sessionId,
workspaceId: workspaceId,
Comment on lines 22381 to 22418

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 Symptom patch that leaves the bad state representable

The root cause is that the Notification handler sets needsInput but does not advance active.turnId, so every subsequent event arrives with a drifted turn ID and the strict gate drops it. This PR introduces a new side-channel gate (isCurrentOrClearsStaleNeedsInput) and a parallel wrapper (shouldApplyClaudeHookClearingMutation) that work around the dropped mutations after the fact. A minimal alternative that names the invariant: in the Notification handler, carry forward the event's turnId so active.turnId advances to match, keeping isCurrent as the single gate for all transitions.

Rule Used: Flag Swift fixes that patch symptoms while leaving... (source)

turnId: parsedInput.turnId
)
} catch {
telemetry.breadcrumb(
"claude-hook.clearing-gate.error",
data: [
"error": String(describing: error),
"session_id": parsedInput.sessionId ?? "",
"workspace_id": workspaceId,
"turn_id": parsedInput.turnId ?? "",
]
)
return true
}
}

private func shouldReplaceStoppedClaudeSession(
sessionStore: ClaudeHookSessionStore,
parsedInput: ClaudeHookParsedInput,
Expand Down
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -188821,6 +188821,23 @@
}
}
}
},
"agent.claude.input.subtitle.waiting": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Waiting"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "待機中"
}
}
}
}
}
}
Loading