Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
a02fc02
test: cover Claude no-flicker stale surface status
austinywang Jun 13, 2026
34c734e
fix: route Claude hook status through owning pane
austinywang Jun 13, 2026
d20e8b1
ci: refresh Swift file length budget
austinywang Jun 13, 2026
326c188
fix: avoid PID snapshot during Claude cleanup routing
austinywang Jun 13, 2026
bcaab63
fix: avoid workspace probe in Claude binding correction
austinywang Jun 13, 2026
af1ee9c
test: cover Claude PID-only stale surface routing
austinywang Jun 13, 2026
7f189b0
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang Jun 13, 2026
6fac6a3
fix: bound Claude hook process binding lookup
austinywang Jun 13, 2026
63a7fd0
merge: sync with origin main
austinywang Jun 13, 2026
7ca7953
fix: validate ambient Claude workspace fallback
austinywang Jun 13, 2026
854277a
fix: unwrap Claude binding source
austinywang Jun 13, 2026
bd79085
test: move Claude no-flicker coverage to Swift Testing
austinywang Jun 13, 2026
564dbbb
fix: validate PID-bound Claude workspace
austinywang Jun 13, 2026
251061f
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang Jun 13, 2026
ec79ecd
fix: harden Claude hook routing tests
austinywang Jun 13, 2026
38bdb27
fix: isolate Claude process snapshot socket
austinywang Jun 13, 2026
3c824d7
fix: authenticate Claude snapshot socket
austinywang Jun 13, 2026
a36582e
test: cover Claude no-flicker binding races
austinywang Jun 13, 2026
d27c0c8
fix: validate Claude hook terminal binding
austinywang Jun 13, 2026
b4c5af3
test: cover stale Claude session workspace
austinywang Jun 13, 2026
56b31ce
fix: ignore stale Claude session workspace
austinywang Jun 13, 2026
5d13d75
test: cover stale Claude session surface
austinywang Jun 13, 2026
8e22c0f
fix: prefer Claude PID for stale stored surface
austinywang Jun 13, 2026
52b3d7e
test: cover transient Claude workspace probe
austinywang Jun 13, 2026
936e857
fix: keep Claude target on transient probe failure
austinywang Jun 13, 2026
525d538
fix: allow PID-bound Claude hooks without env target
austinywang Jun 13, 2026
91399f2
fix: restrict Claude hook target recovery metadata
austinywang Jun 13, 2026
6543d16
test: cover live Claude PID over stored PID
austinywang Jun 13, 2026
ec4680e
fix: prefer live Claude hook PID
austinywang Jun 13, 2026
91cb753
fix: let live Claude binding override stored surface
austinywang Jun 13, 2026
f9a5e4e
test: cover Claude PID persistence refresh
austinywang Jun 13, 2026
89d185c
fix: refresh stored Claude hook PID
austinywang Jun 13, 2026
8a74a9e
merge: sync with origin main
austinywang Jun 13, 2026
0b1e4db
test: expand Claude live PID assertions
austinywang Jun 13, 2026
82c3dcd
fix: no-op Claude hooks when PID recovery misses
austinywang Jun 13, 2026
2554098
fix: validate recovered Claude hook targets
austinywang Jun 13, 2026
440c9e2
docs: clarify Claude hook binding priority
austinywang Jun 13, 2026
cad2dc0
fix: avoid Claude hook process snapshots on hot path
austinywang Jun 13, 2026
0e75fba
fix: keep Claude hook PID recovery bounded
austinywang Jun 13, 2026
3a06dd3
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang Jun 13, 2026
c498113
fix: defer generic hook PID inference
austinywang Jun 13, 2026
6be88c4
fix: preserve PID metadata for explicit hooks
austinywang Jun 13, 2026
0087600
test: assert stored PID skips process snapshot
austinywang Jun 13, 2026
97b94d2
fix: ignore raw TTY for Claude hook recovery
austinywang Jun 13, 2026
777fa15
fix: prefer live Claude workspace binding
austinywang Jun 13, 2026
ada9f71
test: tidy Claude raw TTY assertions
austinywang Jun 13, 2026
9fa0dcc
fix: avoid persisting PID on fallback surfaces
austinywang Jun 13, 2026
988f7c4
test: split Claude raw TTY setup
austinywang Jun 13, 2026
bb851aa
fix: avoid republishing stored Claude PID
austinywang Jun 13, 2026
f0f115f
fix: tighten Claude hook PID fallback
austinywang Jun 14, 2026
f7c59e5
test: assert Claude stored PID skips snapshot
austinywang Jun 14, 2026
312b107
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang Jun 14, 2026
644e9d5
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang Jun 14, 2026
f790246
test: assert closed-workspace mapped Claude hook no-ops
austinywang Jun 14, 2026
1004514
fix: no-op Claude hooks when mapped session target is gone
austinywang Jun 14, 2026
88b81e0
merge: resolve conflicts with main
austinywang Jun 14, 2026
9a1d76b
chore: regenerate Swift file length budget
austinywang Jun 14, 2026
39bfeee
Merge remote-tracking branch 'origin/main' into issue-6048-no-flicker…
austinywang Jun 14, 2026
d5060ac
docs: avoid conflict marker scan false positives
austinywang Jun 14, 2026
76fd88f
chore: regenerate Swift file length budget
austinywang Jun 14, 2026
c53e1d0
fix: tighten Claude hook routing and feed telemetry
austinywang Jun 14, 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
2 changes: 1 addition & 1 deletion .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
33525 CLI/cmux.swift
19990 Sources/Workspace.swift
19225 Sources/ContentView.swift
18077 Sources/AppDelegate.swift
Expand Down
358 changes: 293 additions & 65 deletions CLI/cmux.swift

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; };
C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; };
C6048B01B2C3D4E5F6071801 /* ClaudeHookRoutingTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048B02B2C3D4E5F6071802 /* ClaudeHookRoutingTestSupport.swift */; };
C6048C01B2C3D4E5F6071801 /* ClaudeNoFlickerHookBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048C02B2C3D4E5F6071802 /* ClaudeNoFlickerHookBindingTests.swift */; };
C6048A01B2C3D4E5F6071801 /* ClaudeNoFlickerHookRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6048A02B2C3D4E5F6071802 /* ClaudeNoFlickerHookRoutingTests.swift */; };
A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; };
A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; };
C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */; };
Expand Down Expand Up @@ -978,6 +981,9 @@
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
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>"; };
C6048B02B2C3D4E5F6071802 /* ClaudeHookRoutingTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookRoutingTestSupport.swift; sourceTree = "<group>"; };
C6048C02B2C3D4E5F6071802 /* ClaudeNoFlickerHookBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNoFlickerHookBindingTests.swift; sourceTree = "<group>"; };
C6048A02B2C3D4E5F6071802 /* ClaudeNoFlickerHookRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNoFlickerHookRoutingTests.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>"; };
C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIForwardingLaunchArgumentTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2475,6 +2481,9 @@
A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */,
A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */,
A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */,
C6048B02B2C3D4E5F6071802 /* ClaudeHookRoutingTestSupport.swift */,
C6048C02B2C3D4E5F6071802 /* ClaudeNoFlickerHookBindingTests.swift */,
C6048A02B2C3D4E5F6071802 /* ClaudeNoFlickerHookRoutingTests.swift */,
A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */,
C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */,
A5D41206A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift */,
Expand Down Expand Up @@ -3495,6 +3504,9 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */,
C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */,
C6048B01B2C3D4E5F6071801 /* ClaudeHookRoutingTestSupport.swift in Sources */,
C6048C01B2C3D4E5F6071801 /* ClaudeNoFlickerHookBindingTests.swift in Sources */,
C6048A01B2C3D4E5F6071801 /* ClaudeNoFlickerHookRoutingTests.swift in Sources */,
A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */,
C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */,
A5D41207A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift in Sources */,
Expand Down
344 changes: 344 additions & 0 deletions cmuxTests/ClaudeHookRoutingTestSupport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
import Darwin
import Foundation

