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
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.
33285 CLI/cmux.swift
33346 CLI/cmux.swift
19985 Sources/Workspace.swift
19265 Sources/ContentView.swift
18118 Sources/AppDelegate.swift
Expand All @@ -10,7 +10,7 @@
13606 Sources/Panels/BrowserPanel.swift
12044 cmuxTests/AppDelegateShortcutRoutingTests.swift
10020 Sources/TabManager.swift
9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
9389 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7737 Sources/Panels/BrowserPanelView.swift
7291 cmuxTests/WorkspaceUnitTests.swift
6948 cmuxTests/WorkspaceRemoteConnectionTests.swift
Expand Down
111 changes: 86 additions & 25 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22158,6 +22158,15 @@ struct CMUXCLI {
)
let shouldPromoteActiveSession = !isForkSessionLaunch && (isClearSessionStart || canReplaceStoppedSession)
if let sessionId = parsedInput.sessionId, !isForkSessionLaunch {
let mappedSession = try? sessionStore.lookup(sessionId: sessionId)
let canPublishResumeBinding = shouldPublishClaudeResumeBinding(
sessionId: sessionId,
parsedInput: parsedInput,
mappedSession: mappedSession,
env: ProcessInfo.processInfo.environment,
fallbackPID: claudePid
)
let shouldPromoteThisSession = shouldPromoteActiveSession && canPublishResumeBinding
// Non-clear SessionStart can arrive late from startup/resume/compact
// after /clear, so only /clear or replacement of a stopped owner
// establishes a new active boundary.
Expand All @@ -22170,11 +22179,11 @@ struct CMUXCLI {
pid: claudePid,
launchCommand: launchCommand,
isRestorable: false,
agentLifecycle: shouldPromoteActiveSession ? .running : .unknown,
markActive: shouldPromoteActiveSession,
agentLifecycle: shouldPromoteThisSession ? .running : .unknown,
markActive: shouldPromoteThisSession,
turnId: parsedInput.turnId
)
if shouldPromoteActiveSession {
if shouldPromoteThisSession {
publishAgentSurfaceResumeBinding(
client: client,
workspaceId: workspaceId,
Expand Down Expand Up @@ -22283,29 +22292,38 @@ struct CMUXCLI {
sessionRecord: mappedSession
)
if let sessionId = parsedInput.sessionId {
let canPublishResumeBinding = shouldPublishClaudeResumeBinding(
sessionId: sessionId,
parsedInput: parsedInput,
mappedSession: mappedSession,
env: ProcessInfo.processInfo.environment,
fallbackPID: claudePid
)
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd,
transcriptPath: parsedInput.transcriptPath,
isRestorable: true,
isRestorable: canPublishResumeBinding,
agentLifecycle: .idle,
lastSubtitle: completion?.subtitle,
lastBody: completion?.body,
markActive: true,
allowsNewSessionReplacement: true
)
publishAgentSurfaceResumeBinding(
client: client,
workspaceId: workspaceId,
surfaceId: surfaceId,
kind: "claude",
displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"),
sessionId: sessionId,
cwd: parsedInput.cwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand
)
if canPublishResumeBinding {
publishAgentSurfaceResumeBinding(
client: client,
workspaceId: workspaceId,
surfaceId: surfaceId,
kind: "claude",
displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"),
sessionId: sessionId,
cwd: parsedInput.cwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand
)
}
}

setAgentLifecycle(
Expand Down Expand Up @@ -22402,6 +22420,13 @@ struct CMUXCLI {
cwd: parsedInput.cwd
)
: nil
let canPublishResumeBinding = shouldPublishClaudeResumeBinding(
sessionId: sessionId,
parsedInput: parsedInput,
mappedSession: mappedSession,
env: ProcessInfo.processInfo.environment,
fallbackPID: claudePid
)
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
Expand All @@ -22410,21 +22435,23 @@ struct CMUXCLI {
transcriptPath: parsedInput.transcriptPath,
pid: mappedSession == nil ? claudePid : nil,
launchCommand: firstSightingLaunchCommand,
isRestorable: true,
isRestorable: canPublishResumeBinding,
agentLifecycle: .running,
markActive: true,
turnId: parsedInput.turnId
)
publishAgentSurfaceResumeBinding(
client: client,
workspaceId: workspaceId,
surfaceId: surfaceId,
kind: "claude",
displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"),
sessionId: sessionId,
cwd: parsedInput.cwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand
)
if canPublishResumeBinding {
publishAgentSurfaceResumeBinding(
client: client,
workspaceId: workspaceId,
surfaceId: surfaceId,
kind: "claude",
displayName: String(localized: "cli.claude-hook.notification.title", defaultValue: "Claude Code"),
sessionId: sessionId,
cwd: parsedInput.cwd ?? mappedSession?.cwd,
launchCommand: mappedSession?.launchCommand ?? firstSightingLaunchCommand
)
}
}
_ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
setAgentLifecycle(
Expand Down Expand Up @@ -22944,6 +22971,40 @@ struct CMUXCLI {
}
}

private func shouldPublishClaudeResumeBinding(
sessionId: String,
parsedInput: ClaudeHookParsedInput,
mappedSession: ClaudeHookSessionRecord?,
env: [String: String],
fallbackPID: Int?
) -> Bool {
guard let normalizedSessionId = normalizedHookValue(sessionId) else { return false }
if normalizedHookValue(parsedInput.transcriptPath ?? mappedSession?.transcriptPath) != nil {
return true
}
if let resumedSessionId = claudeResumeLaunchSessionId(env: env, fallbackPID: fallbackPID),
resumedSessionId == normalizedSessionId {
return false
}
return true
Comment on lines +22985 to +22989

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 Guard is fail-open when launch argv is unavailable

claudeResumeLaunchSessionId returns nil if CMUX_AGENT_LAUNCH_ARGV_B64 is absent and processArguments(for:) cannot read the PID (e.g., claudePid is nil). When both fallbacks fail, the code skips the resume-id comparison and falls through to return true, publishing the binding for a failed resume — the exact outcome the PR is trying to prevent. The bug is only fully suppressed when at least one argv source is reliably available on the hook invocation path.

}

private func claudeResumeLaunchSessionId(env: [String: String], fallbackPID: Int?) -> String? {
guard let arguments = claudeRawLaunchArguments(env: env, fallbackPID: fallbackPID) else {
return nil
}
Comment thread
cursor[bot] marked this conversation as resolved.
for (index, argument) in arguments.enumerated() {
if argument == "--resume" || argument == "-r" {
guard index + 1 < arguments.count else { return nil }
return normalizedHookValue(arguments[index + 1])
}
for prefix in ["--resume=", "-r="] where argument.hasPrefix(prefix) {
return normalizedHookValue(String(argument.dropFirst(prefix.count)))
}
}
return nil
}
Comment on lines +22974 to +23006

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does Claude CLI support multiple --resume flags, and if so which one takes precedence?

💡 Result:

The Claude CLI does not support passing multiple --resume flags in a single command. The --resume flag is designed to accept a single argument—either a specific session ID, a session name, or no argument at all to trigger an interactive session picker [1][2]. If you attempt to provide multiple --resume flags, it is not a supported use case, and the behavior is not explicitly defined in the official documentation [1][2]. To resume a session, you should use the flag once, followed by the desired session identifier [1][2]: claude --resume If you need to switch between sessions, you can use the interactive /resume picker within an active session or run the command without arguments to open the picker [2]. Additionally, you can use --continue to resume the most recent session in the current directory [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file
ls -la

# Search for CLI/cmux.swift existence
if [ -f "CLI/cmux.swift" ]; then
  echo "FOUND: CLI/cmux.swift"
else
  echo "NOT FOUND: CLI/cmux.swift; searching..."
  fd -a "cmux.swift" -t f
fi

# Print relevant regions for resume parsing logic
rg -n --no-heading "claudeResumeLaunchSessionId|--resume|\\-r\\b" CLI/cmux.swift || true

# Show surrounding code for the function
if [ -f "CLI/cmux.swift" ]; then
  line=$(rg -n "claudeResumeLaunchSessionId" CLI/cmux.swift | head -n1 | cut -d: -f1 || true)
  if [ -n "${line:-}" ]; then
    start=$((line-80))
    end=$((line+220))
    echo "---- Function region (approx) lines ${start}-${end} ----"
    nl -ba CLI/cmux.swift | sed -n "${start},${end}p"
  fi

  echo "---- All nearby resume parsing blocks (context) ----"
  rg -n --context 6 --no-heading "if argument == \"--resume\" \\|\\| argument == \"-r\"|hasPrefix\\(\"--resume=\"\\)|hasPrefix\\(\"-r=\"\\)|\\[\"--resume=\", \"-r=\"\\]" CLI/cmux.swift || true
fi

Repository: manaflow-ai/cmux

Length of output: 6963


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if [ -f "CLI/cmux.swift" ]; then
  echo "FOUND: CLI/cmux.swift"
else
  echo "NOT FOUND: CLI/cmux.swift"
  fd -a -t f -e swift "cmux.swift"
fi

if [ -f "CLI/cmux.swift" ]; then
  rg -n --no-heading "claudeResumeLaunchSessionId|--resume|-r\\b" CLI/cmux.swift || true

  line=$(rg -n "claudeResumeLaunchSessionId" CLI/cmux.swift | head -n1 | cut -d: -f1 || true)
  if [ -n "${line:-}" ]; then
    start=$((line-80))
    end=$((line+260))
    echo "---- Function region (approx) lines ${start}-${end} ----"
    nl -ba CLI/cmux.swift | sed -n "${start},${end}p"
  fi

  echo "---- Candidate resume parsing blocks with context ----"
  rg -n --context 8 --no-heading "resume|--resume|\\-r\\b|normalizedHookValue" CLI/cmux.swift | head -n 200
fi

Repository: manaflow-ai/cmux

Length of output: 1651


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify file and inspect relevant resume parsing blocks in CLI/cmux.swift
if [ -f "CLI/cmux.swift" ]; then
  echo "FOUND: CLI/cmux.swift"
else
  echo "NOT FOUND: CLI/cmux.swift"
  fd -a -t f "cmux.swift"
  exit 0
fi

echo "---- resume-related matches ----"
rg -n --no-heading "--resume|\\-r\\b|claudeResumeLaunchSessionId|normalizedHookValue" CLI/cmux.swift

echo "---- showing function claudeResumeLaunchSessionId (approx) ----"
fn_line=$(rg -n --no-heading "claudeResumeLaunchSessionId" CLI/cmux.swift | head -n1 | cut -d: -f1)
start=$((fn_line-60))
end=$((fn_line+320))
nl -ba CLI/cmux.swift | sed -n "${start},${end}p" | sed -n '1,260p'

echo "---- showing any other occurrences of '--resume'/'-r' parsing (context) ----"
rg -n --context 12 --no-heading "if argument == \"--resume\" \\|\\| argument == \"-r\"|hasPrefix\\(\"--resume=\"\\)|hasPrefix\\(\"-r=\"\\)" CLI/cmux.swift

Repository: manaflow-ai/cmux

Length of output: 598


Use first --resume occurrence; make prefix list a constant; check -r= consistency

  • Keep the current precedence in claudeResumeLaunchSessionId: return immediately on the first --resume/-r match (and first --resume=/-r= prefix match).
  • In CLI/cmux.swift there’s a second resume parsing block around line ~25920: it checks hasPrefix("--resume=") but no corresponding hasPrefix("-r=") match is present—align support there if -r= should behave the same.
  • Refactor the ["--resume=", "-r="] prefix list in claudeResumeLaunchSessionId into a static constant so it isn’t recreated during the scan.
🤖 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 `@CLI/cmux.swift` around lines 22974 - 23006, The resume parsing should return
on the first match and avoid recreating the prefix list each iteration: in
claudeResumeLaunchSessionId use a static constant for the prefixes (e.g., let
resumePrefixes = ["--resume=", "-r="]) and iterate using that constant, ensuring
you return immediately for the first "--resume"/"-r" positional match or the
first prefix match; also update the other resume parsing block (the second parse
near the later code) to include "-r=" in its hasPrefix checks so short-form
equals-style flags are handled consistently; keep existing calls to
claudeRawLaunchArguments, normalizedHookValue and the early-return behavior in
shouldPublishClaudeResumeBinding unchanged.


private func isClaudeClearSessionStart(_ parsedInput: ClaudeHookParsedInput) -> Bool {
guard let source = parsedInput.object?["source"] as? String else {
return false
Expand Down
54 changes: 49 additions & 5 deletions cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,32 +402,33 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
func testClaudePromptSubmitFromNewSessionCanReplaceStoppedSession() throws {
let context = try makeClaudeHookContext(name: "claude-new-session-after-stop")
defer { context.cleanup() }
startAgentHookMockServerAccepting(context: context, connectionLimit: 32)

let oldStart = runClaudeHook(
let oldStart = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "session-start"],
standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#
)
XCTAssertFalse(oldStart.timedOut, oldStart.stderr)
XCTAssertEqual(oldStart.status, 0, oldStart.stderr)

let oldPrompt = runClaudeHook(
let oldPrompt = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "prompt-submit"],
standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"#
)
XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr)
XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr)

let oldStop = runClaudeHook(
let oldStop = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "stop"],
standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old turn finished"}"#
)
XCTAssertFalse(oldStop.timedOut, oldStop.stderr)
XCTAssertEqual(oldStop.status, 0, oldStop.stderr)

let newStart = runClaudeHook(
let newStart = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "session-start"],
standardInput: #"{"session_id":"new-session","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#
Expand All @@ -436,7 +437,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
XCTAssertEqual(newStart.status, 0, newStart.stderr)

let newPromptStart = context.state.commands.count
let newPrompt = runClaudeHook(
let newPrompt = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "prompt-submit"],
standardInput: #"{"session_id":"new-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"#
Expand All @@ -453,6 +454,47 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
)
}

