Skip to content
2 changes: 1 addition & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23428,7 +23428,7 @@ struct CMUXCLI {
surfaceId: surfaceId,
value: "Needs input",
icon: "bell.fill",
color: "#4C8DFF"
color: "#4C8DFF", pid: claudePid
)
let response = try sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client)
print(response)
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@
D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; };
C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; };
A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */; };
A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */; };
A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; };
A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; };
C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */; };
Expand Down Expand Up @@ -1267,6 +1268,7 @@
D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = "<group>"; };
C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeConfigDirectoryPathTests.swift; sourceTree = "<group>"; };
A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSurfaceResolutionSwiftTests.swift; sourceTree = "<group>"; };
A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationStatusLifecycleTests.swift; sourceTree = "<group>"; };
A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ClaudeStreamJSONAccumulator.swift; sourceTree = "<group>"; };
A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAuthAliasTests.swift; sourceTree = "<group>"; };
C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3220,6 +3222,7 @@
A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */,
A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */,
A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */,
A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */,
A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */,
6379A0026379A0026379A002 /* CLISSHPTYResizeInputTests.swift */,
C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */,
Expand Down Expand Up @@ -4435,6 +4438,7 @@
D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */,
C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */,
A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */,
A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */,
A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */,
C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */,
C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */,
Expand Down
27 changes: 27 additions & 0 deletions cmuxTests/AgentSessionAutoResumeSwiftTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import CmuxCore
import CmuxSidebar
import Testing

#if canImport(cmux_DEV)
Expand All @@ -10,6 +11,32 @@ import Testing

@Suite(.serialized)
struct AgentSessionAutoResumeSwiftTests {
@MainActor
@Test func sessionRestoreDropsPersistedAgentStatusRuntimeState() throws {
let source = Workspace()
let sourcePanelId = try #require(source.focusedPanelId)
let pidKey = "claude_code.issue-6441"

source.statusEntries["claude_code"] = SidebarStatusEntry(
key: "claude_code",
value: "Needs input"
)
source.recordAgentPID(key: pidKey, pid: 42_424, panelId: sourcePanelId, refreshPorts: false)

let snapshot = source.sessionSnapshot(includeScrollback: false)
#expect(snapshot.statusEntries.contains { $0.key == "claude_code" })

let restored = Workspace()
let restoredPanelIds = restored.restoreSessionSnapshot(snapshot)
let restoredPanelId = try #require(restoredPanelIds[sourcePanelId])

#expect(restored.statusEntries["claude_code"] == nil)
#expect(restored.agentPIDs.isEmpty)
#expect(restored.agentPIDPanelIdsByKey.isEmpty)
#expect(restored.agentPIDKeysByPanelId.isEmpty)
#expect(restored.agentHibernationLifecycleState(panelId: restoredPanelId, fallback: nil) == .unknown)
}

@MainActor
@Test func claudeAgentHookResumeBindingRestoresFromLaunchCwdWhenRuntimeCwdDrifted() throws {
try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) {
Expand Down
18 changes: 9 additions & 9 deletions cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,10 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
)
}

private struct ProcessRunResult { let status: Int32; let stdout: String; let stderr: String; let timedOut: Bool }
private typealias SurfaceFixture = (id: String, ref: String, focused: Bool)
struct ProcessRunResult { let status: Int32; let stdout: String; let stderr: String; let timedOut: Bool }
typealias SurfaceFixture = (id: String, ref: String, focused: Bool)

private struct ClaudeHookContext {
struct ClaudeHookContext {
let cliPath: String
let socketPath: String
let listenerFD: Int32
Expand All @@ -445,7 +445,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
}
}

private final class MockSocketServerState: @unchecked Sendable {
final class MockSocketServerState: @unchecked Sendable {
private let lock = NSLock()
private var commands: [String] = []

Expand All @@ -463,7 +463,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
}
}

private func makeClaudeHookContext(name: String) throws -> ClaudeHookContext {
func makeClaudeHookContext(name: String) throws -> ClaudeHookContext {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true)
let socketPath = makeSocketPath(String(name.prefix(6)))
Expand Down Expand Up @@ -597,7 +597,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
return handled
}

private func startClaudeSurfaceResolutionServer(
func startClaudeSurfaceResolutionServer(
context: ClaudeHookContext,
surfaces: [SurfaceFixture],
ttyName: String,
Expand Down Expand Up @@ -668,7 +668,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
}
}

private func claudeHookEnvironment(context: ClaudeHookContext, surfaceId: String, ttyName: String, storeURL: URL) -> [String: String] {
func claudeHookEnvironment(context: ClaudeHookContext, surfaceId: String, ttyName: String, storeURL: URL) -> [String: String] {
[
"HOME": context.root.path,
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
Expand Down Expand Up @@ -779,7 +779,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
)
}

private func assertSuccessfulHook(_ result: ProcessRunResult) {
func assertSuccessfulHook(_ result: ProcessRunResult) {
#expect(!result.timedOut, Comment(rawValue: result.stderr))
#expect(result.status == 0, Comment(rawValue: result.stderr))
#expect(result.stdout == "OK\n")
Expand Down Expand Up @@ -822,7 +822,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests {
return data.base64EncodedString()
}

private func runProcess(executablePath: String, arguments: [String], environment: [String: String], standardInput: String? = nil, timeout: TimeInterval) -> ProcessRunResult {
func runProcess(executablePath: String, arguments: [String], environment: [String: String], standardInput: String? = nil, timeout: TimeInterval) -> ProcessRunResult {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
Expand Down
52 changes: 52 additions & 0 deletions cmuxTests/ClaudeNotificationStatusLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Dispatch
import Foundation
import Testing

@Suite(.serialized)
struct ClaudeNotificationStatusLifecycleTests {
@Test func claudeNotificationStatusCarriesPIDForStaleSweep() throws {
let harness = ClaudeHookSurfaceResolutionSwiftTests()
let context = try harness.makeClaudeHookContext(name: "claude-notify-pid")
defer { context.cleanup() }

let claudePID = 42_424
let serverHandled = harness.startClaudeSurfaceResolutionServer(
context: context,
surfaces: [(context.surfaceId, "surface:1", true)],
ttyName: "ttys-claude-notify-pid",
ttySurfaceId: context.surfaceId
)

var environment = harness.claudeHookEnvironment(
context: context,
surfaceId: context.surfaceId,
ttyName: "ttys-claude-notify-pid",
storeURL: context.root.appendingPathComponent("claude-hook-sessions.json")
)
environment["CMUX_CLAUDE_PID"] = "\(claudePID)"

let result = harness.runProcess(
executablePath: context.cliPath,
arguments: ["hooks", "claude", "notification"],
environment: environment,
standardInput: #"{"session_id":"claude-notify-pid-session","cwd":"\#(context.root.path)","hook_event_name":"Notification","message":"Claude needs your input"}"#,
timeout: 5
)

#expect(serverHandled.wait(timeout: .now() + 5) == .success)
harness.assertSuccessfulHook(result)

let statusCommand = try #require(
context.state.snapshot().first {
$0.hasPrefix("set_status claude_code Needs input ")
&& $0.contains("--tab=\(context.workspaceId)")
&& $0.contains("--panel=\(context.surfaceId)")
},
"Expected Claude notification to set a Needs input status, saw \(context.state.snapshot())"
)
#expect(
statusCommand.contains("--pid=\(claudePID)"),
"Claude notification status must be PID-backed so the stale PID sweep can clear it after abrupt agent exit; command=\(statusCommand)"
)
}
}
Loading