struct ClaudeHookRoutingTestSupport {
final class BundleMarker: NSObject {}

struct ProcessRunResult: Sendable {
let status: Int32
let stdout: String
let stderr: String
let timedOut: Bool
}

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

func append(_ command: String) {
lock.lock()
commands.append(command)
lock.unlock()
}

func snapshot() -> [String] {
lock.lock()
let value = commands
lock.unlock()
return value
}
}

struct HookContext: @unchecked Sendable {
let cliPath: String
let socketPath: String
let listenerFD: Int32
let state: MockSocketServerState
let root: URL
let workspaceId: String
let surfaceId: String

func cleanup() {
Darwin.close(listenerFD)
unlink(socketPath)
try? FileManager.default.removeItem(at: root)
}
}

func makeHookContext(name: String) throws -> HookContext {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true)
let socketPath = makeSocketPath(String(name.prefix(6)))
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return HookContext(
cliPath: try BundledCLITestSupport.bundledCLIPath(for: BundleMarker.self),
socketPath: socketPath,
listenerFD: try bindUnixSocket(at: socketPath),
state: MockSocketServerState(),
root: root,
workspaceId: "11111111-1111-1111-1111-111111111111",
surfaceId: "22222222-2222-2222-2222-222222222222"
)
}

func startMockServer(
listenerFD: Int32,
state: MockSocketServerState,
handler: @escaping @Sendable (String) -> String
) -> DispatchSemaphore {
let finished = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
while true {
var clientAddr = sockaddr_un()
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen)
}
}
guard clientFD >= 0 else {
if errno == EINTR { continue }
finished.signal()
return
}
DispatchQueue.global(qos: .userInitiated).async {
defer {
Darwin.close(clientFD)
finished.signal()
}

var pending = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
while true {
let count = Darwin.read(clientFD, &buffer, buffer.count)
if count < 0 {
if errno == EINTR { continue }
return
}
if count == 0 { return }
pending.append(buffer, count: count)

while let newlineRange = pending.firstRange(of: Data([0x0A])) {
let lineData = pending.subdata(in: 0..<newlineRange.lowerBound)
pending.removeSubrange(0...newlineRange.lowerBound)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
state.append(line)
let response = handler(line) + "\n"
let responseData = Data(response.utf8)
responseData.withUnsafeBytes { rawBuffer in
guard let base = rawBuffer.baseAddress else { return }
var sent = 0
while sent < rawBuffer.count {
let wrote = Darwin.write(clientFD, base.advanced(by: sent), rawBuffer.count - sent)
if wrote < 0 {
if errno == EINTR { continue }
return
}
if wrote == 0 { return }
sent += wrote
}
}
}
}
}
}
}
return finished
}