func testClaudeFailedResumePromptDoesNotPublishSurfaceResumeBinding() throws {
let context = try makeClaudeHookContext(name: "claude-failed-resume-binding")
defer { context.cleanup() }
startAgentHookMockServerAccepting(context: context, connectionLimit: 8)

let missingSessionId = "932d910e-b979-4583-bf08-66285e5514ce"
let resumeEnvironment = agentLaunchEnvironment(
context: context,
kind: "claude",
executable: "/usr/local/bin/claude",
arguments: ["/usr/local/bin/claude", "--resume", missingSessionId, "--dangerously-skip-permissions"]
)
let start = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "session-start"],
standardInput: #"{"session_id":"\#(missingSessionId)","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#,
extraEnvironment: resumeEnvironment
)
XCTAssertFalse(start.timedOut, start.stderr)
XCTAssertEqual(start.status, 0, start.stderr)

let prompt = runClaudeHookWithoutServer(
context: context,
arguments: ["hooks", "claude", "prompt-submit"],
standardInput: #"{"session_id":"\#(missingSessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"resume"}"#,
extraEnvironment: resumeEnvironment
)
XCTAssertFalse(prompt.timedOut, prompt.stderr)
XCTAssertEqual(prompt.status, 0, prompt.stderr)

XCTAssertFalse(
context.state.commands.contains { command in
guard let payload = jsonObject(command) else { return false }
return payload["method"] as? String == "surface.resume.set"
},
"A failed first-sighting Claude resume must not replace the pane's durable resume binding, saw \(context.state.commands)"
)
let session = try readClaudeHookSession(missingSessionId, context: context)
XCTAssertEqual(session["isRestorable"] as? Bool, false)
}

// MARK: - Forked conversation restore (https://github.com/manaflow-ai/cmux/issues/5908)
//
// `claude --resume <parent> --fork-session` fires SessionStart with the PARENT
Expand Down Expand Up @@ -8666,6 +8708,8 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase {
return self.surfaceListResponse(id: id, surfaceId: context.surfaceId)
case "feed.push":
return self.v2Response(id: id, ok: true, result: [:])
case "surface.resume.set":
return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]])
case "surface.resume.clear":
return self.v2Response(id: id, ok: true, result: ["cleared": true])
default:
Expand Down
Loading