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 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/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 983a726..f08997c 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,88 @@ private struct SessionsDetailPanel: View { } } +private struct SessionHandoffSection: View { + @EnvironmentObject private var handoffStore: SessionHandoffStore + + let detail: MonitoringSelectionDetail + let transport: TransportSnapshot + + 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 { + handoffStore.requestLaunch(detail: detail, transport: transport) + } label: { + if launchPhase == .launching { + Text("Launching...") + } else { + Text("Open in Opencode") + } + } + .buttonStyle(.borderedProminent) + .disabled(!detail.session.attachable || launchPhase == .launching) + + if launchPhase == .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 launchPhase { + 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 launchPhase { + case .success: + return ShellPalette.moss + case .failure: + return ShellPalette.ember + case .idle, .launching: + return ShellPalette.mutedInk + } + } + + private var launchPhase: SessionHandoffPhase { + handoffStore.phase(for: detail) + } +} + 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..349465f --- /dev/null +++ b/macos/ControlCenter/Sources/AetherflowControlCenter/SessionHandoffLauncher.swift @@ -0,0 +1,239 @@ +import Foundation +import OSLog +import Darwin + +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 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) { + self.runner = runner + } + + 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)") + } + } + + static func terminalCommand( + session: DaemonSessionMetadataPayload, + transport: TransportSnapshot + ) throws -> String { + 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))" + ].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 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)" + ) + } + + 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) + .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." + ) + } + + 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) + } + + 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 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 { + var nonEmptyTrimmed: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} 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 new file mode 100644 index 0000000..61fda54 --- /dev/null +++ b/macos/ControlCenter/Tests/AetherflowControlCenterTests/SessionHandoffLauncherTests.swift @@ -0,0 +1,246 @@ +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", + cliPath: "/tmp/tools/af runner" + ) + ) + + XCTAssertEqual( + command, + "cd '/tmp' && '/tmp/tools/af runner' 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 + ) + ) + return SessionHandoffCommandOutput(status: 0, stdout: "", stderr: "") + } + + try await launcher.launch( + session: Self.sessionMetadata(), + transport: Self.transportSnapshot() + ) + + XCTAssertEqual(recorder.calls.count, 1) + XCTAssertEqual(recorder.calls[0].executable, "/usr/bin/osascript") + 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' && '/tmp/tools/af' session attach 'ses-123' --server 'http://127.0.0.1:4096'\"" + ) + ) + } + + func testLaunchSurfacesAttachCommandFailure() async { + let launcher = SessionHandoffLauncher { _, _, _ in + SessionHandoffCommandOutput(status: 1, stdout: "", stderr: "attach failed") + } + + await XCTAssertThrowsErrorAsync( + try await launcher.launch( + session: Self.sessionMetadata(), + transport: Self.transportSnapshot() + ) + ) { error in + XCTAssertEqual( + error as? SessionHandoffLaunchError, + .launchFailed("attach failed") + ) + } + } + + static func transportSnapshot( + workingDirectory: String = "/tmp", + cliPath: String = "/tmp/tools/af" + ) -> TransportSnapshot { + TransportSnapshot( + phase: .connected, + projectName: "aetherflow", + workingDirectory: workingDirectory, + daemonURL: "http://127.0.0.1:7070", + cliPath: cliPath, + daemonTargetReason: "test", + note: "test" + ) + } + + 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 + ) + } +} + +@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] + 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) + } +}