func runProcess(
executablePath: String,
arguments: [String],
environment: [String: String],
standardInput: String,
timeout: TimeInterval
) -> ProcessRunResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.environment = environment
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.standardInput = stdinPipe

do {
try process.run()
stdinPipe.fileHandleForWriting.write(Data(standardInput.utf8))
try? stdinPipe.fileHandleForWriting.close()
} catch {
return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false)
}

let exitSignal = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitSignal.signal()
}
let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut
if timedOut {
process.terminate()
_ = exitSignal.wait(timeout: .now() + 1)
if process.isRunning {
Darwin.kill(process.processIdentifier, SIGKILL)
_ = exitSignal.wait(timeout: .now() + 1)
}
}
return ProcessRunResult(
status: process.terminationStatus,
stdout: String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "",
stderr: String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "",
Comment thread
austinywang marked this conversation as resolved.
timedOut: timedOut
)
}

func baseHookEnvironment(context: HookContext) -> [String: String] {
[
"HOME": context.root.path,
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"CMUX_SOCKET_PATH": context.socketPath,
"CMUX_WORKSPACE_ID": context.workspaceId,
"CMUX_SURFACE_ID": context.surfaceId,
"CMUX_CLAUDE_HOOK_STATE_PATH": context.root.appendingPathComponent("claude-hook-sessions.json").path,
"CMUX_CLI_SENTRY_DISABLED": "1",
"CMUX_CLAUDE_HOOK_SENTRY_DISABLED": "1",
]
}

