From aa9a7534c3f88f8e4dd74a39c25f556cbb9965fb Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sun, 15 Mar 2026 20:59:33 -0500 Subject: [PATCH 1/4] feat: add Control Center Opencode handoff ts-030640 still lacked the session-detail handoff path after the menu bar deep-link slice landed on main. Operators could inspect a live session in the Control Center, but they could not jump from that detail pane into an interactive Opencode attach flow, which left the ticket incomplete. The missing piece was a launcher that used the supported session metadata contract instead of inventing a new attach path. The UI only surfaced attachability diagnostics, so there was no preflighted route from the selected session to the existing af session attach workflow. This change adds a dedicated SessionHandoffLauncher that validates the target against the local session registry, opens Terminal with the supported af session attach command, and reports success or failure inline in the session detail panel. It also adds focused launcher tests and updates the verification matrix for the new behavior. --- MATRIX.md | 1 + .../ControlCenterRootView.swift | 106 +++++++- .../SessionHandoffLauncher.swift | 238 ++++++++++++++++++ .../SessionHandoffLauncherTests.swift | 181 +++++++++++++ 4 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift create mode 100644 macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift diff --git a/MATRIX.md b/MATRIX.md index 279b268..2692da6 100644 --- a/MATRIX.md +++ b/MATRIX.md @@ -22,6 +22,7 @@ Expected behaviors and their verification status. This is the project's oracle | Control Center | Monitoring reconnect forces an authoritative detail/event reload before resuming cursor-based event fetches | `cd macos/ControlCenter && swift test --filter MonitoringStoreTests/testReconnectForcesAuthoritativeEventReloadBeforeCursorResumes` | covered | 2026-03-15 | | Control Center | Selected session detail stays visible with retained output after the workload leaves live status | `cd macos/ControlCenter && swift test --filter MonitoringStoreTests/testSelectedDetailIsRetainedWhenWorkloadLeavesLiveStatus` | covered | 2026-03-15 | | Control Center | Menu bar session shortcuts deep-link the app to the correct session detail and surface handoff-unavailable copy | `cd macos/ControlCenter && swift test --filter MonitoringStoreTests` | covered | 2026-03-15 | +| Control Center | Session detail launches Opencode through the supported attach flow using recorded session metadata | `cd macos/ControlCenter && swift test --filter SessionHandoffLauncherTests` | covered | 2026-03-15 | | Agent Control | af kill sends SIGTERM and validates agent state | `go test ./internal/daemon/... -run TestHandleAgentKill` | covered | 2026-02-10 | | Agent Control | Kill rejects invalid PIDs (0 or negative) | `go test ./internal/daemon/... -run TestHandleAgentKillInvalidPID` | covered | 2026-02-10 | | Agent Control | Kill rejects non-running agents | `go test ./internal/daemon/... -run TestHandleAgentKillNonRunningAgent` | covered | 2026-02-10 | diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index 983a726..f8fb8f1 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -610,7 +610,7 @@ private struct SectionPreview: View { } } case .sessions: - SessionsDetailPanel(snapshot: monitoring) + SessionsDetailPanel(snapshot: monitoring, transport: transport) case .queue: QueueDetailPanel(snapshot: monitoring) case .diagnostics: @@ -1151,6 +1151,7 @@ private struct MonitoringEmptyRow: View { private struct SessionsDetailPanel: View { let snapshot: MonitoringSnapshot + let transport: TransportSnapshot var body: some View { if let detail = snapshot.selectedDetail { @@ -1198,6 +1199,9 @@ private struct SessionsDetailPanel: View { SessionFactCard(label: "Updated", value: monitoringTimestampLabel(detail.session.updatedAt)) } + SessionHandoffSection(detail: detail, transport: transport) + .id("\(detail.workloadID)::\(detail.session.sessionID)") + if let handoffUnavailableCopy = detail.handoffUnavailableCopy { HandoffUnavailableCard(copy: handoffUnavailableCopy) } @@ -1263,6 +1267,106 @@ private struct SessionsDetailPanel: View { } } +private struct SessionHandoffSection: View { + private enum LaunchState: Equatable { + case idle + case launching + case success(String) + case failure(String) + } + + let detail: MonitoringSelectionDetail + let transport: TransportSnapshot + + @State private var launchState: LaunchState = .idle + private let launcher = SessionHandoffLauncher() + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Opencode handoff") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(ShellPalette.mutedInk) + + HStack(alignment: .center, spacing: 12) { + Button { + Task { + await launchIntoOpencode() + } + } label: { + if launchState == .launching { + Text("Launching...") + } else { + Text("Open in Opencode") + } + } + .buttonStyle(.borderedProminent) + .disabled(!detail.session.attachable || launchState == .launching) + + if launchState == .launching { + ProgressView() + .controlSize(.small) + } + + MiniChip( + text: detail.session.attachable ? "Ready" : "Unavailable", + tone: detail.session.attachable ? ShellPalette.moss : ShellPalette.ember + ) + } + + Text(statusMessage) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(statusTone) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(Color.white.opacity(0.22)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(ShellPalette.panelBorder, lineWidth: 1) + ) + ) + } + + private var statusMessage: String { + switch launchState { + case .idle: + if detail.session.attachable { + return "Launches Terminal and runs the supported af session attach flow for this session." + } + return "This session cannot be opened in Opencode until the daemon exposes an attachable route." + case .launching: + return "Opening a Terminal handoff for \(detail.session.sessionID.nonEmptyValue ?? "the selected session")." + case let .success(message), let .failure(message): + return message + } + } + + private var statusTone: Color { + switch launchState { + case .success: + return ShellPalette.moss + case .failure: + return ShellPalette.ember + case .idle, .launching: + return ShellPalette.mutedInk + } + } + + @MainActor + private func launchIntoOpencode() async { + launchState = .launching + do { + try await launcher.launch(session: detail.session, transport: transport) + let sessionLabel = detail.session.sessionID.nonEmptyValue ?? "the selected session" + launchState = .success("Opened Opencode in Terminal for \(sessionLabel).") + } catch { + launchState = .failure(error.localizedDescription) + } + } +} + private struct HandoffUnavailableCard: View { let copy: String diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift new file mode 100644 index 0000000..d6b77cd --- /dev/null +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift @@ -0,0 +1,238 @@ +import Foundation + +struct SessionHandoffCommandOutput: Equatable, Sendable { + let status: Int32 + let stdout: String + let stderr: String +} + +enum SessionHandoffLaunchError: LocalizedError, Equatable { + case unavailable(String) + case invalidMetadata(String) + case launchFailed(String) + + var errorDescription: String? { + switch self { + case let .unavailable(message), let .invalidMetadata(message), let .launchFailed(message): + return message + } + } +} + +struct SessionHandoffLauncher: Sendable { + typealias Runner = @Sendable (_ executable: String, _ arguments: [String], _ currentDirectory: String) throws -> SessionHandoffCommandOutput + + private let runner: Runner + + init(runner: @escaping Runner = Self.runProcess) { + self.runner = runner + } + + func launch(session: DaemonSessionMetadataPayload, transport: TransportSnapshot) async throws { + let validated = try Self.validatedMetadata(session: session, transport: transport) + let validationInvocation = Self.validationInvocation( + cliPath: validated.cliPath, + serverRef: validated.serverRef + ) + let command = try Self.terminalCommand(session: session, transport: transport) + let arguments = Self.appleScriptArguments(command: command) + try await Self.runBlocking { + let validationOutput = try runner( + validationInvocation.executable, + validationInvocation.arguments, + validated.workingDirectory + ) + guard validationOutput.status == 0 else { + throw SessionHandoffLaunchError.launchFailed(Self.commandFailureMessage(validationOutput)) + } + try Self.validateSessionRecord( + sessionID: validated.sessionID, + serverRef: validated.serverRef, + output: validationOutput + ) + + let launchOutput = try runner("/usr/bin/osascript", arguments, validated.workingDirectory) + guard launchOutput.status == 0 else { + throw SessionHandoffLaunchError.launchFailed(Self.commandFailureMessage(launchOutput)) + } + } + } + + static func terminalCommand( + session: DaemonSessionMetadataPayload, + transport: TransportSnapshot + ) throws -> String { + let validated = try validatedMetadata(session: session, transport: transport) + + let command = [ + "cd \(shellQuoted(validated.workingDirectory))", + "\(shellCommandExecutable(validated.cliPath)) session attach \(shellQuoted(validated.sessionID)) --server \(shellQuoted(validated.serverRef))" + ].joined(separator: " && ") + return command + } + + static func appleScriptArguments(command: String) -> [String] { + [ + "-e", "tell application \"Terminal\"", + "-e", "activate", + "-e", "do script \(appleScriptQuoted(command))", + "-e", "end tell" + ] + } + + private static func shellCommandExecutable(_ cliPath: String) -> String { + if cliPath == "af" { + return cliPath + } + return shellQuoted(cliPath) + } + + private static func validationInvocation( + cliPath: String, + serverRef: String + ) -> (executable: String, arguments: [String]) { + let arguments = ["sessions", "--json", "--server", serverRef] + if cliPath == "af" { + return ("/usr/bin/env", ["af"] + arguments) + } + return (cliPath, arguments) + } + + private static func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } + + private static func appleScriptQuoted(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + private static func runBlocking( + _ work: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + continuation.resume(returning: try work()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func runProcess( + executable: String, + arguments: [String], + currentDirectory: String + ) throws -> SessionHandoffCommandOutput { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + throw SessionHandoffLaunchError.launchFailed( + "Failed to launch the handoff command: \(error.localizedDescription)" + ) + } + + process.waitUntilExit() + return SessionHandoffCommandOutput( + status: process.terminationStatus, + stdout: String(decoding: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines), + stderr: String(decoding: stderrPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + private static func validatedMetadata( + session: DaemonSessionMetadataPayload, + transport: TransportSnapshot + ) throws -> (workingDirectory: String, cliPath: String, sessionID: String, serverRef: String) { + guard session.attachable else { + throw SessionHandoffLaunchError.unavailable( + "Opencode handoff is unavailable for this session. The daemon has not exposed an attachable route yet." + ) + } + + guard let workingDirectory = transport.workingDirectory.nonEmptyTrimmed else { + throw SessionHandoffLaunchError.invalidMetadata( + "Cannot launch Opencode handoff because the app has no working directory context." + ) + } + guard let cliPath = transport.cliPath.nonEmptyTrimmed else { + throw SessionHandoffLaunchError.invalidMetadata( + "Cannot launch Opencode handoff because the CLI path is missing." + ) + } + guard let sessionID = session.sessionID.nonEmptyTrimmed else { + throw SessionHandoffLaunchError.invalidMetadata( + "Cannot launch Opencode handoff because session_id is missing from the daemon metadata." + ) + } + guard let serverRef = session.serverRef.nonEmptyTrimmed else { + throw SessionHandoffLaunchError.invalidMetadata( + "Cannot launch Opencode handoff because server_ref is missing from the daemon metadata." + ) + } + return (workingDirectory, cliPath, sessionID, serverRef) + } + + private static func validateSessionRecord( + sessionID: String, + serverRef: String, + output: SessionHandoffCommandOutput + ) throws { + struct SessionRecord: Decodable { + let sessionID: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + } + } + + let data = Data(output.stdout.utf8) + let records: [SessionRecord] + do { + records = try JSONDecoder().decode([SessionRecord].self, from: data) + } catch { + throw SessionHandoffLaunchError.launchFailed( + "Unable to validate the Opencode handoff target from `af sessions --json`: \(error.localizedDescription)" + ) + } + + guard records.contains(where: { $0.sessionID == sessionID }) else { + throw SessionHandoffLaunchError.launchFailed( + "Session \(sessionID) is not available in the local session registry for \(serverRef)." + ) + } + } + + private static func commandFailureMessage(_ output: SessionHandoffCommandOutput) -> String { + if !output.stderr.isEmpty { + return output.stderr + } + if !output.stdout.isEmpty { + return output.stdout + } + return "Command exited with status \(output.status)." + } +} + +private extension String { + var nonEmptyTrimmed: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift new file mode 100644 index 0000000..0cfaeb1 --- /dev/null +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import AetherflowControlCenter + +final class SessionHandoffLauncherTests: XCTestCase { + func testTerminalCommandUsesRecordedSessionMetadata() throws { + let command = try SessionHandoffLauncher.terminalCommand( + session: Self.sessionMetadata(), + transport: Self.transportSnapshot( + workingDirectory: "/tmp/Aetherflow Workspace", + cliPath: "/tmp/tools/af" + ) + ) + + XCTAssertEqual( + command, + "cd '/tmp/Aetherflow Workspace' && '/tmp/tools/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'" + ) + } + + func testTerminalCommandRejectsUnavailableSessions() { + XCTAssertThrowsError( + try SessionHandoffLauncher.terminalCommand( + session: Self.sessionMetadata(attachable: false), + transport: Self.transportSnapshot() + ) + ) { error in + XCTAssertEqual( + error as? SessionHandoffLaunchError, + .unavailable("Opencode handoff is unavailable for this session. The daemon has not exposed an attachable route yet.") + ) + } + } + + func testTerminalCommandRejectsMissingSessionID() { + XCTAssertThrowsError( + try SessionHandoffLauncher.terminalCommand( + session: Self.sessionMetadata(sessionID: ""), + transport: Self.transportSnapshot() + ) + ) { error in + XCTAssertEqual( + error as? SessionHandoffLaunchError, + .invalidMetadata("Cannot launch Opencode handoff because session_id is missing from the daemon metadata.") + ) + } + } + + func testLaunchInvokesOsascriptForTerminalHandoff() async throws { + let recorder = RunnerRecorder() + let launcher = SessionHandoffLauncher { executable, arguments, currentDirectory in + recorder.calls.append( + RunnerCall( + executable: executable, + arguments: arguments, + currentDirectory: currentDirectory + ) + ) + if arguments.contains("sessions") { + return SessionHandoffCommandOutput( + status: 0, + stdout: """ + [{"session_id":"ses-123"}] + """, + stderr: "" + ) + } + return SessionHandoffCommandOutput(status: 0, stdout: "", stderr: "") + } + + try await launcher.launch( + session: Self.sessionMetadata(), + transport: Self.transportSnapshot() + ) + + XCTAssertEqual(recorder.calls.count, 2) + + XCTAssertEqual(recorder.calls[0].executable, "/tmp/aetherflow/af") + XCTAssertEqual( + recorder.calls[0].arguments, + ["sessions", "--json", "--server", "http://127.0.0.1:4096"] + ) + XCTAssertEqual(recorder.calls[0].currentDirectory, "/tmp/aetherflow") + + XCTAssertEqual(recorder.calls[1].executable, "/usr/bin/osascript") + XCTAssertEqual(recorder.calls[1].currentDirectory, "/tmp/aetherflow") + XCTAssertEqual( + Array(recorder.calls[1].arguments.prefix(4)), + ["-e", "tell application \"Terminal\"", "-e", "activate"] + ) + XCTAssertTrue( + recorder.calls[1].arguments.contains( + "do script \"cd '/tmp/aetherflow' && '/tmp/aetherflow/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'\"" + ) + ) + } + + func testLaunchRejectsMissingRegistrySession() async { + let launcher = SessionHandoffLauncher { _, _, _ in + SessionHandoffCommandOutput( + status: 0, + stdout: """ + [{"session_id":"ses-other"}] + """, + stderr: "" + ) + } + + await XCTAssertThrowsErrorAsync( + try await launcher.launch( + session: Self.sessionMetadata(), + transport: Self.transportSnapshot() + ) + ) { error in + XCTAssertEqual( + error as? SessionHandoffLaunchError, + .launchFailed("Session ses-123 is not available in the local session registry for http://127.0.0.1:4096.") + ) + } + } + + private static func transportSnapshot( + workingDirectory: String = "/tmp/aetherflow", + cliPath: String = "/tmp/aetherflow/af" + ) -> TransportSnapshot { + TransportSnapshot( + phase: .connected, + projectName: "aetherflow", + workingDirectory: workingDirectory, + daemonURL: "http://127.0.0.1:7070", + cliPath: cliPath, + daemonTargetReason: "test", + note: "test" + ) + } + + private static func sessionMetadata( + sessionID: String = "ses-123", + serverRef: String = "http://127.0.0.1:4096", + attachable: Bool = true + ) -> DaemonSessionMetadataPayload { + DaemonSessionMetadataPayload( + serverRef: serverRef, + sessionID: sessionID, + directory: "/tmp/aetherflow", + project: "aetherflow", + originType: "agent", + workRef: "ts-030640", + agentID: "agent-1", + status: "running", + createdAt: nil, + lastSeenAt: nil, + updatedAt: nil, + attachable: attachable + ) + } +} + +private final class RunnerRecorder: @unchecked Sendable { + var calls: [RunnerCall] = [] +} + +private struct RunnerCall: Equatable { + let executable: String + let arguments: [String] + let currentDirectory: String +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTFail(message(), file: file, line: line) + } catch { + errorHandler(error) + } +} From a480a359c08e82a7970406666f034bf4836d0fa9 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sun, 15 Mar 2026 21:11:28 -0500 Subject: [PATCH 2/4] chore: ignore local worktree directory The repository had an uncommitted local ignore change for the top-level .worktrees directory. Keeping it untracked avoids noise from local worktree management in git status. This commit adds the ignore entry without mixing it into the Control Center feature work. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d963ecb..f30318e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ Thumbs.db /macos/*/build/ *.xcodeproj/xcuserdata/ *.xcuserstate +.worktrees From 18528830b6209649e1a223ede8e15aa00783d281 Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sun, 15 Mar 2026 21:24:22 -0500 Subject: [PATCH 3/4] fix: trust daemon metadata for Control Center handoff The initial Control Center handoff implementation preflighted with null before opening Terminal. That added a second source of truth for session existence even though the UI was already rendering daemon-provided session detail with , , and . This follow-up removes the CLI registry preflight and trusts the daemon-owned session metadata the app already consumed to render the selected session. The launcher now validates only the local handoff inputs and executes the real flow directly, while tests cover the launch path and attach-command failure handling. --- .../SessionHandoffLauncher.swift | 67 ++----------------- .../SessionHandoffLauncherTests.swift | 37 ++-------- 2 files changed, 13 insertions(+), 91 deletions(-) diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift index d6b77cd..acd584f 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift @@ -30,27 +30,9 @@ struct SessionHandoffLauncher: Sendable { func launch(session: DaemonSessionMetadataPayload, transport: TransportSnapshot) async throws { let validated = try Self.validatedMetadata(session: session, transport: transport) - let validationInvocation = Self.validationInvocation( - cliPath: validated.cliPath, - serverRef: validated.serverRef - ) - let command = try Self.terminalCommand(session: session, transport: transport) + let command = Self.terminalCommand(validated: validated) let arguments = Self.appleScriptArguments(command: command) try await Self.runBlocking { - let validationOutput = try runner( - validationInvocation.executable, - validationInvocation.arguments, - validated.workingDirectory - ) - guard validationOutput.status == 0 else { - throw SessionHandoffLaunchError.launchFailed(Self.commandFailureMessage(validationOutput)) - } - try Self.validateSessionRecord( - sessionID: validated.sessionID, - serverRef: validated.serverRef, - output: validationOutput - ) - let launchOutput = try runner("/usr/bin/osascript", arguments, validated.workingDirectory) guard launchOutput.status == 0 else { throw SessionHandoffLaunchError.launchFailed(Self.commandFailureMessage(launchOutput)) @@ -62,8 +44,12 @@ struct SessionHandoffLauncher: Sendable { session: DaemonSessionMetadataPayload, transport: TransportSnapshot ) throws -> String { - let validated = try validatedMetadata(session: session, transport: transport) + terminalCommand(validated: try validatedMetadata(session: session, transport: transport)) + } + private static func terminalCommand( + validated: (workingDirectory: String, cliPath: String, sessionID: String, serverRef: String) + ) -> String { let command = [ "cd \(shellQuoted(validated.workingDirectory))", "\(shellCommandExecutable(validated.cliPath)) session attach \(shellQuoted(validated.sessionID)) --server \(shellQuoted(validated.serverRef))" @@ -87,17 +73,6 @@ struct SessionHandoffLauncher: Sendable { return shellQuoted(cliPath) } - private static func validationInvocation( - cliPath: String, - serverRef: String - ) -> (executable: String, arguments: [String]) { - let arguments = ["sessions", "--json", "--server", serverRef] - if cliPath == "af" { - return ("/usr/bin/env", ["af"] + arguments) - } - return (cliPath, arguments) - } - private static func shellQuoted(_ value: String) -> String { "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } @@ -189,36 +164,6 @@ struct SessionHandoffLauncher: Sendable { return (workingDirectory, cliPath, sessionID, serverRef) } - private static func validateSessionRecord( - sessionID: String, - serverRef: String, - output: SessionHandoffCommandOutput - ) throws { - struct SessionRecord: Decodable { - let sessionID: String - - private enum CodingKeys: String, CodingKey { - case sessionID = "session_id" - } - } - - let data = Data(output.stdout.utf8) - let records: [SessionRecord] - do { - records = try JSONDecoder().decode([SessionRecord].self, from: data) - } catch { - throw SessionHandoffLaunchError.launchFailed( - "Unable to validate the Opencode handoff target from `af sessions --json`: \(error.localizedDescription)" - ) - } - - guard records.contains(where: { $0.sessionID == sessionID }) else { - throw SessionHandoffLaunchError.launchFailed( - "Session \(sessionID) is not available in the local session registry for \(serverRef)." - ) - } - } - private static func commandFailureMessage(_ output: SessionHandoffCommandOutput) -> String { if !output.stderr.isEmpty { return output.stderr diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift index 0cfaeb1..eebbaf8 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift @@ -55,15 +55,6 @@ final class SessionHandoffLauncherTests: XCTestCase { currentDirectory: currentDirectory ) ) - if arguments.contains("sessions") { - return SessionHandoffCommandOutput( - status: 0, - stdout: """ - [{"session_id":"ses-123"}] - """, - stderr: "" - ) - } return SessionHandoffCommandOutput(status: 0, stdout: "", stderr: "") } @@ -72,37 +63,23 @@ final class SessionHandoffLauncherTests: XCTestCase { transport: Self.transportSnapshot() ) - XCTAssertEqual(recorder.calls.count, 2) - - XCTAssertEqual(recorder.calls[0].executable, "/tmp/aetherflow/af") - XCTAssertEqual( - recorder.calls[0].arguments, - ["sessions", "--json", "--server", "http://127.0.0.1:4096"] - ) + XCTAssertEqual(recorder.calls.count, 1) + XCTAssertEqual(recorder.calls[0].executable, "/usr/bin/osascript") XCTAssertEqual(recorder.calls[0].currentDirectory, "/tmp/aetherflow") - - XCTAssertEqual(recorder.calls[1].executable, "/usr/bin/osascript") - XCTAssertEqual(recorder.calls[1].currentDirectory, "/tmp/aetherflow") XCTAssertEqual( - Array(recorder.calls[1].arguments.prefix(4)), + Array(recorder.calls[0].arguments.prefix(4)), ["-e", "tell application \"Terminal\"", "-e", "activate"] ) XCTAssertTrue( - recorder.calls[1].arguments.contains( + recorder.calls[0].arguments.contains( "do script \"cd '/tmp/aetherflow' && '/tmp/aetherflow/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'\"" ) ) } - func testLaunchRejectsMissingRegistrySession() async { + func testLaunchSurfacesAttachCommandFailure() async { let launcher = SessionHandoffLauncher { _, _, _ in - SessionHandoffCommandOutput( - status: 0, - stdout: """ - [{"session_id":"ses-other"}] - """, - stderr: "" - ) + SessionHandoffCommandOutput(status: 1, stdout: "", stderr: "attach failed") } await XCTAssertThrowsErrorAsync( @@ -113,7 +90,7 @@ final class SessionHandoffLauncherTests: XCTestCase { ) { error in XCTAssertEqual( error as? SessionHandoffLaunchError, - .launchFailed("Session ses-123 is not available in the local session registry for http://127.0.0.1:4096.") + .launchFailed("attach failed") ) } } From 21a5ba66d3aeba2d33d9972bb77ca59258b7943a Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Sun, 15 Mar 2026 21:29:31 -0500 Subject: [PATCH 4/4] fix: harden Control Center handoff flow Review on the Control Center handoff work surfaced three concrete issues: the launcher could block forever while waiting on osascript, the SwiftUI view owned the launch orchestration directly, and the button could queue duplicate launches before the async state flipped to launching. This change moves handoff state and launch orchestration into a dedicated SessionHandoffStore, so the view stays declarative and disables the button synchronously. It also hardens SessionHandoffLauncher with invariant checks, bounded process waiting, forced termination on timeout, and structured logging around launch attempts and failures. The test suite now covers the store-level launch transitions in addition to the launcher command and failure cases. --- .../AetherflowControlCenterApp.swift | 4 + .../ControlCenterRootView.swift | 36 ++---- .../SessionHandoffLauncher.swift | 58 +++++++++- .../SessionHandoffStore.swift | 65 +++++++++++ .../AetherflowControlCenter/ShellModels.swift | 12 ++ .../SessionHandoffLauncherTests.swift | 106 ++++++++++++++++-- 6 files changed, 244 insertions(+), 37 deletions(-) create mode 100644 macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffStore.swift diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/AetherflowControlCenterApp.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/AetherflowControlCenterApp.swift index b11342c..71be06f 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/AetherflowControlCenterApp.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/AetherflowControlCenterApp.swift @@ -10,6 +10,7 @@ struct AetherflowControlCenterApp: App { @StateObject private var lifecycleStore: DaemonLifecycleStore @StateObject private var monitoringStore: MonitoringStore @StateObject private var navigationStore: NavigationStore + @StateObject private var handoffStore: SessionHandoffStore init() { let bootstrap = ShellBootstrapContext.detect() @@ -18,6 +19,7 @@ struct AetherflowControlCenterApp: App { _lifecycleStore = StateObject(wrappedValue: DaemonLifecycleStore(context: bootstrap, transportStore: transportStore)) _monitoringStore = StateObject(wrappedValue: MonitoringStore(context: bootstrap)) _navigationStore = StateObject(wrappedValue: NavigationStore()) + _handoffStore = StateObject(wrappedValue: SessionHandoffStore()) } var body: some Scene { @@ -27,6 +29,7 @@ struct AetherflowControlCenterApp: App { .environmentObject(lifecycleStore) .environmentObject(monitoringStore) .environmentObject(navigationStore) + .environmentObject(handoffStore) } .defaultSize(width: 1480, height: 920) @@ -36,6 +39,7 @@ struct AetherflowControlCenterApp: App { .environmentObject(lifecycleStore) .environmentObject(monitoringStore) .environmentObject(navigationStore) + .environmentObject(handoffStore) } .menuBarExtraStyle(.window) } diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift index f8fb8f1..f08997c 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ControlCenterRootView.swift @@ -1268,19 +1268,11 @@ private struct SessionsDetailPanel: View { } private struct SessionHandoffSection: View { - private enum LaunchState: Equatable { - case idle - case launching - case success(String) - case failure(String) - } + @EnvironmentObject private var handoffStore: SessionHandoffStore let detail: MonitoringSelectionDetail let transport: TransportSnapshot - @State private var launchState: LaunchState = .idle - private let launcher = SessionHandoffLauncher() - var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Opencode handoff") @@ -1289,20 +1281,18 @@ private struct SessionHandoffSection: View { HStack(alignment: .center, spacing: 12) { Button { - Task { - await launchIntoOpencode() - } + handoffStore.requestLaunch(detail: detail, transport: transport) } label: { - if launchState == .launching { + if launchPhase == .launching { Text("Launching...") } else { Text("Open in Opencode") } } .buttonStyle(.borderedProminent) - .disabled(!detail.session.attachable || launchState == .launching) + .disabled(!detail.session.attachable || launchPhase == .launching) - if launchState == .launching { + if launchPhase == .launching { ProgressView() .controlSize(.small) } @@ -1330,7 +1320,7 @@ private struct SessionHandoffSection: View { } private var statusMessage: String { - switch launchState { + switch launchPhase { case .idle: if detail.session.attachable { return "Launches Terminal and runs the supported af session attach flow for this session." @@ -1344,7 +1334,7 @@ private struct SessionHandoffSection: View { } private var statusTone: Color { - switch launchState { + switch launchPhase { case .success: return ShellPalette.moss case .failure: @@ -1354,16 +1344,8 @@ private struct SessionHandoffSection: View { } } - @MainActor - private func launchIntoOpencode() async { - launchState = .launching - do { - try await launcher.launch(session: detail.session, transport: transport) - let sessionLabel = detail.session.sessionID.nonEmptyValue ?? "the selected session" - launchState = .success("Opened Opencode in Terminal for \(sessionLabel).") - } catch { - launchState = .failure(error.localizedDescription) - } + private var launchPhase: SessionHandoffPhase { + handoffStore.phase(for: detail) } } diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift index acd584f..349465f 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift @@ -1,4 +1,6 @@ import Foundation +import OSLog +import Darwin struct SessionHandoffCommandOutput: Equatable, Sendable { let status: Int32 @@ -22,6 +24,9 @@ enum SessionHandoffLaunchError: LocalizedError, Equatable { struct SessionHandoffLauncher: Sendable { typealias Runner = @Sendable (_ executable: String, _ arguments: [String], _ currentDirectory: String) throws -> SessionHandoffCommandOutput + private static let logger = Logger(subsystem: "com.baiirun.aetherflow.controlcenter", category: "session-handoff") + private static let commandTimeout: TimeInterval = 5 + private let runner: Runner init(runner: @escaping Runner = Self.runProcess) { @@ -30,13 +35,23 @@ struct SessionHandoffLauncher: Sendable { func launch(session: DaemonSessionMetadataPayload, transport: TransportSnapshot) async throws { let validated = try Self.validatedMetadata(session: session, transport: transport) + assert(!validated.workingDirectory.isEmpty) + assert(!validated.cliPath.isEmpty) + assert(!validated.sessionID.isEmpty) let command = Self.terminalCommand(validated: validated) let arguments = Self.appleScriptArguments(command: command) try await Self.runBlocking { + Self.logger.info( + "Launching Opencode handoff executable=/usr/bin/osascript cwd=\(validated.workingDirectory, privacy: .public) args=\(arguments.joined(separator: " "), privacy: .public)" + ) let launchOutput = try runner("/usr/bin/osascript", arguments, validated.workingDirectory) guard launchOutput.status == 0 else { + Self.logger.error( + "Opencode handoff failed status=\(launchOutput.status) stdout=\(launchOutput.stdout, privacy: .public) stderr=\(launchOutput.stderr, privacy: .public)" + ) throw SessionHandoffLaunchError.launchFailed(Self.commandFailureMessage(launchOutput)) } + Self.logger.info("Opencode handoff launched successfully for session \(validated.sessionID, privacy: .public)") } } @@ -121,7 +136,16 @@ struct SessionHandoffLauncher: Sendable { ) } - process.waitUntilExit() + if waitForExit(process, timeout: commandTimeout) { + logger.error( + "Opencode handoff command timed out executable=\(executable, privacy: .public) cwd=\(currentDirectory, privacy: .public) args=\(arguments.joined(separator: " "), privacy: .public)" + ) + terminate(process: process) + throw SessionHandoffLaunchError.launchFailed( + "Opencode handoff timed out after \(Int(commandTimeout)) seconds." + ) + } + return SessionHandoffCommandOutput( status: process.terminationStatus, stdout: String(decoding: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) @@ -161,6 +185,14 @@ struct SessionHandoffLauncher: Sendable { "Cannot launch Opencode handoff because server_ref is missing from the daemon metadata." ) } + + var isDirectory = ObjCBool(false) + guard FileManager.default.fileExists(atPath: workingDirectory, isDirectory: &isDirectory), isDirectory.boolValue else { + throw SessionHandoffLaunchError.invalidMetadata( + "Cannot launch Opencode handoff because the working directory is unavailable." + ) + } + return (workingDirectory, cliPath, sessionID, serverRef) } @@ -173,6 +205,30 @@ struct SessionHandoffLauncher: Sendable { } return "Command exited with status \(output.status)." } + + private static func waitForExit(_ process: Process, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning { + if Date() >= deadline { + return true + } + Thread.sleep(forTimeInterval: 0.05) + } + return false + } + + private static func terminate(process: Process) { + guard process.isRunning else { + return + } + + process.terminate() + Thread.sleep(forTimeInterval: 0.1) + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + process.waitUntilExit() + } } private extension String { diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffStore.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffStore.swift new file mode 100644 index 0000000..35c7fa3 --- /dev/null +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffStore.swift @@ -0,0 +1,65 @@ +import Foundation + +protocol SessionHandoffLaunching: Sendable { + func launch(session: DaemonSessionMetadataPayload, transport: TransportSnapshot) async throws +} + +extension SessionHandoffLauncher: SessionHandoffLaunching {} + +@MainActor +final class SessionHandoffStore: ObservableObject { + @Published private var snapshot: SessionHandoffSnapshot? + + private let launcher: SessionHandoffLaunching + + init(launcher: SessionHandoffLaunching = SessionHandoffLauncher()) { + self.launcher = launcher + } + + func phase(for detail: MonitoringSelectionDetail) -> SessionHandoffPhase { + let selectionKey = Self.selectionKey(for: detail) + guard snapshot?.selectionKey == selectionKey else { + return .idle + } + return snapshot?.phase ?? .idle + } + + func requestLaunch(detail: MonitoringSelectionDetail, transport: TransportSnapshot) { + let selectionKey = Self.selectionKey(for: detail) + if snapshot?.selectionKey == selectionKey, snapshot?.phase == .launching { + return + } + + snapshot = SessionHandoffSnapshot(selectionKey: selectionKey, phase: .launching) + + Task { [weak self] in + guard let self else { + return + } + do { + try await launcher.launch(session: detail.session, transport: transport) + let sessionLabel = { + let trimmed = detail.session.sessionID.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "the selected session" : trimmed + }() + self.apply( + phase: .success("Opened Opencode in Terminal for \(sessionLabel)."), + selectionKey: selectionKey + ) + } catch { + self.apply(phase: .failure(error.localizedDescription), selectionKey: selectionKey) + } + } + } + + private func apply(phase: SessionHandoffPhase, selectionKey: String) { + guard snapshot?.selectionKey == selectionKey else { + return + } + snapshot = SessionHandoffSnapshot(selectionKey: selectionKey, phase: phase) + } + + private static func selectionKey(for detail: MonitoringSelectionDetail) -> String { + "\(detail.workloadID)::\(detail.session.sessionID)" + } +} diff --git a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift index ede18f7..3ea8f4d 100644 --- a/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/ShellModels.swift @@ -207,6 +207,18 @@ struct TransportSnapshot { let note: String } +enum SessionHandoffPhase: Equatable { + case idle + case launching + case success(String) + case failure(String) +} + +struct SessionHandoffSnapshot: Equatable { + let selectionKey: String + let phase: SessionHandoffPhase +} + enum DaemonLifecyclePhase: String { case starting = "Starting" case running = "Running" diff --git a/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift index eebbaf8..61fda54 100644 --- a/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift @@ -6,14 +6,14 @@ final class SessionHandoffLauncherTests: XCTestCase { let command = try SessionHandoffLauncher.terminalCommand( session: Self.sessionMetadata(), transport: Self.transportSnapshot( - workingDirectory: "/tmp/Aetherflow Workspace", - cliPath: "/tmp/tools/af" + workingDirectory: "/tmp", + cliPath: "/tmp/tools/af runner" ) ) XCTAssertEqual( command, - "cd '/tmp/Aetherflow Workspace' && '/tmp/tools/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'" + "cd '/tmp' && '/tmp/tools/af runner' session attach 'ses-123' --server 'http://127.0.0.1:4096'" ) } @@ -65,14 +65,14 @@ final class SessionHandoffLauncherTests: XCTestCase { XCTAssertEqual(recorder.calls.count, 1) XCTAssertEqual(recorder.calls[0].executable, "/usr/bin/osascript") - XCTAssertEqual(recorder.calls[0].currentDirectory, "/tmp/aetherflow") + XCTAssertEqual(recorder.calls[0].currentDirectory, "/tmp") XCTAssertEqual( Array(recorder.calls[0].arguments.prefix(4)), ["-e", "tell application \"Terminal\"", "-e", "activate"] ) XCTAssertTrue( recorder.calls[0].arguments.contains( - "do script \"cd '/tmp/aetherflow' && '/tmp/aetherflow/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'\"" + "do script \"cd '/tmp' && '/tmp/tools/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'\"" ) ) } @@ -95,9 +95,9 @@ final class SessionHandoffLauncherTests: XCTestCase { } } - private static func transportSnapshot( - workingDirectory: String = "/tmp/aetherflow", - cliPath: String = "/tmp/aetherflow/af" + static func transportSnapshot( + workingDirectory: String = "/tmp", + cliPath: String = "/tmp/tools/af" ) -> TransportSnapshot { TransportSnapshot( phase: .connected, @@ -110,7 +110,7 @@ final class SessionHandoffLauncherTests: XCTestCase { ) } - private static func sessionMetadata( + static func sessionMetadata( sessionID: String = "ses-123", serverRef: String = "http://127.0.0.1:4096", attachable: Bool = true @@ -132,10 +132,98 @@ final class SessionHandoffLauncherTests: XCTestCase { } } +@MainActor +final class SessionHandoffStoreTests: XCTestCase { + func testRequestLaunchTransitionsToLaunchingImmediately() async { + let launcher = TestHandoffLauncher { _, _ in + try await Task.sleep(nanoseconds: 200_000_000) + } + let store = SessionHandoffStore(launcher: launcher) + let detail = Self.detail() + let transport = Self.transportSnapshot() + + store.requestLaunch(detail: detail, transport: transport) + + XCTAssertEqual(store.phase(for: detail), .launching) + } + + func testRequestLaunchPublishesFailureForSelection() async { + let launcher = TestHandoffLauncher { _, _ in + throw SessionHandoffLaunchError.launchFailed("attach failed") + } + let store = SessionHandoffStore(launcher: launcher) + let detail = Self.detail() + let transport = Self.transportSnapshot() + + store.requestLaunch(detail: detail, transport: transport) + await waitForPhase(store, detail: detail) { phase in + phase == .failure("attach failed") + } + + XCTAssertEqual(store.phase(for: detail), .failure("attach failed")) + } + + private static func detail(sessionID: String = "ses-123") -> MonitoringSelectionDetail { + MonitoringSelectionDetail( + workloadID: "agent-1", + session: SessionHandoffLauncherTests.sessionMetadata(sessionID: sessionID), + agent: DaemonAgentStatusPayload( + id: "agent-1", + taskID: "ts-030640", + role: "worker", + pid: 42, + spawnTime: .now, + taskTitle: "Launch handoff", + lastLog: "working", + sessionID: sessionID, + state: "running", + lifecycleState: "running", + lastActivityAt: .now, + attentionNeeded: false + ), + toolCalls: [], + eventLines: [], + lastEventTimestamp: 0, + errors: [], + isLive: true + ) + } + + private static func transportSnapshot() -> TransportSnapshot { + SessionHandoffLauncherTests.transportSnapshot() + } + + private func waitForPhase( + _ store: SessionHandoffStore, + detail: MonitoringSelectionDetail, + matches: @escaping (SessionHandoffPhase) -> Bool + ) async { + for _ in 0..<20 { + if matches(store.phase(for: detail)) { + return + } + try? await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("Timed out waiting for expected handoff phase.") + } +} + private final class RunnerRecorder: @unchecked Sendable { var calls: [RunnerCall] = [] } +private struct TestHandoffLauncher: SessionHandoffLaunching { + let launchImpl: @Sendable (DaemonSessionMetadataPayload, TransportSnapshot) async throws -> Void + + init(_ launchImpl: @escaping @Sendable (DaemonSessionMetadataPayload, TransportSnapshot) async throws -> Void) { + self.launchImpl = launchImpl + } + + func launch(session: DaemonSessionMetadataPayload, transport: TransportSnapshot) async throws { + try await launchImpl(session, transport) + } +} + private struct RunnerCall: Equatable { let executable: String let arguments: [String]