func agentLaunchEnvironment(
context: HookContext,
kind: String,
executable: String,
arguments: [String]? = nil
) -> [String: String] {
[
"CMUX_AGENT_LAUNCH_KIND": kind,
"CMUX_AGENT_LAUNCH_EXECUTABLE": executable,
"CMUX_AGENT_LAUNCH_CWD": context.root.path,
"CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated(arguments ?? [executable]),
]
}

func claudeForkLaunchEnvironment(context: HookContext, parentSessionId: String) -> [String: String] {
agentLaunchEnvironment(
context: context,
kind: "claude",
executable: "/usr/local/bin/claude",
arguments: ["/usr/local/bin/claude", "--resume", parentSessionId, "--fork-session"]
)
}

func seedClaudeForkHookStore(
context: HookContext,
parentSessionId: String,
parentSurfaceId: String,
activeSessionId: String
) throws {
let now = Date().timeIntervalSince1970
let store: [String: Any] = [
"version": 1,
"sessions": [
parentSessionId: [
"sessionId": parentSessionId,
"workspaceId": context.workspaceId,
"surfaceId": parentSurfaceId,
"cwd": context.root.path,
"agentLifecycle": "running",
"startedAt": now,
"updatedAt": now,
],
],
"activeSessionsByWorkspace": [
context.workspaceId: [
"sessionId": activeSessionId,
"updatedAt": now,
],
],
]
try JSONSerialization.data(withJSONObject: store, options: [.prettyPrinted])
.write(
to: context.root.appendingPathComponent("claude-hook-sessions.json"),
options: .atomic
)
}

static func v2Response(
id: String,
ok: Bool,
result: [String: Any]? = nil,
error: [String: Any]? = nil
) -> String {
var payload: [String: Any] = ["id": id, "ok": ok]
if let result { payload["result"] = result }
if let error { payload["error"] = error }
let data = try? JSONSerialization.data(withJSONObject: payload, options: [])
return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}"
}

static func malformedRequestResponse(id: String? = nil, raw: String) -> String {
v2Response(
id: id ?? "unknown",
ok: false,
error: ["code": "malformed_request", "message": "invalid or non-JSON payload", "raw": raw]
)
}

static func surfaceListResponse(id: String, surfaceId: String) -> String {
v2Response(
id: id,
ok: true,
result: ["surfaces": [["id": surfaceId, "ref": "surface:1", "focused": true]]]
)
}

static func jsonObject(_ line: String) -> [String: Any]? {
guard let data = line.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
}

private func makeSocketPath(_ name: String) -> String {
let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8)
return URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock")
.path
}

private func bindUnixSocket(at path: String) throws -> Int32 {
unlink(path)
let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw posixError("socket")
}

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path)
let utf8 = Array(path.utf8)
guard utf8.count < maxPathLength else {
Darwin.close(fd)
throw NSError(domain: "cmux.tests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "socket path is too long: \(path)",
])
}
_ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in
pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in
for index in 0..<utf8.count {
buffer[index] = CChar(bitPattern: utf8[index])
}
buffer[utf8.count] = 0
}
}

let bindResult = withUnsafePointer(to: &addr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bindResult == 0 else {
Darwin.close(fd)
throw posixError("bind")
}
guard Darwin.listen(fd, 1) == 0 else {
Darwin.close(fd)
throw posixError("listen")
}
return fd
}

private func base64NULSeparated(_ values: [String]) -> String {
var data = Data()
for value in values {
data.append(contentsOf: value.utf8)
data.append(0)
}
return data.base64EncodedString()
}

private func posixError(_ operation: String) -> NSError {
NSError(domain: "cmux.tests", code: Int(errno), userInfo: [
NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))",
])
}
}
Loading
Loading