diff --git a/apps/mac/.gitignore b/apps/mac/.gitignore new file mode 100644 index 00000000000..e11dfecddf4 --- /dev/null +++ b/apps/mac/.gitignore @@ -0,0 +1,2 @@ +.build/ +dist/ diff --git a/apps/mac/ARCHITECTURE.md b/apps/mac/ARCHITECTURE.md new file mode 100644 index 00000000000..8f3bf101591 --- /dev/null +++ b/apps/mac/ARCHITECTURE.md @@ -0,0 +1,130 @@ +# SergeCode for macOS — Architecture + +Native macOS client for the t3 server. SwiftUI with Liquid Glass (macOS 26+), +no Electron, no web view. The existing Node server (`apps/server`, npm package +`t3`) is kept unchanged and runs as a supervised child process. + +``` +┌───────────────────────────────────────────────┐ +│ SergeCode.app (SwiftUI, macOS 26+) │ +│ │ +│ SergeCodeMac (app target) │ +│ • Liquid Glass UI: sidebar, chat, diffs │ +│ • AppModel: observable session state │ +│ T3Kit (library) │ +│ • Effect-RPC-over-WebSocket client │ +│ • Codable models mirroring @t3tools/contracts│ +│ • Reconnect supervisor (backoff, resubscribe)│ +│ SidecarKit (library) │ +│ • Spawns `node dist/bin.mjs --mode desktop` │ +│ • Bootstrap JSON over stdin (--bootstrap-fd 0)│ +│ • Readiness poll, crash restart, shutdown │ +└──────────────┬────────────────────────────────┘ + │ ws://127.0.0.1:/ws + local HTTP +┌──────────────▼────────────────────────────────┐ +│ t3 server (Node, unchanged) │ +│ providers: codex / claude / cursor / opencode │ +└───────────────────────────────────────────────┘ +``` + +## Why this shape + +- The server is the product's brain (orchestration engine, provider drivers, + SQLite persistence). The Electron app already runs it as a supervised child + (`apps/desktop/src/backend/DesktopBackendManager.ts`); we reuse that exact + contract from Swift instead of porting Effect-TS to Swift. +- `apps/mobile` proves a non-browser client works: it speaks the same + `WS_METHODS` effect-rpc contract via `packages/client-runtime`. T3Kit is the + Swift equivalent of `client-runtime`'s Primary connection path. + +## Sidecar contract (from apps/desktop + apps/server) + +- Spawn: `node /dist/bin.mjs --mode desktop --bootstrap-fd 0`, + loopback host. Write ONE line of JSON (schema + `packages/contracts/src/desktopBootstrap.ts` `DesktopBackendBootstrap`, + includes a random `desktopBootstrapToken`) to stdin before anything else. +- Readiness: poll `GET /.well-known/t3/environment` (100ms interval, 60s cap). +- Auth: with mode=desktop + loopback bind, server policy is + `desktop-managed-local` — exchange the bootstrap token for a session/bearer + token via the local HTTP auth API. No Clerk, no pairing, no DPoP in v1. +- Shutdown: SIGTERM, 2s grace, then SIGKILL. Restart on crash with + exponential backoff (500ms → 10s). +- Data dir: pass explicit `--base-dir` under + `~/Library/Application Support/SergeCode/` so we never collide with an + Electron install's state. +- PATH repair is the server's job (`os-jank.ts fixPath()`); the app just + spawns node. Dev builds locate node via `/usr/bin/env node` (engines: + ^22.16 || ^23.11 || >=24.10); bundling a Node runtime into the .app is a + post-v1 packaging task. + +## Wire protocol + +See `docs/wire-protocol.md` (generated from packages/contracts + +packages/client-runtime). Effect-RPC over a WebSocket at `/ws`: JSON request +envelopes with tag/id/payload, streamed responses for subscriptions. +T3Kit hand-ports the message shapes as Codable structs; contract drift is +caught by `Tests/T3KitTests` fixtures copied from the TS side. + +## Scope + +v1 (core parity): project/thread sidebar, chat timeline (turns, streaming +tokens, tool events), composer, approvals, diff panel, checkpoints, +provider status, light/dark. + +v2 (feature parity — landed): user-input prompt cards, plan mode + +proposed-plan cards with implement action, runtime-mode picker, model +picker, context-window meter, live plan-progress (todo) inspector tab, +composer @-file-mentions (projects.searchEntries), image attachments +(inline base64 dataUrl), slash-command menu, editable settings +(server.getSettings/updateSettings), provider refresh + CLI update, +archived-thread management, git/VCS (subscribeVcsStatus, branch +switch/create, pull, stacked commit/push/PR actions with outcome banner, +PR links), workspace file browser + preview + open-in-external-editor. + +Out (excluded or later): embedded terminal (needs a SwiftTerm dependency — +Package.swift change to coordinate), browser preview/automation, PR review +dialogs, Clerk/T3 Connect cloud auth + relay + pairing (t3-service +exclusive, permanently out for the fork), SSH/tailscale remotes, +keybindings editor, auto-update (Sparkle later), Windows/Linux (Electron +app remains for those). + +## Liquid Glass usage + +macOS 26+ only, no fallbacks: `glassEffect(_:in:)` on floating surfaces +(composer, approval sheets, toolbars), `GlassEffectContainer` for morphing +groups, `.buttonStyle(.glass)`, scroll-edge effects on sidebar/timeline. +Content layers (chat text, diffs) stay opaque for readability; glass is for +chrome, never for long-form reading surfaces. + +## Build without Xcode + +Only Command Line Tools are required. `scripts/make-app.sh` runs +`swift build` and assembles `dist/SergeCode.app` (hand-written Info.plist, +ad-hoc codesign). CRITICAL: the macOS 27 beta SDK defines SwiftUI `@State`, +`@Entry`, `@Animatable` as compiler macros in a `SwiftUIMacros` plugin that +ships only with Xcode. Rules for all Swift code in this package: + +- Use `@UIState` (Sources/SergeCodeMac/Support/StateShim.swift) — never `@State`. +- Custom environment values: manual `EnvironmentKey` conformance — never `@Entry`. +- Manual `Animatable` conformance — never the `@Animatable` macro. +- `@Observable` (ObservationMacros) works and is the preferred store pattern. + +### Code signing + TCC (why Finder launches need a stable identity) + +The node sidecar (and the repo checkout it loads from) usually lives under +`~/Documents`, which is TCC-protected. macOS keys the Documents-folder grant +to the app's code identity; an ad-hoc signature produces a new identity on +every rebuild, so the grant never sticks — and while consent is unresolved +the sidecar's very first `open()` of `bin.mjs` blocks in the kernel, which +presents as "app launches but stays on Launching Server… forever" (terminal +launches are unaffected because they inherit the terminal app's grant). + +One-time setup for a stable local identity (`make-app.sh` picks it up +automatically and falls back to ad-hoc with a warning): + +1. Generate + import a self-signed codesigning cert named + `SergeCode Dev Signing` (openssl req/pkcs12 + `security import`). +2. Trust it for code signing: + `security add-trusted-cert -p codeSign ` (approve the prompt). +3. Rebuild with `scripts/make-app.sh`; on first Finder launch approve the + "SergeCode would like to access files in your Documents folder" dialog. diff --git a/apps/mac/CLAUDE.md b/apps/mac/CLAUDE.md new file mode 100644 index 00000000000..7b328dcdadf --- /dev/null +++ b/apps/mac/CLAUDE.md @@ -0,0 +1,23 @@ +# apps/mac — agent rules + +Native SwiftUI macOS app (macOS 26+ Liquid Glass). Read ARCHITECTURE.md first. + +Hard rules (build breaks otherwise — no Xcode on this machine, CLT only): +- NEVER use `@State`, `@Entry`, or `@Animatable` — the macOS 27 beta SDK makes + them Xcode-only compiler macros. Use `@UIState` + (Sources/SergeCodeMac/Support/StateShim.swift), manual `EnvironmentKey` + conformances, and manual `Animatable` conformance instead. +- `@Observable`, `@Binding`, `@Environment`, `@Bindable`, `@StateObject` are fine. +- Build/test: `swift build --package-path apps/mac`. Tests need the swift-testing + macro plugin passed explicitly (CLT keeps it in a subdir the compiler doesn't + search): `swift test --package-path apps/mac -Xswiftc -plugin-path -Xswiftc + /Library/Developer/CommandLineTools/usr/lib/swift/host/plugins/testing`. + App bundle: `apps/mac/scripts/make-app.sh`. +- Live E2E (spawns real server): prefix with `SERGECODE_LIVE_E2E=1`, filter + `LiveIntegrationTests`. +- Concurrency: Swift 6 strict mode. UI types `@MainActor`; T3Kit/SidecarKit + internals actor-isolated. +- Do not edit Package.swift without coordinating — target layout is fixed + (T3Kit, SidecarKit, SergeCodeMac + test targets). +- Liquid Glass: glass for chrome (toolbars, composer, sheets); never behind + long-form text (chat bodies, diffs). diff --git a/apps/mac/Package.swift b/apps/mac/Package.swift new file mode 100644 index 00000000000..a695b1b3f36 --- /dev/null +++ b/apps/mac/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 6.1 +import PackageDescription + +let package = Package( + name: "SergeCodeMac", + platforms: [ + .macOS("26.0") + ], + targets: [ + .target( + name: "T3Kit", + path: "Sources/T3Kit" + ), + .target( + name: "SidecarKit", + path: "Sources/SidecarKit" + ), + .executableTarget( + name: "SergeCodeMac", + dependencies: ["T3Kit", "SidecarKit"], + path: "Sources/SergeCodeMac" + ), + .testTarget( + name: "T3KitTests", + dependencies: ["T3Kit"], + path: "Tests/T3KitTests" + ), + .testTarget( + name: "SidecarKitTests", + dependencies: ["SidecarKit"], + path: "Tests/SidecarKitTests" + ), + ] +) diff --git a/apps/mac/Sources/SergeCodeMac/App.swift b/apps/mac/Sources/SergeCodeMac/App.swift new file mode 100644 index 00000000000..6b8d4e6ee4e --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/App.swift @@ -0,0 +1,90 @@ +import SwiftUI + +// App entry point. AppModel is a reference type (@Observable class), so a +// plain `let` on the App struct is sufficient — no @State needed (and @State +// is unusable here anyway, see Support/StateShim.swift). The model is passed +// explicitly down the view tree rather than injected via .environment, to +// match the explicit `model:` init parameter used by every screen-level view. +@main +struct SergeCodeApp: App { + private static let backend: any BackendService = SergeCodeApp.makeBackend() + private let model = AppModel(backend: SergeCodeApp.backend) + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + // MockBackend when launched with `--mock` or `SERGECODE_MOCK=1`; otherwise + // the real sidecar-backed LiveBackend. + private static func makeBackend() -> any BackendService { + if CommandLine.arguments.contains("--mock") + || ProcessInfo.processInfo.environment["SERGECODE_MOCK"] == "1" + { + return MockBackend() + } + return LiveBackend() + } + + var body: some Scene { + WindowGroup { + RootView(model: model) + .containerBackground(.thinMaterial, for: .window) + .onAppear { + model.start() + appDelegate.backend = SergeCodeApp.backend + } + } + .defaultSize(width: 1100, height: 720) + + Settings { + SettingsScene(model: model) + } + } +} + +/// Ensures `backend.stop()` (SIGTERM -> 2s grace -> SIGKILL of the node +/// sidecar, plus socket/subscription teardown) actually runs before the app +/// quits. Without this, quitting orphans the sidecar child process — +/// `Process` does not kill children when its owning process exits. +/// +/// Two deliberate choices here, both learned the hard way on this SDK: +/// - Raw SIGTERM/SIGINT (kill, launchd, logout) bypass +/// `applicationShouldTerminate` entirely, so DispatchSourceSignal routes +/// them into `NSApp.terminate`. +/// - `.terminateLater` + `reply(toApplicationShouldTerminate:)` is unusable +/// for async cleanup: while AppKit waits for the reply it spins a modal +/// run loop that services neither the main dispatch queue nor MainActor +/// tasks (verified empirically — GCD asyncAfter and MainActor Tasks both +/// starve), so the reply can never be sent from async work. Instead the +/// delegate blocks the main thread on a semaphore, bounded at 6s, while +/// `backend.stop()` runs on a detached task. That is safe precisely +/// because `BackendService.stop()` is actor- (not MainActor-) isolated +/// and never hops to the main actor; the UI is about to die anyway. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + var backend: (any BackendService)? + private var signalSources: [DispatchSourceSignal] = [] + private var didCleanup = false + + func applicationDidFinishLaunching(_ notification: Notification) { + for sig in [SIGTERM, SIGINT] { + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { NSApp.terminate(nil) } + source.resume() + signalSources.append(source) + } + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let backend, !didCleanup else { return .terminateNow } + didCleanup = true + let done = DispatchSemaphore(value: 0) + Task.detached { + await backend.stop() + done.signal() + } + // ServerProcess.stop() SIGKILLs the child after a 2s grace, so 6s + // covers the worst legitimate case; a hung teardown must never + // wedge quit. + _ = done.wait(timeout: .now() + 6) + return .terminateNow + } +} diff --git a/apps/mac/Sources/SergeCodeMac/ContentView.swift b/apps/mac/Sources/SergeCodeMac/ContentView.swift new file mode 100644 index 00000000000..1b8d911f6aa --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/ContentView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +// App shell. File kept as ContentView.swift for historical reasons but now +// hosts RootView, the top-level NavigationSplitView wired to AppModel. +struct RootView: View { + let model: AppModel + + @UIState private var showInspector = true + @UIState private var showNewSessionSheet = false + + var body: some View { + NavigationSplitView { + SidebarView(model: model) + .navigationSplitViewColumnWidth(min: 220, ideal: 280) + } detail: { + if let thread = model.selectedThread { + ThreadDetailView(model: model, thread: thread, showInspector: $showInspector) + } else { + EmptyStateView { + showNewSessionSheet = true + } + } + } + .toolbar { + ToolbarItem(placement: .navigation) { + ConnectionStatusPill(phase: model.connection) + } + ToolbarItem(placement: .primaryAction) { + newSessionMenu + } + ToolbarItem(placement: .primaryAction) { + Button { + showInspector.toggle() + } label: { + Label("Inspector", systemImage: "sidebar.right") + } + .disabled(model.selectedThread == nil) + } + } + .sheet(isPresented: $showNewSessionSheet) { + NewSessionSheet(model: model, isPresented: $showNewSessionSheet) + } + } + + /// Toolbar "New Session" menu: pick an existing project + provider + /// directly (calls model.createThread immediately), or fall through to + /// the glass sheet to add a new project first. + @ViewBuilder + private var newSessionMenu: some View { + Menu { + if model.projects.isEmpty { + Text("No projects yet") + } + ForEach(model.projects) { project in + Menu(project.name) { + ForEach(ProviderKind.allCases) { provider in + Button { + Task { await model.createThread(projectID: project.id, provider: provider) } + } label: { + Label(provider.displayName, systemImage: "bolt") + } + } + } + } + Divider() + Button { + showNewSessionSheet = true + } label: { + Label("Add Project…", systemImage: "folder.badge.plus") + } + } label: { + Label("New Session", systemImage: "plus") + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/Model/AppModel.swift b/apps/mac/Sources/SergeCodeMac/Model/AppModel.swift new file mode 100644 index 00000000000..137be64db86 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/Model/AppModel.swift @@ -0,0 +1,462 @@ +import Foundation +import Observation + +@Observable +@MainActor +public final class AppModel { + public private(set) var connection: ConnectionPhase = .launchingServer + public private(set) var projects: [Project] = [] + public private(set) var threads: [ChatThread] = [] + public private(set) var timelines: [String: [TimelineItem]] = [:] + public private(set) var providers: [ProviderInstance] = [] + public private(set) var diffs: [String: [DiffFile]] = [:] + public private(set) var checkpoints: [String: [Checkpoint]] = [:] + public private(set) var models: [ModelOption] = [] + public private(set) var contextWindows: [String: ContextWindowStatus] = [:] + public private(set) var planProgress: [String: PlanProgress] = [:] + public private(set) var vcsStatuses: [String: VcsStatus] = [:] + /// Outcome of the most recent git action, shown as a transient banner. + public var lastGitActionOutcome: GitActionOutcome? + + public var selectedThreadID: String? + public var lastError: String? + + private let backend: any BackendService + private var eventTask: Task? + + public init(backend: any BackendService) { + self.backend = backend + } + + public var selectedThread: ChatThread? { + threads.first { $0.id == selectedThreadID } + } + + public func selectedTimeline() -> [TimelineItem] { + selectedThreadID.flatMap { timelines[$0] } ?? [] + } + + // MARK: - Lifecycle + + public func start() { + guard eventTask == nil else { return } + let stream = backend.events + let backend = backend + eventTask = Task { [weak self] in + async let _ = backend.start() + for await event in stream { + self?.apply(event) + } + } + } + + public func shutdown() async { + eventTask?.cancel() + eventTask = nil + await backend.stop() + } + + private func apply(_ event: BackendEvent) { + switch event { + case .connection(let phase): + connection = phase + if phase == .ready { + Task { await refreshAll() } + } + case .projectsChanged(let list): + projects = list + case .threadUpserted(let thread): + if let index = threads.firstIndex(where: { $0.id == thread.id }) { + threads[index] = thread + } else { + threads.append(thread) + } + threads.sort { $0.updatedAt > $1.updatedAt } + case .threadRemoved(let id): + threads.removeAll { $0.id == id } + if selectedThreadID == id { selectedThreadID = nil } + case .timelineAppended(let threadID, let item): + timelines[threadID, default: []].append(item) + case .timelineReset(let threadID, let items): + timelines[threadID] = items + case .assistantDelta(let threadID, let messageID, let delta): + appendDelta(threadID: threadID, messageID: messageID, delta: delta) + case .assistantCompleted(let threadID, let messageID, let markdown): + finishStreaming(threadID: threadID, messageID: messageID, markdown: markdown) + case .approvalRequested, .userInputRequested: + break + case .approvalResolved(let id), .userInputResolved(let id): + for threadID in timelines.keys { + if let index = timelines[threadID]?.firstIndex(where: { $0.id == id }) { + timelines[threadID]?.remove(at: index) + break + } + } + case .diffInvalidated(let threadID): + // Diff invalidation always coincides with a checkpoint change + // (new checkpoint completed, or a revert pruned some), so refresh + // both — an open Checkpoints inspector stays current. + Task { + await refreshDiff(threadID: threadID) + await refreshCheckpoints(threadID: threadID) + } + case .providersChanged(let list): + providers = list + Task { await refreshModels() } + case .contextWindowUpdated(let threadID, let status): + contextWindows[threadID] = status + case .planProgressUpdated(let threadID, let progress): + planProgress[threadID] = progress + case .vcsStatusChanged(let projectID, let status): + vcsStatuses[projectID] = status + } + } + + private func appendDelta(threadID: String, messageID: String, delta: String) { + var items = timelines[threadID] ?? [] + for (index, item) in items.enumerated() { + if case .assistantMessage(let id, let markdown, _, let at) = item, id == messageID { + items[index] = .assistantMessage(id: id, markdown: markdown + delta, isStreaming: true, at: at) + timelines[threadID] = items + return + } + } + items.append(.assistantMessage(id: messageID, markdown: delta, isStreaming: true, at: Date())) + timelines[threadID] = items + } + + private func finishStreaming(threadID: String, messageID: String, markdown: String) { + guard var items = timelines[threadID] else { return } + for (index, item) in items.enumerated() { + if case .assistantMessage(let id, _, _, let at) = item, id == messageID { + items[index] = .assistantMessage(id: id, markdown: markdown, isStreaming: false, at: at) + timelines[threadID] = items + return + } + } + } + + // MARK: - Queries + + public func refreshAll() async { + do { + async let projects = backend.projects() + async let threads = backend.threads() + async let providers = backend.providers() + async let models = backend.models() + self.projects = try await projects + self.threads = try await threads.sorted { $0.updatedAt > $1.updatedAt } + self.providers = try await providers + self.models = try await models + } catch { + lastError = String(describing: error) + } + } + + public func refreshModels() async { + do { + models = try await backend.models() + } catch { + lastError = String(describing: error) + } + } + + public func loadTimelineIfNeeded(threadID: String) async { + guard timelines[threadID] == nil else { return } + do { + timelines[threadID] = try await backend.timeline(threadID: threadID) + } catch { + lastError = String(describing: error) + } + } + + public func refreshDiff(threadID: String) async { + do { + diffs[threadID] = try await backend.diff(threadID: threadID) + } catch { + lastError = String(describing: error) + } + } + + public func refreshCheckpoints(threadID: String) async { + do { + checkpoints[threadID] = try await backend.checkpoints(threadID: threadID) + } catch { + lastError = String(describing: error) + } + } + + // MARK: - Commands + + public func send(text: String, attachments: [OutgoingAttachment] = []) async { + guard let threadID = selectedThreadID, !(text.isEmpty && attachments.isEmpty) else { return } + do { + try await backend.sendMessage(threadID: threadID, text: text, attachments: attachments) + } catch { + lastError = String(describing: error) + } + } + + public func searchWorkspace(query: String) async -> [WorkspaceEntry] { + guard let projectID = selectedThread?.projectID else { return [] } + do { + return try await backend.searchWorkspace(projectID: projectID, query: query) + } catch { + // Mention search is best-effort UI sugar; a transient failure + // should not surface as a banner error. + return [] + } + } + + /// Slash commands for the selected thread's provider instance. + public var selectedThreadSlashCommands: [SlashCommandInfo] { + guard let thread = selectedThread else { return [] } + if let instanceID = thread.modelInstanceID, + let instance = providers.first(where: { $0.id == instanceID }) + { + return instance.slashCommands + } + return providers.first { $0.kind == thread.provider }?.slashCommands ?? [] + } + + public func createThread(projectID: String, provider: ProviderKind) async { + do { + let thread = try await backend.createThread(projectID: projectID, provider: provider) + selectedThreadID = thread.id + } catch { + lastError = String(describing: error) + } + } + + public func respond(to approval: ApprovalRequest, approve: Bool) async { + do { + try await backend.respondToApproval(id: approval.id, approve: approve) + } catch { + lastError = String(describing: error) + } + } + + public func respond(to request: UserInputRequest, answers: [String: [String]]) async { + do { + try await backend.respondToUserInput(id: request.id, answers: answers) + } catch { + lastError = String(describing: error) + } + } + + public func setRuntimeMode(_ mode: ThreadRuntimeMode) async { + guard let threadID = selectedThreadID else { return } + do { + try await backend.setRuntimeMode(threadID: threadID, mode: mode) + } catch { + lastError = String(describing: error) + } + } + + public func setInteractionMode(_ mode: ThreadInteractionMode) async { + guard let threadID = selectedThreadID else { return } + do { + try await backend.setInteractionMode(threadID: threadID, mode: mode) + } catch { + lastError = String(describing: error) + } + } + + public func setModel(_ model: ModelOption) async { + guard let threadID = selectedThreadID else { return } + do { + try await backend.setModel(threadID: threadID, model: model) + } catch { + lastError = String(describing: error) + } + } + + public func implementPlan(_ plan: ProposedPlan) async { + do { + try await backend.implementPlan(threadID: plan.threadID, planID: plan.id) + } catch { + lastError = String(describing: error) + } + } + + public func cancelCurrentTurn() async { + guard let threadID = selectedThreadID else { return } + do { + try await backend.cancelTurn(threadID: threadID) + } catch { + lastError = String(describing: error) + } + } + + public func restoreCheckpoint(_ checkpoint: Checkpoint) async { + do { + try await backend.restoreCheckpoint(id: checkpoint.id) + } catch { + lastError = String(describing: error) + } + } + + public func addProject(path: String) async { + do { + _ = try await backend.addProject(path: path) + await refreshAll() + } catch { + lastError = String(describing: error) + } + } + + // MARK: - Settings / providers / archive + + public private(set) var settings: AppSettings? + + public func loadSettings() async { + do { + settings = try await backend.settings() + } catch { + lastError = String(describing: error) + } + } + + public func saveSettings(_ new: AppSettings) async { + do { + settings = try await backend.updateSettings(new) + } catch { + lastError = String(describing: error) + } + } + + public func refreshProviders() async { + do { + try await backend.refreshProviders() + } catch { + lastError = String(describing: error) + } + } + + public func updateProvider(instanceID: String) async { + do { + try await backend.updateProvider(instanceID: instanceID) + } catch { + lastError = String(describing: error) + } + } + + // MARK: - Workspace files + + public func listWorkspace(subpath: String) async -> [WorkspaceEntry] { + guard let projectID = selectedThread?.projectID else { return [] } + do { + return try await backend.listWorkspace(projectID: projectID, subpath: subpath) + } catch { + lastError = String(describing: error) + return [] + } + } + + public func readWorkspaceFile(path: String) async -> FilePreview? { + guard let projectID = selectedThread?.projectID else { return nil } + do { + return try await backend.readWorkspaceFile(projectID: projectID, path: path) + } catch { + lastError = String(describing: error) + return nil + } + } + + public func openInEditor(subpath: String?, editor: ExternalEditor) async { + guard let projectID = selectedThread?.projectID else { return } + do { + try await backend.openInEditor(projectID: projectID, subpath: subpath, editor: editor) + } catch { + lastError = String(describing: error) + } + } + + // MARK: - Git / VCS + + public func selectedProjectVcsStatus() -> VcsStatus? { + guard let projectID = selectedThread?.projectID else { return nil } + return vcsStatuses[projectID] + } + + public func watchVcsStatus() async { + guard let projectID = selectedThread?.projectID else { return } + try? await backend.watchVcsStatus(projectID: projectID) + } + + public func listBranches(query: String?) async -> [BranchRef] { + guard let projectID = selectedThread?.projectID else { return [] } + do { + return try await backend.listBranches(projectID: projectID, query: query) + } catch { + lastError = String(describing: error) + return [] + } + } + + public func switchBranch(_ name: String) async { + guard let projectID = selectedThread?.projectID else { return } + do { + try await backend.switchBranch(projectID: projectID, name: name) + } catch { + lastError = String(describing: error) + } + } + + public func createBranch(_ name: String) async { + guard let projectID = selectedThread?.projectID else { return } + do { + try await backend.createBranch(projectID: projectID, name: name) + } catch { + lastError = String(describing: error) + } + } + + public func pull() async { + guard let projectID = selectedThread?.projectID else { return } + do { + try await backend.pull(projectID: projectID) + } catch { + lastError = String(describing: error) + } + } + + public func runGitAction(_ action: GitAction, commitMessage: String?) async { + guard let projectID = selectedThread?.projectID else { return } + do { + lastGitActionOutcome = try await backend.runGitAction( + projectID: projectID, action: action, commitMessage: commitMessage) + } catch { + lastGitActionOutcome = GitActionOutcome( + success: false, title: "Git action failed", detail: String(describing: error)) + } + } + + public var archivedThreads: [ChatThread] { + threads.filter { $0.status == .archived } + } + + public func archiveThread(_ thread: ChatThread) async { + do { + try await backend.archiveThread(id: thread.id) + } catch { + lastError = String(describing: error) + } + } + + public func unarchiveThread(_ thread: ChatThread) async { + do { + try await backend.unarchiveThread(id: thread.id) + } catch { + lastError = String(describing: error) + } + } + + public func deleteThread(_ thread: ChatThread) async { + do { + try await backend.deleteThread(id: thread.id) + } catch { + lastError = String(describing: error) + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/Model/BackendService.swift b/apps/mac/Sources/SergeCodeMac/Model/BackendService.swift new file mode 100644 index 00000000000..932aa2722c6 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/Model/BackendService.swift @@ -0,0 +1,107 @@ +import Foundation + +// Seam between the UI and the transport. LiveBackend (T3Kit + SidecarKit) +// and MockBackend both implement this; the UI only ever sees BackendService. + +public enum BackendEvent: Sendable { + case connection(ConnectionPhase) + /// Full replacement of the project list — emitted whenever the backend's + /// project set changes (snapshot, upsert, removal), so consumers never + /// depend on polling `projects()` at the right moment. + case projectsChanged([Project]) + case threadUpserted(ChatThread) + case threadRemoved(id: String) + case timelineAppended(threadID: String, item: TimelineItem) + /// Full replacement of a thread's timeline — emitted when the backend has + /// re-derived the timeline from a fresh snapshot after the caller already + /// had one cached (e.g. a reconnect re-subscribe). Consumers should + /// overwrite their cached timeline for `threadID` wholesale rather than + /// appending. + case timelineReset(threadID: String, items: [TimelineItem]) + case assistantDelta(threadID: String, messageID: String, delta: String) + /// `markdown` is the server's authoritative final text for the message — + /// callers should replace their accumulated/delta-built text with it + /// rather than trusting the locally-accumulated deltas, since a delta can + /// be lossy (see `LiveBackend.assistantDelta(new:old:)`). + case assistantCompleted(threadID: String, messageID: String, markdown: String) + case approvalRequested(ApprovalRequest) + case approvalResolved(id: String) + case userInputRequested(UserInputRequest) + case userInputResolved(id: String) + case diffInvalidated(threadID: String) + case providersChanged([ProviderInstance]) + case contextWindowUpdated(threadID: String, status: ContextWindowStatus) + case planProgressUpdated(threadID: String, progress: PlanProgress) + case vcsStatusChanged(projectID: String, status: VcsStatus) +} + +public protocol BackendService: Sendable { + /// Long-lived event stream; UI consumes exactly once from AppModel. + var events: AsyncStream { get } + + func start() async + func stop() async + + func projects() async throws -> [Project] + func threads() async throws -> [ChatThread] + func timeline(threadID: String) async throws -> [TimelineItem] + func providers() async throws -> [ProviderInstance] + + /// Every selectable (instance, model) pair across configured providers. + func models() async throws -> [ModelOption] + + /// Fuzzy filename search in a project's workspace (composer @-mentions). + func searchWorkspace(projectID: String, query: String) async throws -> [WorkspaceEntry] + /// Entries under a directory of the project workspace ("" = root). + func listWorkspace(projectID: String, subpath: String) async throws -> [WorkspaceEntry] + /// Read one workspace file (server truncates very large files). + func readWorkspaceFile(projectID: String, path: String) async throws -> FilePreview + /// Open the project (or a path inside it) in an external editor. + func openInEditor(projectID: String, subpath: String?, editor: ExternalEditor) async throws + + func createThread(projectID: String, provider: ProviderKind) async throws -> ChatThread + func archiveThread(id: String) async throws + func unarchiveThread(id: String) async throws + func deleteThread(id: String) async throws + func sendMessage(threadID: String, text: String, attachments: [OutgoingAttachment]) async throws + func cancelTurn(threadID: String) async throws + func respondToApproval(id: String, approve: Bool) async throws + /// Answer a pending user-input request. `answers` maps each question id + /// to the selected option labels (single-element unless multi-select) or + /// one free-form string. + func respondToUserInput(id: String, answers: [String: [String]]) async throws + + func setRuntimeMode(threadID: String, mode: ThreadRuntimeMode) async throws + func setInteractionMode(threadID: String, mode: ThreadInteractionMode) async throws + /// Repoint the thread at a different provider instance/model. + func setModel(threadID: String, model: ModelOption) async throws + /// Start an implementation turn from a proposed plan (plan-mode follow-up). + func implementPlan(threadID: String, planID: String) async throws + + func diff(threadID: String) async throws -> [DiffFile] + func checkpoints(threadID: String) async throws -> [Checkpoint] + func restoreCheckpoint(id: String) async throws + + func addProject(path: String) async throws -> Project + + /// Start (or keep) a live VCS status subscription for a project; status + /// arrives via `.vcsStatusChanged` events. + func watchVcsStatus(projectID: String) async throws + func listBranches(projectID: String, query: String?) async throws -> [BranchRef] + func switchBranch(projectID: String, name: String) async throws + func createBranch(projectID: String, name: String) async throws + func pull(projectID: String) async throws + /// Runs a stacked commit/push/PR pipeline to completion. + func runGitAction( + projectID: String, action: GitAction, commitMessage: String? + ) async throws -> GitActionOutcome + + /// Server-side settings (the editable subset). + func settings() async throws -> AppSettings + /// Applies the full editable subset as a patch; returns the merged result. + func updateSettings(_ settings: AppSettings) async throws -> AppSettings + /// Ask the server to re-probe installed provider CLIs. + func refreshProviders() async throws + /// Run a provider CLI's own update command. + func updateProvider(instanceID: String) async throws +} diff --git a/apps/mac/Sources/SergeCodeMac/Model/Entities.swift b/apps/mac/Sources/SergeCodeMac/Model/Entities.swift new file mode 100644 index 00000000000..125638f9d0b --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/Model/Entities.swift @@ -0,0 +1,572 @@ +import Foundation + +// UI-level domain model. T3Kit maps wire types into these; MockBackend fakes +// them. Keep UI code independent of wire-shape churn. + +public enum ProviderKind: String, Codable, CaseIterable, Sendable, Identifiable { + case claude, codex, cursor, opencode + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .claude: "Claude Code" + case .codex: "Codex" + case .cursor: "Cursor" + case .opencode: "OpenCode" + } + } +} + +public struct Project: Identifiable, Hashable, Sendable { + public var id: String + public var name: String + public var path: String + + public init(id: String, name: String, path: String) { + self.id = id + self.name = name + self.path = path + } +} + +public enum ThreadStatus: String, Sendable { + case idle, running, waitingApproval, error, archived +} + +/// UI mirror of the wire `RuntimeMode` (how much the agent may do unprompted). +public enum ThreadRuntimeMode: String, CaseIterable, Sendable, Identifiable { + case approvalRequired, autoAcceptEdits, fullAccess + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .approvalRequired: "Approvals required" + case .autoAcceptEdits: "Auto-accept edits" + case .fullAccess: "Full access" + } + } + + public var symbolName: String { + switch self { + case .approvalRequired: "hand.raised" + case .autoAcceptEdits: "pencil.circle" + case .fullAccess: "bolt.circle" + } + } +} + +/// UI mirror of the wire `ProviderInteractionMode` (plan-first vs direct). +public enum ThreadInteractionMode: String, CaseIterable, Sendable, Identifiable { + case normal, plan + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .normal: "Default" + case .plan: "Plan" + } + } +} + +/// One selectable model of one provider instance (model picker rows). +public struct ModelOption: Identifiable, Hashable, Sendable { + public var instanceID: String + public var modelID: String + public var displayName: String + public var provider: ProviderKind + public var isDefault: Bool + + public var id: String { "\(instanceID)/\(modelID)" } + + public init( + instanceID: String, modelID: String, displayName: String, provider: ProviderKind, + isDefault: Bool + ) { + self.instanceID = instanceID + self.modelID = modelID + self.displayName = displayName + self.provider = provider + self.isDefault = isDefault + } +} + +public struct ChatThread: Identifiable, Hashable, Sendable { + public var id: String + public var projectID: String + public var title: String + public var provider: ProviderKind + public var status: ThreadStatus + public var updatedAt: Date + public var runtimeMode: ThreadRuntimeMode + public var interactionMode: ThreadInteractionMode + /// Provider-instance + model slug backing this thread (from its + /// modelSelection); used to mark the active row in the model picker. + public var modelInstanceID: String? + public var modelID: String? + + public init( + id: String, projectID: String, title: String, provider: ProviderKind, + status: ThreadStatus, updatedAt: Date, + runtimeMode: ThreadRuntimeMode = .fullAccess, + interactionMode: ThreadInteractionMode = .normal, + modelInstanceID: String? = nil, modelID: String? = nil + ) { + self.id = id + self.projectID = projectID + self.title = title + self.provider = provider + self.status = status + self.updatedAt = updatedAt + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + self.modelInstanceID = modelInstanceID + self.modelID = modelID + } +} + +public enum ToolEventStatus: String, Sendable { + case running, succeeded, failed +} + +public enum TimelineItem: Identifiable, Sendable { + case userMessage(id: String, text: String, at: Date) + case assistantMessage(id: String, markdown: String, isStreaming: Bool, at: Date) + case toolEvent(id: String, name: String, detail: String, status: ToolEventStatus, at: Date) + case approval(ApprovalRequest) + case userInput(UserInputRequest) + case checkpoint(Checkpoint) + case plan(ProposedPlan) + case notice(id: String, text: String, at: Date) + + public var id: String { + switch self { + case .userMessage(let id, _, _): id + case .assistantMessage(let id, _, _, _): id + case .toolEvent(let id, _, _, _, _): id + case .approval(let request): request.id + case .userInput(let request): request.id + case .checkpoint(let checkpoint): checkpoint.id + case .plan(let plan): plan.id + case .notice(let id, _, _): id + } + } +} + +public enum ApprovalKind: String, Sendable { + case command, fileEdit, other +} + +public struct ApprovalRequest: Identifiable, Hashable, Sendable { + public var id: String + public var threadID: String + public var kind: ApprovalKind + public var title: String + public var detail: String + public var createdAt: Date + + public init(id: String, threadID: String, kind: ApprovalKind, title: String, detail: String, createdAt: Date) { + self.id = id + self.threadID = threadID + self.kind = kind + self.title = title + self.detail = detail + self.createdAt = createdAt + } +} + +/// One option of a `UserInputQuestion`. +public struct UserInputOption: Hashable, Sendable { + public var label: String + public var detail: String? + + public init(label: String, detail: String? = nil) { + self.label = label + self.detail = detail + } +} + +/// One question in a user-input request. Empty `options` means free-form. +public struct UserInputQuestionItem: Identifiable, Hashable, Sendable { + public var id: String + public var header: String + public var question: String + public var options: [UserInputOption] + public var multiSelect: Bool + + public init( + id: String, header: String, question: String, options: [UserInputOption], + multiSelect: Bool + ) { + self.id = id + self.header = header + self.question = question + self.options = options + self.multiSelect = multiSelect + } +} + +/// A provider prompt the user must answer before the turn continues +/// (distinct from approvals — option-based and/or free-form questions). +public struct UserInputRequest: Identifiable, Hashable, Sendable { + public var id: String + public var threadID: String + public var questions: [UserInputQuestionItem] + public var createdAt: Date + + public init(id: String, threadID: String, questions: [UserInputQuestionItem], createdAt: Date) { + self.id = id + self.threadID = threadID + self.questions = questions + self.createdAt = createdAt + } +} + +/// Live token-usage snapshot for a thread's context window. +public struct ContextWindowStatus: Hashable, Sendable { + public var usedTokens: Int + public var maxTokens: Int? + + public init(usedTokens: Int, maxTokens: Int?) { + self.usedTokens = usedTokens + self.maxTokens = maxTokens + } + + /// 0...1 fraction of the window consumed; nil when the max is unknown. + public var usedFraction: Double? { + guard let maxTokens, maxTokens > 0 else { return nil } + return min(1, Double(usedTokens) / Double(maxTokens)) + } +} + +public enum PlanStepStatus: String, Sendable { + case pending, inProgress, completed +} + +/// One step of the agent's live in-turn todo/plan list. +public struct PlanStep: Identifiable, Hashable, Sendable { + public var id: Int + public var title: String + public var status: PlanStepStatus + + public init(id: Int, title: String, status: PlanStepStatus) { + self.id = id + self.title = title + self.status = status + } +} + +/// The agent's live todo list for the running turn (TodoWrite equivalent). +public struct PlanProgress: Hashable, Sendable { + public var steps: [PlanStep] + public var explanation: String? + + public init(steps: [PlanStep], explanation: String?) { + self.steps = steps + self.explanation = explanation + } +} + +/// A plan the agent proposed in plan mode; the user can start an +/// implementation turn from it. +public struct ProposedPlan: Identifiable, Hashable, Sendable { + public var id: String + public var threadID: String + public var markdown: String + public var isImplemented: Bool + public var createdAt: Date + + public init(id: String, threadID: String, markdown: String, isImplemented: Bool, createdAt: Date) { + self.id = id + self.threadID = threadID + self.markdown = markdown + self.isImplemented = isImplemented + self.createdAt = createdAt + } +} + +public struct Checkpoint: Identifiable, Hashable, Sendable { + public var id: String + public var threadID: String + public var label: String + public var createdAt: Date + + public init(id: String, threadID: String, label: String, createdAt: Date) { + self.id = id + self.threadID = threadID + self.label = label + self.createdAt = createdAt + } +} + +public enum DiffLineKind: Sendable { + case context, addition, deletion +} + +public struct DiffLine: Identifiable, Sendable { + public var id = UUID() + public var kind: DiffLineKind + public var text: String + public var oldNumber: Int? + public var newNumber: Int? + + public init(kind: DiffLineKind, text: String, oldNumber: Int?, newNumber: Int?) { + self.kind = kind + self.text = text + self.oldNumber = oldNumber + self.newNumber = newNumber + } +} + +public struct DiffHunk: Identifiable, Sendable { + public var id = UUID() + public var header: String + public var lines: [DiffLine] + + public init(header: String, lines: [DiffLine]) { + self.header = header + self.lines = lines + } +} + +public enum DiffFileStatus: String, Sendable { + case added, modified, deleted, renamed +} + +public struct DiffFile: Identifiable, Sendable { + public var id: String { path } + public var path: String + public var status: DiffFileStatus + public var hunks: [DiffHunk] + + public init(path: String, status: DiffFileStatus, hunks: [DiffHunk]) { + self.path = path + self.status = status + self.hunks = hunks + } +} + +public enum ProviderAvailability: String, Sendable { + case available, missing, authRequired +} + +/// A provider-native slash command (typed into the composer as `/name`). +public struct SlashCommandInfo: Identifiable, Hashable, Sendable { + public var name: String + public var detail: String? + public var argumentHint: String? + + public var id: String { name } + + public init(name: String, detail: String? = nil, argumentHint: String? = nil) { + self.name = name + self.detail = detail + self.argumentHint = argumentHint + } +} + +public struct ProviderInstance: Identifiable, Sendable { + public var id: String + public var kind: ProviderKind + public var availability: ProviderAvailability + public var version: String? + public var slashCommands: [SlashCommandInfo] + + public init( + id: String, kind: ProviderKind, availability: ProviderAvailability, version: String?, + slashCommands: [SlashCommandInfo] = [] + ) { + self.id = id + self.kind = kind + self.availability = availability + self.version = version + self.slashCommands = slashCommands + } +} + +/// One file/directory hit from a workspace search (composer @-mentions). +public struct WorkspaceEntry: Identifiable, Hashable, Sendable { + public var path: String + public var isDirectory: Bool + + public var id: String { path } + + public init(path: String, isDirectory: Bool) { + self.path = path + self.isDirectory = isDirectory + } +} + +/// Contents of one workspace file for the inspector preview. +public struct FilePreview: Hashable, Sendable { + public var path: String + public var contents: String + public var truncated: Bool + + public init(path: String, contents: String, truncated: Bool) { + self.path = path + self.contents = contents + self.truncated = truncated + } +} + +/// External editors the server's launcher can open a path in +/// (subset of contracts editor.ts `EDITORS`). +public enum ExternalEditor: String, CaseIterable, Sendable, Identifiable { + case vscode, cursor, zed, fileManager = "file-manager" + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .vscode: "VS Code" + case .cursor: "Cursor" + case .zed: "Zed" + case .fileManager: "Finder" + } + } +} + +/// An image staged in the composer, ready to upload with the next turn. +public struct OutgoingAttachment: Identifiable, Hashable, Sendable { + public var id: String + public var name: String + public var mimeType: String + public var sizeBytes: Int + /// base64 `data:` URL — the wire upload shape embeds bytes inline. + public var dataURL: String + + public init(id: String, name: String, mimeType: String, sizeBytes: Int, dataURL: String) { + self.id = id + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.dataURL = dataURL + } +} + +// MARK: - Git / VCS + +/// Working-tree + branch + PR status for one project repo. +public struct VcsStatus: Hashable, Sendable { + public var isRepo: Bool + public var branch: String? + public var isDefaultBranch: Bool + public var changedFileCount: Int + public var insertions: Int + public var deletions: Int + public var aheadCount: Int + public var behindCount: Int + public var hasUpstream: Bool + public var prNumber: Int? + public var prTitle: String? + public var prURL: String? + + public init( + isRepo: Bool, branch: String?, isDefaultBranch: Bool, changedFileCount: Int, + insertions: Int, deletions: Int, aheadCount: Int, behindCount: Int, hasUpstream: Bool, + prNumber: Int? = nil, prTitle: String? = nil, prURL: String? = nil + ) { + self.isRepo = isRepo + self.branch = branch + self.isDefaultBranch = isDefaultBranch + self.changedFileCount = changedFileCount + self.insertions = insertions + self.deletions = deletions + self.aheadCount = aheadCount + self.behindCount = behindCount + self.hasUpstream = hasUpstream + self.prNumber = prNumber + self.prTitle = prTitle + self.prURL = prURL + } +} + +public struct BranchRef: Identifiable, Hashable, Sendable { + public var name: String + public var isCurrent: Bool + public var isDefault: Bool + public var isRemote: Bool + + public var id: String { (isRemote ? "remote/" : "local/") + name } + + public init(name: String, isCurrent: Bool, isDefault: Bool, isRemote: Bool) { + self.name = name + self.isCurrent = isCurrent + self.isDefault = isDefault + self.isRemote = isRemote + } +} + +/// The stacked git pipelines the toolbar offers. +public enum GitAction: String, CaseIterable, Sendable, Identifiable { + case commit, push, commitPush, commitPushPR + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .commit: "Commit" + case .push: "Push" + case .commitPush: "Commit & Push" + case .commitPushPR: "Commit, Push & Open PR" + } + } + + public var needsCommitMessage: Bool { + self != .push + } +} + +/// Terminal outcome of a stacked git action. +public struct GitActionOutcome: Hashable, Sendable { + public var success: Bool + public var title: String + public var detail: String? + public var prURL: String? + + public init(success: Bool, title: String, detail: String? = nil, prURL: String? = nil) { + self.success = success + self.title = title + self.detail = detail + self.prURL = prURL + } +} + +/// Where new threads run: the project checkout itself or a fresh worktree. +public enum ProjectEnvMode: String, CaseIterable, Sendable, Identifiable { + case local, worktree + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .local: "Project directory" + case .worktree: "Isolated worktree" + } + } +} + +/// The editable server-settings subset surfaced in the Settings scene. +public struct AppSettings: Hashable, Sendable { + public var assistantStreaming: Bool + public var providerUpdateChecks: Bool + public var defaultEnvMode: ProjectEnvMode + public var newWorktreesStartFromOrigin: Bool + public var addProjectBaseDirectory: String + + public init( + assistantStreaming: Bool, providerUpdateChecks: Bool, defaultEnvMode: ProjectEnvMode, + newWorktreesStartFromOrigin: Bool, addProjectBaseDirectory: String + ) { + self.assistantStreaming = assistantStreaming + self.providerUpdateChecks = providerUpdateChecks + self.defaultEnvMode = defaultEnvMode + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.addProjectBaseDirectory = addProjectBaseDirectory + } +} + +public enum ConnectionPhase: Sendable, Equatable { + case launchingServer + case connecting + case ready + case reconnecting(attempt: Int) + case failed(String) +} diff --git a/apps/mac/Sources/SergeCodeMac/Model/LiveBackend.swift b/apps/mac/Sources/SergeCodeMac/Model/LiveBackend.swift new file mode 100644 index 00000000000..3f2eead7998 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/Model/LiveBackend.swift @@ -0,0 +1,1701 @@ +import Foundation +import SidecarKit +import T3Kit + +// LiveBackend — the real BackendService: composes SidecarKit.ServerProcess +// (spawns/supervises the Node t3 server child) with T3Kit's AuthClient + +// RpcConnection + T3Client (authenticated effect-RPC-over-WebSocket), and +// translates the wire protocol (docs/wire-protocol.md) into the UI-level +// BackendService surface (BackendEvent + the domain entities in Entities.swift). +// +// Isolation: this is an `actor`. All mutable projection state lives here; the +// four composed pieces (ServerProcess/AuthClient/RpcConnection/T3Client) are +// themselves Sendable actors. The `events` stream + its continuation are +// nonisolated `let`s so AppModel can grab the stream synchronously. +// +// ── Lifecycle ──────────────────────────────────────────────────────────────── +// start(): locate node -> build SidecarConfig (free port, default baseDir +// ~/Library/Application Support/SergeCode, entry from +// $SERGECODE_SERVER_ENTRY or the dev resolver) -> spawn sidecar -> +// observe SidecarState. +// SidecarState -> ConnectionPhase: +// .launching(0) -> .launchingServer +// .launching(n>0) -> .reconnecting(n) +// .ready(pid) -> open a *socket session* (auth + connect + +// getConfig + subscribe); emits .connecting +// then .ready. +// .crashed(_, n) -> tear down socket session, .reconnecting(n+1) +// (the sidecar auto-restarts w/ backoff). +// .stopped -> tear down socket session (no phase; a +// deliberate stop, not a failure). +// Socket-level reconnect: while the sidecar is alive, a dropped socket is +// retried with exponential backoff (reusing ServerProcess.backoffDelay), a +// fresh wsTicket minted per attempt (AuthClient.makeSocketURL, per §4.3/§risk8). +// +// ── Mapping decisions (wire -> UI) — best-effort, documented, never silent ──── +// * ProviderKind: derived from ServerProvider.driver by substring match +// (claude/codex/cursor/opencode). Drivers with no ProviderKind equivalent +// (e.g. "grok") are dropped from providers() — ProviderKind is a closed enum +// with no `.other`. See `providerKind(fromDriver:)`. +// * A thread's ProviderKind is resolved from its modelSelection.instanceId via +// the ServerConfig provider table, falling back to session.providerName, then +// to `.claude`. Documented in `resolveProviderKind`. +// * ThreadStatus is a projection of session.status + latestTurn.state + +// hasPendingApprovals + archivedAt (see `mapStatus`); the shell subscription +// is the source of truth for status (per-thread `thread.session-set` events +// are intentionally NOT used to mutate status, to avoid fighting the shell +// projection which already reflects session changes). +// * Assistant streaming: the wire has no per-token delta on subscribeThread; +// growing `thread.message-sent` payloads for the same messageId are diffed +// into `assistantDelta`s (prefix-suffix). A non-append replacement can't be +// expressed as a delta and is skipped (see `assistantDelta(new:old:)`). +// * OrchestrationProposedPlan has no dedicated TimelineItem case -> rendered as +// a `.notice`. System messages -> `.notice`. Activity tones map: .tool -> +// toolEvent(.succeeded), .error -> toolEvent(.failed), .info -> notice. +// * Approvals: the wire `requestId` needed to respond is extracted best-effort +// from the opaque activity payload (OrchestrationMapping.extractRequestId), +// falling back to the activity id. The (threadId, requestId) route is +// remembered per approval id so respondToApproval can dispatch. If the id +// can't be routed, respondToApproval throws (never silently no-ops). +// * Diff: getFullThreadDiff(toTurnCount:) needs a turn count; we track the +// highest checkpointTurnCount seen per thread. Before any completed turn we +// return [] (graceful — no diff yet). The unified-diff string is parsed by +// UnifiedDiffParser below. +// * Checkpoints: OrchestrationCheckpointSummary.checkpointRef is used as the +// UI Checkpoint.id; restoreCheckpoint routes id -> (threadId, turnCount). + +public actor LiveBackend: BackendService { + + // MARK: BackendService event stream + + public nonisolated let events: AsyncStream + private nonisolated let continuation: AsyncStream.Continuation + + // MARK: Composed transport + + private var authClient: AuthClient? + private var serverProcess: ServerProcess? + private var currentConnection: RpcConnection? + private var currentClient: T3Client? + + // MARK: Supervising tasks + + private var sidecarStatesTask: Task? + private var socketSessionTask: Task? + private var threadSubscriptions: [String: Task] = [:] + + // MARK: Projection state (source for the query methods) + + private var projectsByID: [String: Project] = [:] + private var threadsByID: [String: ChatThread] = [:] + private var providersByInstanceId: [String: ServerProvider] = [:] + + /// Threads the UI has opened; re-subscribed on every reconnect. + private var activeThreadIDs: Set = [] + /// Latest mapped timeline per opened thread (returned by `timeline`). + private var latestTimeline: [String: [TimelineItem]] = [:] + /// Callers awaiting a thread's first snapshot before `timeline` can return. + private var snapshotWaiters: [String: [CheckedContinuation<[TimelineItem], Error>]] = [:] + + /// Per-thread dedup + delta-tracking, seeded from each snapshot. + private var seenMessageIDs: [String: Set] = [:] + private var assistantTextByMessage: [String: [String: String]] = [:] + /// Dedup sets for the non-message timeline kinds, seeded from each + /// snapshot. The server's subscribeThread live tail is not filtered by + /// snapshot sequence (unlike subscribeShell), so an activity/checkpoint/ + /// plan present in the snapshot can also arrive on the live tail. + private var seenActivityIDs: [String: Set] = [:] + private var seenCheckpointRefs: [String: Set] = [:] + private var seenPlanIDs: [String: Set] = [:] + + /// Approval id -> (threadId, wire requestId) for respondToApproval. + private var approvalRoutes: [String: (threadID: String, requestId: String)] = [:] + /// User-input request id -> (dispatch route + the request itself, kept so + /// answers can be encoded per-question: multi-select -> array, else string). + private var userInputRoutes: [String: (threadID: String, requestId: String, request: UserInputRequest)] = [:] + /// Checkpoint id (checkpointRef) -> (threadId, turnCount) for restore. + private var checkpointRoutes: [String: (threadID: String, turnCount: Int)] = [:] + private var checkpointsByThread: [String: [Checkpoint]] = [:] + /// Highest completed-turn count per thread, used as getFullThreadDiff's `toTurnCount`. + private var currentTurnCount: [String: Int] = [:] + + /// Verification-only breadcrumb path (see `emit`), opt-in via + /// `$SERGECODE_DEBUG_LOG` (e.g. for a manual `open`-launched run whose + /// stdio isn't attached to a terminal). `nil` — the default for every + /// normal/user launch — disables the file breadcrumb entirely so nothing + /// grows unbounded on a real user's machine; the connection-phase line + /// still goes to stderr either way (direct/terminal launches only). + private static let debugLogPath: String? = { + guard let path = ProcessInfo.processInfo.environment["SERGECODE_DEBUG_LOG"] else { + return nil + } + try? FileManager.default.createDirectory( + atPath: (path as NSString).deletingLastPathComponent, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + return path + }() + + public init() { + let (stream, continuation) = AsyncStream.makeStream() + self.events = stream + self.continuation = continuation + } + + // MARK: - Lifecycle + + public func start() async { + guard serverProcess == nil else { return } + emit(.connection(.launchingServer)) + + let token = BootstrapTokenGenerator.generate() + + let nodePath: String + do { + nodePath = try NodeRuntimeLocator().locate().path + } catch { + emit(.connection(.failed("Could not locate a compatible Node.js runtime: \(error)"))) + return + } + + let entryPath = + ProcessInfo.processInfo.environment["SERGECODE_SERVER_ENTRY"] + ?? SidecarEntryPathResolver.devDefaultEntryPath() + + let sidecarConfig: SidecarConfig + do { + sidecarConfig = try SidecarConfig(nodePath: nodePath, entryPath: entryPath) + } catch { + emit(.connection(.failed("Could not configure the server sidecar: \(error)"))) + return + } + + let kit = T3KitConfig( + host: sidecarConfig.host, port: sidecarConfig.port, desktopBootstrapToken: token) + authClient = AuthClient(config: kit.authConfig) + + let process = ServerProcess(config: sidecarConfig, bootstrapToken: token) + serverProcess = process + + let states = await process.states() + sidecarStatesTask = Task { [weak self] in + for await state in states { + await self?.handleSidecarState(state) + } + } + + await process.start() + } + + public func stop() async { + sidecarStatesTask?.cancel() + sidecarStatesTask = nil + socketSessionTask?.cancel() + socketSessionTask = nil + cancelAllThreadSubscriptions() + cancelAllVcsSubscriptions() + + if let conn = currentConnection { + currentConnection = nil + await conn.disconnect(reason: "client stop") + } + currentClient = nil + + if let process = serverProcess { + serverProcess = nil + await process.stop() + } + + failAllSnapshotWaiters(error: LiveBackendError.notConnected) + continuation.finish() + } + + // MARK: - Sidecar state -> connection phase + + private func handleSidecarState(_ state: SidecarState) async { + switch state { + case .idle: + break + case .launching(let attempt): + emit(.connection(attempt == 0 ? .launchingServer : .reconnecting(attempt: attempt))) + case .ready: + startSocketSession() + case .crashed(_, let attempt): + await teardownSocketSession() + // The sidecar auto-restarts; surface as reconnecting rather than a + // hard failure so the UI keeps its spinner instead of erroring out. + emit(.connection(.reconnecting(attempt: attempt + 1))) + case .stopped: + await teardownSocketSession() + } + } + + private func startSocketSession() { + socketSessionTask?.cancel() + socketSessionTask = Task { [weak self] in + await self?.runSocketSession() + } + } + + private func teardownSocketSession() async { + socketSessionTask?.cancel() + socketSessionTask = nil + cancelAllThreadSubscriptions() + cancelAllVcsSubscriptions() + currentClient = nil + if let conn = currentConnection { + currentConnection = nil + await conn.disconnect(reason: "sidecar restart") + } + } + + // MARK: - Socket session (connect + subscribe, with socket-level reconnect) + + private func runSocketSession() async { + var attempt = 0 + while !Task.isCancelled { + guard let auth = authClient else { return } + // Kept outside the `do` so the catch can close a socket that was + // opened before the failure (e.g. getConfig / subscription setup + // throwing an RPC or decode error): without an explicit + // disconnect its receive/ping loops would keep running alongside + // the next attempt's fresh connection. + var attemptConnection: RpcConnection? + do { + emit(.connection(attempt == 0 ? .connecting : .reconnecting(attempt: attempt))) + + // Fresh wsTicket per attempt (tickets are single-use / short-lived). + let url = try await auth.makeSocketURL() + let conn = RpcConnection(url: url) + attemptConnection = conn + try await conn.connect() + let client = T3Client(transport: conn) + + // Initial sync — populates providers before we announce ready. + let config = try await client.getConfig() + applyProviders(config.providers) + + currentConnection = conn + currentClient = client + attempt = 0 + emit(.connection(.ready)) + + // Blocks until a subscription/connection failure (throws) or a + // clean stream close (returns) — both mean: reconnect. + try await runSubscriptions(client: client, conn: conn) + + currentClient = nil + currentConnection = nil + await conn.disconnect(reason: "reconnect") + // runSubscriptions returned rather than threw: an unexpected + // clean stream end (the common socket-drop path throws via + // watchConnection's `.closed` -> the `catch` below). Back off + // here too so a server that repeatedly completes a + // subscription stream cleanly can't spin a tight + // reconnect/auth loop with no delay between attempts. + if Task.isCancelled { return } + attempt += 1 + emit(.connection(.reconnecting(attempt: attempt))) + let cleanEndDelay = ServerProcess.backoffDelay(forAttempt: attempt) + try? await Task.sleep(nanoseconds: UInt64(cleanEndDelay * 1_000_000_000)) + } catch { + currentClient = nil + currentConnection = nil + if let conn = attemptConnection { + // Idempotent if the socket already closed itself (the + // common drop path); required for non-transport failures. + await conn.disconnect(reason: "socket session failed") + } + if Task.isCancelled { return } + attempt += 1 + emit(.connection(.reconnecting(attempt: attempt))) + let delay = ServerProcess.backoffDelay(forAttempt: attempt) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + } + + private func runSubscriptions(client: T3Client, conn: RpcConnection) async throws { + // Re-establish per-thread subscriptions on the fresh client (§4.3: no + // resume token, every stream re-subscribes from a new snapshot). + for id in activeThreadIDs { + startThreadSubscription(id, client: client) + } + + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { [weak self] in + guard let self else { return } + try await self.consumeShell(client) + } + group.addTask { [weak self] in + guard let self else { return } + try await self.consumeServerConfig(client) + } + group.addTask { [weak self] in + guard let self else { return } + try await self.watchConnection(conn) + } + // First child to finish/throw ends the session; cancel the rest. + try await group.next() + group.cancelAll() + } + } catch { + cancelAllThreadSubscriptions() + throw error + } + cancelAllThreadSubscriptions() + } + + private func consumeShell(_ client: T3Client) async throws { + let stream = await client.subscribeShell() + for try await item in stream { + handleShellItem(item) + } + } + + private func consumeServerConfig(_ client: T3Client) async throws { + let stream = await client.subscribeServerConfig() + for try await event in stream { + handleServerConfigEvent(event) + } + } + + private func watchConnection(_ conn: RpcConnection) async throws { + for await state in conn.stateUpdates { + if case .closed(let reason) = state { + throw T3Error.connectionClosed(reason: reason) + } + } + } + + // MARK: - Shell subscription (project + thread list) + + private func handleShellItem(_ item: OrchestrationShellStreamItem) { + switch item { + case .snapshot(let snapshot): + // The snapshot is the authoritative current state, so reconcile + // rather than merge: anything deleted while the socket was down + // gets no replayed removal event and must be dropped here. + projectsByID = Dictionary( + snapshot.projects.map { ($0.id, mapProject($0)) }, + uniquingKeysWith: { _, new in new }) + emit(.projectsChanged(currentProjectList())) + let snapshotThreadIDs = Set(snapshot.threads.map(\.id)) + for id in threadsByID.keys where !snapshotThreadIDs.contains(id) { + threadsByID[id] = nil + emit(.threadRemoved(id: id)) + } + for shell in snapshot.threads { + let thread = mapThread(shell) + threadsByID[thread.id] = thread + emit(.threadUpserted(thread)) + } + case .event(let event): + switch event { + case .projectUpserted(_, let shell): + projectsByID[shell.id] = mapProject(shell) + emit(.projectsChanged(currentProjectList())) + case .projectRemoved(_, let projectID): + projectsByID[projectID] = nil + emit(.projectsChanged(currentProjectList())) + case .threadUpserted(_, let shell): + let thread = mapThread(shell) + threadsByID[thread.id] = thread + emit(.threadUpserted(thread)) + case .threadRemoved(_, let threadID): + threadsByID[threadID] = nil + emit(.threadRemoved(id: threadID)) + } + } + } + + private func currentProjectList() -> [Project] { + Array(projectsByID.values).sorted { $0.name < $1.name } + } + + // MARK: - Server-config subscription (providers) + + private func handleServerConfigEvent(_ event: ServerConfigStreamEvent) { + switch event { + case .snapshot(let config): + applyProviders(config.providers) + case .providerStatuses(let payload): + applyProviders(payload.providers) + case .settingsUpdated, .keybindingsUpdated, .other: + break + } + } + + private func applyProviders(_ providers: [ServerProvider]) { + // Every wire payload carrying `providers` (getConfig, config snapshot, + // providerStatuses, refresh/update results) is the full current list, + // so replace rather than merge — an instance removed from the server + // config must not linger as a selectable stale entry. + providersByInstanceId = Dictionary( + providers.map { ($0.instanceId, $0) }, + uniquingKeysWith: { _, new in new }) + emit(.providersChanged(currentProviderList())) + } + + // MARK: - Per-thread subscription (timeline / approvals / checkpoints) + + private func startThreadSubscription(_ threadID: String, client: T3Client) { + threadSubscriptions[threadID]?.cancel() + let task = Task { [weak self] in + guard let self else { return } + do { + let stream = await client.subscribeThread(threadId: threadID) + for try await item in stream { + await self.handleThreadItem(threadID: threadID, item: item) + } + await self.failSnapshotWaiters( + threadID: threadID, error: LiveBackendError.notConnected) + } catch { + await self.failSnapshotWaiters(threadID: threadID, error: error) + } + } + threadSubscriptions[threadID] = task + } + + private func cancelAllThreadSubscriptions() { + for task in threadSubscriptions.values { + task.cancel() + } + threadSubscriptions.removeAll() + } + + private func cancelAllVcsSubscriptions() { + for task in vcsSubscriptions.values { + task.cancel() + } + vcsSubscriptions.removeAll() + } + + private func handleThreadItem(threadID: String, item: OrchestrationThreadStreamItem) { + switch item { + case .snapshot(let snapshot): + applyThreadSnapshot(threadID: threadID, thread: snapshot.thread) + case .event(let event): + applyThreadEvent(threadID: threadID, event: event) + } + } + + private func applyThreadSnapshot(threadID: String, thread: OrchestrationThread) { + // Seed dedup + delta state so live events for already-shown items don't + // duplicate and assistant deltas continue from the snapshot text. + seenMessageIDs[threadID] = Set(thread.messages.map(\.id)) + var assistantText: [String: String] = [:] + for message in thread.messages where message.role == .assistant { + assistantText[message.id] = message.text + } + assistantTextByMessage[threadID] = assistantText + seenActivityIDs[threadID] = Set(thread.activities.map(\.id)) + seenCheckpointRefs[threadID] = Set(thread.checkpoints.map(\.checkpointRef)) + seenPlanIDs[threadID] = Set(thread.proposedPlans.map(\.id)) + + // Only `approval.requested` is actionable; the server records + // resolutions as separate `approval.resolved` activities with the same + // tone. A request already named by a resolution must not resurface as + // a pending card. + var resolvedApprovalIDs: Set = [] + for activity in thread.activities where activity.kind == ActivityKind.approvalResolved { + if let requestID = OrchestrationMapping.extractRequestId(from: activity.payload) { + resolvedApprovalIDs.insert(requestID) + } + } + var pendingApprovalIDs: Set = [] + for activity in thread.activities where activity.kind == ActivityKind.approvalRequested { + let requestID = + OrchestrationMapping.extractRequestId(from: activity.payload) ?? activity.id + guard !resolvedApprovalIDs.contains(requestID) else { continue } + pendingApprovalIDs.insert(requestID) + approvalRoutes[requestID] = (threadID, requestID) + } + + var checkpoints: [Checkpoint] = [] + var maxTurn = currentTurnCount[threadID] ?? 0 + for summary in thread.checkpoints { + checkpointRoutes[summary.checkpointRef] = (threadID, summary.checkpointTurnCount) + let at = WireDate.parse(summary.completedAt) ?? Date() + checkpoints.append( + Checkpoint( + id: summary.checkpointRef, threadID: threadID, + label: "Turn \(summary.checkpointTurnCount)", createdAt: at)) + maxTurn = max(maxTurn, summary.checkpointTurnCount) + } + checkpointsByThread[threadID] = checkpoints + currentTurnCount[threadID] = maxTurn + + // Typed activity kinds: rebuild user-input routes (a request is + // pending unless a later `user-input.resolved` names it) and replay + // the latest plan/context-window side-channel state. + var resolvedInputIDs: Set = [] + for activity in thread.activities where activity.kind == ActivityKind.userInputResolved { + let requestID = + activity.decodePayload(UserInputResolvedActivityPayload.self)?.requestId + ?? OrchestrationMapping.extractRequestId(from: activity.payload) + if let requestID { resolvedInputIDs.insert(requestID) } + } + var pendingInputIDs: Set = [] + for activity in thread.activities where activity.kind == ActivityKind.userInputRequested { + let at = WireDate.parse(activity.createdAt) ?? Date() + guard let request = mapUserInputRequest(activity, threadID: threadID, at: at), + !resolvedInputIDs.contains(request.id) + else { continue } + pendingInputIDs.insert(request.id) + userInputRoutes[request.id] = (threadID, request.id, request) + } + + let items = OrchestrationMapping.timeline(for: thread).compactMap { + mapEntry( + $0, threadID: threadID, pendingUserInputIDs: pendingInputIDs, + pendingApprovalIDs: pendingApprovalIDs) + } + // A prior timeline means this snapshot is a *re*-subscribe (e.g. after + // a socket reconnect), not the thread's first load. `timeline()` + // callers already have `latestTimeline` cached, so `snapshotWaiters` + // (below) would be empty and any content that arrived during the gap + // — including a resolved streaming state — would otherwise be silent. + // Push the freshly rebuilt timeline to already-subscribed consumers. + let isResubscribe = latestTimeline[threadID] != nil + latestTimeline[threadID] = items + if isResubscribe { + emit(.timelineReset(threadID: threadID, items: items)) + } + resolveSnapshotWaiters(threadID: threadID, items: items) + + // Side-channel state derived from the newest matching activity. + if let activity = thread.activities.last(where: { $0.kind == ActivityKind.turnPlanUpdated }), + let payload = activity.decodePayload(TurnPlanUpdatedActivityPayload.self) + { + let steps = payload.plan.enumerated().map { index, step in + PlanStep(id: index, title: step.step, status: Self.uiPlanStatus(step.status)) + } + emit( + .planProgressUpdated( + threadID: threadID, + progress: PlanProgress(steps: steps, explanation: payload.explanation))) + } + if let activity = thread.activities.last(where: { + $0.kind == ActivityKind.contextWindowUpdated + }), + let payload = activity.decodePayload(ContextWindowUpdatedActivityPayload.self) + { + emit( + .contextWindowUpdated( + threadID: threadID, + status: ContextWindowStatus( + usedTokens: payload.usedTokens, maxTokens: payload.maxTokens))) + } + } + + private func applyThreadEvent(threadID: String, event: OrchestrationEvent) { + switch event.payload { + case .threadMessageSent(let payload): + applyMessageSent(threadID: threadID, payload: payload) + + case .threadActivityAppended(let payload): + let activity = payload.activity + // The live tail is not filtered by snapshot sequence (unlike + // subscribeShell), so an activity already folded into the snapshot + // can also arrive here again; dedup by id, seeded from the snapshot. + guard !(seenActivityIDs[threadID]?.contains(activity.id) ?? false) else { return } + seenActivityIDs[threadID, default: []].insert(activity.id) + let at = WireDate.parse(activity.createdAt) ?? Date() + // Typed activity kinds (user-input, live plan, context window) + // are consumed into dedicated events, not generic timeline rows. + if consumeSpecialActivity(activity, threadID: threadID, at: at, appendToTimeline: true) { + return + } + switch activity.kind { + case ActivityKind.approvalRequested: + let requestID = + OrchestrationMapping.extractRequestId(from: activity.payload) ?? activity.id + approvalRoutes[requestID] = (threadID, requestID) + let request = ApprovalRequest( + id: requestID, threadID: threadID, kind: approvalKind(activity.kind), + title: activity.summary.isEmpty ? "Approval required" : activity.summary, + detail: approvalDetail(activity.payload), createdAt: at) + emit(.approvalRequested(request)) + emit(.timelineAppended(threadID: threadID, item: .approval(request))) + case ActivityKind.approvalResolved: + // The request was answered (possibly by another client); + // retire the pending card instead of rendering a new one — + // both activities share tone `.approval`. + if let requestID = OrchestrationMapping.extractRequestId(from: activity.payload) { + approvalRoutes[requestID] = nil + emit(.approvalResolved(id: requestID)) + } + default: + emit(.timelineAppended(threadID: threadID, item: mapActivity(activity, at: at))) + } + + case .threadProposedPlanUpserted(let payload): + let plan = payload.proposedPlan + guard !(seenPlanIDs[threadID]?.contains(plan.id) ?? false) else { return } + seenPlanIDs[threadID, default: []].insert(plan.id) + let at = WireDate.parse(plan.createdAt) ?? Date() + emit( + .timelineAppended( + threadID: threadID, + item: .plan( + ProposedPlan( + id: plan.id, threadID: threadID, markdown: plan.planMarkdown, + isImplemented: plan.implementedAt != nil, createdAt: at)))) + + case .threadTurnDiffCompleted(let payload): + currentTurnCount[threadID] = max( + currentTurnCount[threadID] ?? 0, payload.checkpointTurnCount) + checkpointRoutes[payload.checkpointRef] = (threadID, payload.checkpointTurnCount) + // Dedup by checkpointRef: a checkpoint already present in the + // snapshot can also arrive again on the unfiltered live tail. + // currentTurnCount/checkpointRoutes above stay idempotent either + // way, but the timeline item + checkpointsByThread list must not + // gain a duplicate entry. + guard !(seenCheckpointRefs[threadID]?.contains(payload.checkpointRef) ?? false) else { + return + } + seenCheckpointRefs[threadID, default: []].insert(payload.checkpointRef) + let at = WireDate.parse(payload.completedAt) ?? Date() + let checkpoint = Checkpoint( + id: payload.checkpointRef, threadID: threadID, + label: "Turn \(payload.checkpointTurnCount)", createdAt: at) + checkpointsByThread[threadID, default: []].append(checkpoint) + emit(.timelineAppended(threadID: threadID, item: .checkpoint(checkpoint))) + emit(.diffInvalidated(threadID: threadID)) + + case .threadReverted(let payload): + // The revert rewinds the thread to `turnCount`; checkpoints (and + // the diff turn cursor) beyond it no longer exist server-side. + // Leaving them tracked would keep stale restore points visible + // and make `diff()` query a turn count that was reverted away. + currentTurnCount[threadID] = payload.turnCount + checkpointsByThread[threadID]?.removeAll { checkpoint in + guard let route = checkpointRoutes[checkpoint.id] else { return false } + return route.turnCount > payload.turnCount + } + for (ref, route) in checkpointRoutes + where route.threadID == threadID && route.turnCount > payload.turnCount { + checkpointRoutes[ref] = nil + seenCheckpointRefs[threadID]?.remove(ref) + } + emit(.diffInvalidated(threadID: threadID)) + emit( + .timelineAppended( + threadID: threadID, + item: .notice( + id: "revert-\(event.eventId)", + text: "Reverted to turn \(payload.turnCount).", at: Date()))) + + // Status is projected from the shell subscription; the remaining events + // (session-set, meta-updated, turn-start/interrupt requests, user-input, + // approval-response-requested, etc.) are intentionally not mirrored into + // the timeline here. TODO: surface user-input prompts once the UI grows a + // dedicated affordance (today it only models approvals). + default: + break + } + } + + /// Consumes an activity with a well-known typed kind (ActivityKind.*). + /// Returns false when the activity is not special — the caller falls + /// through to generic tone-based mapping. When true, the dedicated + /// BackendEvents were emitted; `.userInput` additionally joins the + /// timeline when `appendToTimeline` is set (live tail; snapshot rebuilds + /// the timeline wholesale instead). + private func consumeSpecialActivity( + _ activity: OrchestrationThreadActivity, threadID: String, at: Date, + appendToTimeline: Bool + ) -> Bool { + switch activity.kind { + case ActivityKind.userInputRequested: + guard let request = mapUserInputRequest(activity, threadID: threadID, at: at) else { + return false // Malformed payload: degrade to generic rendering. + } + userInputRoutes[request.id] = (threadID, request.id, request) + emit(.userInputRequested(request)) + if appendToTimeline { + emit(.timelineAppended(threadID: threadID, item: .userInput(request))) + } + return true + + case ActivityKind.userInputResolved: + let requestID = + activity.decodePayload(UserInputResolvedActivityPayload.self)?.requestId + ?? OrchestrationMapping.extractRequestId(from: activity.payload) + if let requestID { + userInputRoutes[requestID] = nil + emit(.userInputResolved(id: requestID)) + } + return true + + case ActivityKind.turnPlanUpdated: + guard let payload = activity.decodePayload(TurnPlanUpdatedActivityPayload.self) else { + return false + } + let steps = payload.plan.enumerated().map { index, step in + PlanStep(id: index, title: step.step, status: Self.uiPlanStatus(step.status)) + } + emit( + .planProgressUpdated( + threadID: threadID, + progress: PlanProgress(steps: steps, explanation: payload.explanation))) + return true + + case ActivityKind.contextWindowUpdated: + guard let payload = activity.decodePayload(ContextWindowUpdatedActivityPayload.self) + else { return false } + emit( + .contextWindowUpdated( + threadID: threadID, + status: ContextWindowStatus( + usedTokens: payload.usedTokens, maxTokens: payload.maxTokens))) + return true + + default: + return false + } + } + + private func mapUserInputRequest( + _ activity: OrchestrationThreadActivity, threadID: String, at: Date + ) -> UserInputRequest? { + guard let payload = activity.decodePayload(UserInputRequestedActivityPayload.self), + !payload.questions.isEmpty + else { return nil } + let requestID = payload.requestId ?? activity.id + let questions = payload.questions.map { question in + UserInputQuestionItem( + id: question.id, header: question.header, question: question.question, + options: question.options.map { + UserInputOption(label: $0.label, detail: $0.description) + }, + multiSelect: question.multiSelect) + } + return UserInputRequest(id: requestID, threadID: threadID, questions: questions, createdAt: at) + } + + private static func uiPlanStatus(_ status: TurnPlanStepStatus?) -> PlanStepStatus { + switch status { + case .pending, nil: .pending + case .inProgress: .inProgress + case .completed: .completed + } + } + + private func applyMessageSent(threadID: String, payload: ThreadMessageSentPayload) { + let at = WireDate.parse(payload.createdAt) ?? Date() + let messageID = payload.messageId + let alreadySeen = seenMessageIDs[threadID]?.contains(messageID) ?? false + + switch payload.role { + case .user: + guard !alreadySeen else { return } + seenMessageIDs[threadID, default: []].insert(messageID) + emit( + .timelineAppended( + threadID: threadID, + item: .userMessage(id: messageID, text: payload.text, at: at))) + + case .assistant: + if !alreadySeen { + seenMessageIDs[threadID, default: []].insert(messageID) + assistantTextByMessage[threadID, default: [:]][messageID] = payload.text + emit( + .timelineAppended( + threadID: threadID, + item: .assistantMessage( + id: messageID, markdown: payload.text, + isStreaming: payload.streaming, at: at))) + } else { + let old = assistantTextByMessage[threadID]?[messageID] ?? "" + let delta = Self.assistantDelta(new: payload.text, old: old) + assistantTextByMessage[threadID, default: [:]][messageID] = payload.text + if !delta.isEmpty { + emit(.assistantDelta(threadID: threadID, messageID: messageID, delta: delta)) + } + } + if !payload.streaming { + // The terminal, non-streaming `message-sent` is authoritative — + // pass the server's full text so the UI corrects any lossy/ + // skipped delta (see `assistantDelta(new:old:)`) on completion. + emit( + .assistantCompleted( + threadID: threadID, messageID: messageID, markdown: payload.text)) + } + + case .system: + guard !alreadySeen else { return } + seenMessageIDs[threadID, default: []].insert(messageID) + emit( + .timelineAppended( + threadID: threadID, + item: .notice(id: messageID, text: payload.text, at: at))) + } + } + + /// A wire `message-sent` carries the full assistant text, not a delta; we + /// diff against the last-seen text. A pure append yields the new suffix; a + /// non-append replacement can't be represented as a delta and is skipped + /// (returns "") rather than corrupting the rendered message. + private static func assistantDelta(new: String, old: String) -> String { + if old.isEmpty { return new } + if new.hasPrefix(old) { return String(new.dropFirst(old.count)) } + return "" + } + + private func resolveSnapshotWaiters(threadID: String, items: [TimelineItem]) { + let waiters = snapshotWaiters.removeValue(forKey: threadID) ?? [] + for waiter in waiters { + waiter.resume(returning: items) + } + } + + private func failSnapshotWaiters(threadID: String, error: Error) { + let waiters = snapshotWaiters.removeValue(forKey: threadID) ?? [] + for waiter in waiters { + waiter.resume(throwing: error) + } + } + + private func failAllSnapshotWaiters(error: Error) { + for waiters in snapshotWaiters.values { + for waiter in waiters { + waiter.resume(throwing: error) + } + } + snapshotWaiters.removeAll() + } + + // MARK: - BackendService: queries + + public func projects() async throws -> [Project] { + Array(projectsByID.values).sorted { $0.name < $1.name } + } + + public func threads() async throws -> [ChatThread] { + Array(threadsByID.values).sorted { $0.updatedAt > $1.updatedAt } + } + + public func timeline(threadID: String) async throws -> [TimelineItem] { + guard let client = currentClient else { throw LiveBackendError.notConnected } + activeThreadIDs.insert(threadID) + if threadSubscriptions[threadID] == nil { + startThreadSubscription(threadID, client: client) + } + if let cached = latestTimeline[threadID] { + return cached + } + return try await withCheckedThrowingContinuation { continuation in + snapshotWaiters[threadID, default: []].append(continuation) + } + } + + public func providers() async throws -> [ProviderInstance] { + currentProviderList() + } + + public func models() async throws -> [ModelOption] { + providersByInstanceId.values + .flatMap { provider -> [ModelOption] in + guard let kind = providerKind(fromDriver: provider.driver) else { return [] } + return provider.models.map { model in + ModelOption( + instanceID: provider.instanceId, modelID: model.slug, + displayName: model.name, provider: kind, + // The wire has no per-instance default marker; the first + // listed model is what `modelSelection(for:)` picks for + // new threads, so mark that one. + isDefault: model.slug == provider.models.first?.slug) + } + } + .sorted { ($0.provider.rawValue, $0.displayName) < ($1.provider.rawValue, $1.displayName) } + } + + // MARK: - BackendService: commands + + public func createThread(projectID: String, provider: ProviderKind) async throws -> ChatThread { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let selection = modelSelection(for: provider) else { + throw LiveBackendError.noProviderForKind(provider) + } + let threadID = UUID().uuidString + let title = "New \(provider.displayName) thread" + _ = try await client.createThread( + threadId: threadID, projectId: projectID, title: title, modelSelection: selection, + runtimeMode: .fullAccess) + let thread = ChatThread( + id: threadID, projectID: projectID, title: title, provider: provider, status: .idle, + updatedAt: Date()) + threadsByID[threadID] = thread + // Emit immediately: the shell subscription's authoritative upsert can + // lag (or be missed across a reconnect), and the caller selects the + // thread right away — without this the detail pane shows an empty + // state for a thread that exists. + emit(.threadUpserted(thread)) + return thread + } + + public func archiveThread(id: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.archiveThread(threadId: id) + // The shell subscription re-emits the thread with archivedAt set. + } + + public func unarchiveThread(id: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.unarchiveThread(threadId: id) + } + + public func deleteThread(id: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.deleteThread(threadId: id) + // The shell subscription emits thread-removed; drop local caches now + // so a re-created id never sees stale dedup state. + threadsByID[id] = nil + latestTimeline[id] = nil + activeThreadIDs.remove(id) + threadSubscriptions[id]?.cancel() + threadSubscriptions[id] = nil + } + + public func sendMessage( + threadID: String, text: String, attachments: [OutgoingAttachment] + ) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + // The wire command requires modes; echo the thread's current ones so a + // send never silently flips an approval-required thread to full access. + let thread = threadsByID[threadID] + let uploads = attachments.map { attachment in + UploadChatAttachment( + name: attachment.name, mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, dataUrl: attachment.dataURL) + } + _ = try await client.startTurn( + threadId: threadID, text: text, attachments: uploads, + runtimeMode: thread.map { Self.wireRuntimeMode($0.runtimeMode) } ?? .wireDefault, + interactionMode: thread.map { Self.wireInteractionMode($0.interactionMode) } ?? .wireDefault) + } + + public func searchWorkspace(projectID: String, query: String) async throws -> [WorkspaceEntry] { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let project = projectsByID[projectID] else { return [] } + let result = try await client.searchEntries(cwd: project.path, query: query, limit: 20) + return result.entries.map { + WorkspaceEntry(path: $0.path, isDirectory: $0.kind == .directory) + } + } + + public func listWorkspace(projectID: String, subpath: String) async throws -> [WorkspaceEntry] { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let root = try projectCwd(projectID) + let cwd = subpath.isEmpty ? root : root + "/" + subpath + let result = try await client.listEntries(cwd: cwd) + return result.entries + .map { WorkspaceEntry(path: $0.path, isDirectory: $0.kind == .directory) } + .sorted { lhs, rhs in + if lhs.isDirectory != rhs.isDirectory { return lhs.isDirectory } + return lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending + } + } + + public func readWorkspaceFile(projectID: String, path: String) async throws -> FilePreview { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let result = try await client.readFile(cwd: try projectCwd(projectID), relativePath: path) + return FilePreview( + path: result.relativePath, contents: result.contents, truncated: result.truncated) + } + + public func openInEditor( + projectID: String, subpath: String?, editor: ExternalEditor + ) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let root = try projectCwd(projectID) + let cwd = subpath.map { root + "/" + $0 } ?? root + try await client.openInEditor(cwd: cwd, editor: editor.rawValue) + } + + public func implementPlan(threadID: String, planID: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let thread = threadsByID[threadID] + // Implementation turns always run in the default interaction mode — + // plan mode is what produced the plan being implemented. + _ = try await client.startTurn( + threadId: threadID, text: "Implement the proposed plan.", + runtimeMode: thread.map { Self.wireRuntimeMode($0.runtimeMode) } ?? .wireDefault, + interactionMode: .default, + sourceProposedPlan: SourceProposedPlanReference(threadId: threadID, planId: planID)) + } + + public func cancelTurn(threadID: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.interruptTurn(threadId: threadID) + } + + public func respondToApproval(id: String, approve: Bool) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let route = approvalRoutes[id] else { + throw LiveBackendError.unresolvedApproval(id) + } + let decision = OrchestrationMapping.approvalDecision(approve: approve) + _ = try await client.respondToApproval( + threadId: route.threadID, requestId: route.requestId, decision: decision) + approvalRoutes[id] = nil + emit(.approvalResolved(id: id)) + } + + public func respondToUserInput(id: String, answers: [String: [String]]) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let route = userInputRoutes[id] else { + throw LiveBackendError.unresolvedUserInput(id) + } + // Wire `answers` is Record: multi-select questions + // get an array of option labels, everything else a single string. + var wireAnswers: ProviderUserInputAnswers = [:] + for question in route.request.questions { + guard let values = answers[question.id], !values.isEmpty else { continue } + wireAnswers[question.id] = + question.multiSelect + ? .array(values.map { .string($0) }) + : .string(values[0]) + } + _ = try await client.respondToUserInput( + threadId: route.threadID, requestId: route.requestId, answers: wireAnswers) + userInputRoutes[id] = nil + emit(.userInputResolved(id: id)) + } + + public func setRuntimeMode(threadID: String, mode: ThreadRuntimeMode) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.setRuntimeMode(threadId: threadID, runtimeMode: Self.wireRuntimeMode(mode)) + updateCachedThread(threadID) { $0.runtimeMode = mode } + } + + public func setInteractionMode(threadID: String, mode: ThreadInteractionMode) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.setInteractionMode( + threadId: threadID, interactionMode: Self.wireInteractionMode(mode)) + updateCachedThread(threadID) { $0.interactionMode = mode } + } + + public func setModel(threadID: String, model: ModelOption) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.updateThreadMeta( + threadId: threadID, + modelSelection: ModelSelection(instanceId: model.instanceID, model: model.modelID)) + updateCachedThread(threadID) { + $0.modelInstanceID = model.instanceID + $0.modelID = model.modelID + $0.provider = model.provider + } + } + + /// Optimistically patch the cached thread and re-emit it; the shell + /// subscription's authoritative upsert follows and overwrites. + private func updateCachedThread(_ threadID: String, _ mutate: (inout ChatThread) -> Void) { + guard var thread = threadsByID[threadID] else { return } + mutate(&thread) + threadsByID[threadID] = thread + emit(.threadUpserted(thread)) + } + + public func diff(threadID: String) async throws -> [DiffFile] { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let toTurn = currentTurnCount[threadID], toTurn > 0 else { + // No completed turn yet -> no diff to show (graceful, not an error). + return [] + } + let result = try await client.getFullThreadDiff(threadId: threadID, toTurnCount: toTurn) + return UnifiedDiffParser.parse(result.diff) + } + + public func checkpoints(threadID: String) async throws -> [Checkpoint] { + checkpointsByThread[threadID] ?? [] + } + + public func restoreCheckpoint(id: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let route = checkpointRoutes[id] else { + throw LiveBackendError.unresolvedCheckpoint(id) + } + _ = try await client.revertCheckpoint(threadId: route.threadID, turnCount: route.turnCount) + emit(.diffInvalidated(threadID: route.threadID)) + } + + public func addProject(path: String) async throws -> Project { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let projectID = UUID().uuidString + let name = (path as NSString).lastPathComponent + let title = name.isEmpty ? path : name + _ = try await client.createProject( + projectId: projectID, title: title, workspaceRoot: path, + createWorkspaceRootIfMissing: false) + let project = Project(id: projectID, name: title, path: path) + projectsByID[projectID] = project + emit(.projectsChanged(currentProjectList())) + return project + } + + // MARK: - BackendService: git / VCS + + /// Live status subscriptions keyed by projectID; re-established on + /// demand after reconnects (watchVcsStatus is called again by the UI). + private var vcsSubscriptions: [String: Task] = [:] + /// Last combined local+remote projection per project. + private var vcsLocal: [String: VcsStatusLocal] = [:] + private var vcsRemote: [String: VcsStatusRemote] = [:] + + public func watchVcsStatus(projectID: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let project = projectsByID[projectID] else { return } + guard vcsSubscriptions[projectID] == nil else { return } + let cwd = project.path + let task = Task { [weak self] in + guard let self else { return } + do { + let stream = await client.subscribeVcsStatus(cwd: cwd) + for try await event in stream { + await self.applyVcsEvent(projectID: projectID, event: event) + } + } catch { + // Stream ended (socket drop or non-repo error): forget the + // subscription so the next watch call re-establishes it. + } + await self.clearVcsSubscription(projectID: projectID) + } + vcsSubscriptions[projectID] = task + } + + private func clearVcsSubscription(projectID: String) { + vcsSubscriptions[projectID] = nil + } + + private func applyVcsEvent(projectID: String, event: VcsStatusStreamEvent) { + switch event { + case .snapshot(let local, let remote): + vcsLocal[projectID] = local + vcsRemote[projectID] = remote ?? vcsRemote[projectID] + case .localUpdated(let local): + vcsLocal[projectID] = local + case .remoteUpdated(let remote): + if let remote { vcsRemote[projectID] = remote } + } + guard let local = vcsLocal[projectID] else { return } + emit( + .vcsStatusChanged( + projectID: projectID, + status: Self.uiVcsStatus(local: local, remote: vcsRemote[projectID]))) + } + + private static func uiVcsStatus(local: VcsStatusLocal, remote: VcsStatusRemote?) -> VcsStatus { + VcsStatus( + isRepo: local.isRepo, branch: local.refName, isDefaultBranch: local.isDefaultRef, + changedFileCount: local.workingTree.files.count, + insertions: local.workingTree.insertions, deletions: local.workingTree.deletions, + aheadCount: remote?.aheadCount ?? 0, behindCount: remote?.behindCount ?? 0, + hasUpstream: remote?.hasUpstream ?? false, prNumber: remote?.pr?.number, + prTitle: remote?.pr?.title, prURL: remote?.pr?.url) + } + + private func projectCwd(_ projectID: String) throws -> String { + guard let project = projectsByID[projectID] else { + throw LiveBackendError.notConnected + } + return project.path + } + + public func listBranches(projectID: String, query: String?) async throws -> [BranchRef] { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let result = try await client.listRefs( + cwd: try projectCwd(projectID), query: query, refKind: "local", limit: 50) + return result.refs.map { + BranchRef( + name: $0.name, isCurrent: $0.current, isDefault: $0.isDefault, + isRemote: $0.isRemote ?? false) + } + } + + public func switchBranch(projectID: String, name: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.switchRef(cwd: try projectCwd(projectID), refName: name) + } + + public func createBranch(projectID: String, name: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.createRef(cwd: try projectCwd(projectID), refName: name, switchRef: true) + } + + public func pull(projectID: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + _ = try await client.pull(cwd: try projectCwd(projectID)) + } + + public func runGitAction( + projectID: String, action: GitAction, commitMessage: String? + ) async throws -> GitActionOutcome { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let wireAction: GitStackedAction = + switch action { + case .commit: .commit + case .push: .push + case .commitPush: .commitPush + case .commitPushPR: .commitPushPR + } + let stream = await client.runStackedAction( + cwd: try projectCwd(projectID), action: wireAction, commitMessage: commitMessage) + var outcome = GitActionOutcome(success: false, title: "No response from git action") + for try await event in stream { + switch event { + case .finished(let result): + outcome = GitActionOutcome( + success: true, title: result.toast.title, detail: result.toast.description, + prURL: result.toast.prURL) + case .failed(_, let message): + outcome = GitActionOutcome(success: false, title: message) + case .started, .phaseStarted, .hookStarted, .hookOutput, .hookFinished: + break + } + } + return outcome + } + + // MARK: - BackendService: settings + providers + + public func settings() async throws -> AppSettings { + guard let client = currentClient else { throw LiveBackendError.notConnected } + return Self.uiSettings(try await client.getSettings()) + } + + public func updateSettings(_ settings: AppSettings) async throws -> AppSettings { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let patch = ServerSettingsPatch( + enableAssistantStreaming: settings.assistantStreaming, + enableProviderUpdateChecks: settings.providerUpdateChecks, + defaultThreadEnvMode: settings.defaultEnvMode == .worktree ? .worktree : .local, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + addProjectBaseDirectory: settings.addProjectBaseDirectory) + return Self.uiSettings(try await client.updateSettings(patch: patch)) + } + + public func refreshProviders() async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + let payload = try await client.refreshProviders() + applyProviders(payload.providers) + } + + public func updateProvider(instanceID: String) async throws { + guard let client = currentClient else { throw LiveBackendError.notConnected } + guard let provider = providersByInstanceId[instanceID] else { + throw LiveBackendError.noProviderInstance(instanceID) + } + let payload = try await client.updateProviderCLI( + driver: provider.driver, instanceId: instanceID) + applyProviders(payload.providers) + } + + private static func uiSettings(_ settings: ServerSettings) -> AppSettings { + AppSettings( + assistantStreaming: settings.enableAssistantStreaming, + providerUpdateChecks: settings.enableProviderUpdateChecks, + defaultEnvMode: settings.defaultThreadEnvMode == .worktree ? .worktree : .local, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, + addProjectBaseDirectory: settings.addProjectBaseDirectory) + } + + // MARK: - Emit helper + + private func emit(_ event: BackendEvent) { + if case .connection(let phase) = event { + // Lightweight breadcrumb so there's real evidence of sidecar+socket + // progress without a full logger (one line per transition, cheap). + // stderr always (harmless — only meaningful for a terminal-attached + // launch); the file breadcrumb is opt-in (`debugLogPath` is `nil` + // unless `$SERGECODE_DEBUG_LOG` is set) for launches whose stdio + // isn't attached to a terminal (e.g. `open`). + let line = "[LiveBackend] connection -> \(phase)\n" + FileHandle.standardError.write(Data(line.utf8)) + if let debugLogPath = Self.debugLogPath, let handle = FileHandle(forWritingAtPath: debugLogPath) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } + } + continuation.yield(event) + } + + // MARK: - Wire -> UI mapping + + private func mapProject(_ shell: OrchestrationProjectShell) -> Project { + Project(id: shell.id, name: shell.title, path: shell.workspaceRoot) + } + + private func mapThread(_ shell: OrchestrationThreadShell) -> ChatThread { + let kind = resolveProviderKind( + instanceId: shell.modelSelection.instanceId, providerName: shell.session?.providerName) + let status = mapStatus( + session: shell.session, latestTurn: shell.latestTurn, archivedAt: shell.archivedAt, + hasPendingApprovals: shell.hasPendingApprovals || shell.hasPendingUserInput) + let updatedAt = WireDate.parse(shell.updatedAt) ?? Date() + return ChatThread( + id: shell.id, projectID: shell.projectId, title: shell.title, provider: kind, + status: status, updatedAt: updatedAt, + runtimeMode: Self.uiRuntimeMode(shell.runtimeMode), + interactionMode: Self.uiInteractionMode(shell.interactionMode), + modelInstanceID: shell.modelSelection.instanceId, modelID: shell.modelSelection.model) + } + + // MARK: - Mode mapping (wire <-> UI) + + static func uiRuntimeMode(_ mode: RuntimeMode) -> ThreadRuntimeMode { + switch mode { + case .approvalRequired: .approvalRequired + case .autoAcceptEdits: .autoAcceptEdits + case .fullAccess: .fullAccess + } + } + + static func wireRuntimeMode(_ mode: ThreadRuntimeMode) -> RuntimeMode { + switch mode { + case .approvalRequired: .approvalRequired + case .autoAcceptEdits: .autoAcceptEdits + case .fullAccess: .fullAccess + } + } + + static func uiInteractionMode(_ mode: ProviderInteractionMode) -> ThreadInteractionMode { + switch mode { + case .default: .normal + case .plan: .plan + } + } + + static func wireInteractionMode(_ mode: ThreadInteractionMode) -> ProviderInteractionMode { + switch mode { + case .normal: .default + case .plan: .plan + } + } + + private func mapStatus( + session: OrchestrationSession?, latestTurn: OrchestrationLatestTurn?, archivedAt: String?, + hasPendingApprovals: Bool + ) -> ThreadStatus { + if archivedAt != nil { return .archived } + if hasPendingApprovals { return .waitingApproval } + if let status = session?.status { + switch status { + case .running, .starting: return .running + case .error: return .error + case .idle, .ready, .interrupted, .stopped: break + } + } + if let state = latestTurn?.state { + switch state { + case .running: return .running + case .error: return .error + case .interrupted, .completed: break + } + } + return .idle + } + + /// Maps one merged timeline entry to a UI item. Returns nil for entries + /// that live outside the timeline (plan/context-window side channels, + /// answered user-input prompts). + private func mapEntry( + _ entry: T3TimelineEntry, threadID: String, pendingUserInputIDs: Set = [], + pendingApprovalIDs: Set = [] + ) -> TimelineItem? { + switch entry { + case let .userMessage(id, text, at): + return .userMessage(id: id, text: text, at: at) + case let .assistantMessage(id, markdown, isStreaming, at): + return .assistantMessage(id: id, markdown: markdown, isStreaming: isStreaming, at: at) + case let .activity(activity, at): + switch activity.kind { + case ActivityKind.userInputRequested: + guard let request = mapUserInputRequest(activity, threadID: threadID, at: at), + pendingUserInputIDs.contains(request.id) + else { return nil } + return .userInput(request) + case ActivityKind.userInputResolved, ActivityKind.turnPlanUpdated, + ActivityKind.contextWindowUpdated: + return nil + default: + return mapActivity(activity, at: at) + } + case let .approvalActivity(activity, requestID, at): + let id = requestID ?? activity.id + // Actionable card only for a still-pending `approval.requested`; + // resolved requests and `approval.resolved` records (same tone) + // degrade to plain notices. + guard activity.kind == ActivityKind.approvalRequested, + pendingApprovalIDs.contains(id) + else { + return mapActivity(activity, at: at) + } + return .approval( + ApprovalRequest( + id: id, threadID: threadID, kind: approvalKind(activity.kind), + title: activity.summary.isEmpty ? "Approval required" : activity.summary, + detail: approvalDetail(activity.payload), createdAt: at)) + case let .checkpoint(summary, at): + return .checkpoint( + Checkpoint( + id: summary.checkpointRef, threadID: threadID, + label: "Turn \(summary.checkpointTurnCount)", createdAt: at)) + case let .proposedPlan(plan, at): + return .plan( + ProposedPlan( + id: plan.id, threadID: threadID, markdown: plan.planMarkdown, + isImplemented: plan.implementedAt != nil, createdAt: at)) + } + } + + private func mapActivity(_ activity: OrchestrationThreadActivity, at: Date) -> TimelineItem { + switch activity.tone { + case .tool: + return .toolEvent( + id: activity.id, name: activity.kind, detail: activity.summary, + status: .succeeded, at: at) + case .error: + return .toolEvent( + id: activity.id, name: activity.kind, detail: activity.summary, + status: .failed, at: at) + case .info, .approval: + return .notice(id: activity.id, text: activity.summary, at: at) + } + } + + private func approvalKind(_ kind: String) -> ApprovalKind { + let lowered = kind.lowercased() + if lowered.contains("command") || lowered.contains("exec") || lowered.contains("shell") + || lowered.contains("bash") + { + return .command + } + if lowered.contains("edit") || lowered.contains("write") || lowered.contains("file") + || lowered.contains("patch") || lowered.contains("apply") + { + return .fileEdit + } + return .other + } + + private func approvalDetail(_ payload: JSONValue) -> String { + if let object = payload.objectValue { + for key in ["command", "detail", "description", "message", "summary"] { + if let value = object[key]?.stringValue { return value } + } + } + if case let .string(value) = payload { return value } + if let data = try? JSONEncoder().encode(payload), + let string = String(data: data, encoding: .utf8), string != "null" + { + return string + } + return "" + } + + private func resolveProviderKind(instanceId: String?, providerName: String?) -> ProviderKind { + if let instanceId, let provider = providersByInstanceId[instanceId], + let kind = providerKind(fromDriver: provider.driver) + { + return kind + } + if let providerName, let kind = providerKind(fromDriver: providerName) { + return kind + } + // Fallback for a thread whose provider can't be resolved (e.g. its + // instance is gone from the config, or an unmapped driver). + return .claude + } + + private func providerKind(fromDriver driver: String) -> ProviderKind? { + let lowered = driver.lowercased() + if lowered.contains("claude") { return .claude } + if lowered.contains("codex") { return .codex } + if lowered.contains("cursor") { return .cursor } + if lowered.contains("opencode") { return .opencode } + // No ProviderKind equivalent (e.g. "grok"): the closed enum can't hold it. + return nil + } + + private func currentProviderList() -> [ProviderInstance] { + providersByInstanceId.values.compactMap { provider -> ProviderInstance? in + guard let kind = providerKind(fromDriver: provider.driver) else { return nil } + return ProviderInstance( + id: provider.instanceId, kind: kind, availability: availability(for: provider), + version: provider.version, + slashCommands: provider.slashCommands.map { + SlashCommandInfo( + name: $0.name, detail: $0.description, argumentHint: $0.input?.hint) + }) + } + .sorted { $0.id < $1.id } + } + + private func availability(for provider: ServerProvider) -> ProviderAvailability { + if !provider.isAvailable || !provider.installed { return .missing } + if provider.auth.status == .unauthenticated { return .authRequired } + return .available + } + + private func modelSelection(for provider: ProviderKind) -> ModelSelection? { + // Same bar as the provider list UI (`availability(for:)`): an + // uninstalled/unauthenticated instance, or one with no models, can't + // run a thread — returning nil surfaces `noProviderForKind` instead + // of sending the server an unusable ModelSelection. + let chosen = providersByInstanceId.values.first { + providerKind(fromDriver: $0.driver) == provider + && availability(for: $0) == .available + && !$0.models.isEmpty + } + guard let chosen, let model = chosen.models.first?.slug else { return nil } + return ModelSelection(instanceId: chosen.instanceId, model: model) + } +} + +public enum LiveBackendError: Error, Sendable { + case notConnected + case unresolvedApproval(String) + case unresolvedUserInput(String) + case unresolvedCheckpoint(String) + case noProviderForKind(ProviderKind) + case noProviderInstance(String) +} + +// MARK: - Unified diff parsing (getFullThreadDiff string -> [DiffFile]) + +/// Minimal unified-diff parser for `git diff`-style output. Handles `diff --git` +/// file headers, new/deleted/rename status hints, `---`/`+++` path lines, `@@` +/// hunk headers, and +/-/context body lines. Line numbers are tracked from the +/// hunk header. Best-effort: filenames containing spaces or exotic diff options +/// may parse imperfectly; the goal is a faithful side-by-side render, not a +/// byte-exact reconstruction. +enum UnifiedDiffParser { + static func parse(_ diff: String) -> [DiffFile] { + var files: [DiffFile] = [] + + var path: String? + var status: DiffFileStatus = .modified + var hunks: [DiffHunk] = [] + var hunkHeader: String? + var lines: [DiffLine] = [] + var oldLine = 0 + var newLine = 0 + + func flushHunk() { + if let header = hunkHeader { + hunks.append(DiffHunk(header: header, lines: lines)) + } + hunkHeader = nil + lines = [] + } + func flushFile() { + flushHunk() + if let path { + files.append(DiffFile(path: path, status: status, hunks: hunks)) + } + path = nil + status = .modified + hunks = [] + } + + for raw in diff.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(raw) + if line.hasPrefix("diff --git") { + flushFile() + path = gitPath(from: line) + status = .modified + } else if line.hasPrefix("new file") { + status = .added + } else if line.hasPrefix("deleted file") { + status = .deleted + } else if line.hasPrefix("rename ") || line.hasPrefix("copy ") { + status = .renamed + } else if line.hasPrefix("--- ") { + // A bare unified diff with no `diff --git` header: treat a `---` + // that arrives while we already have a file as a new file start. + if path != nil && hunkHeader != nil { flushFile() } + } else if line.hasPrefix("+++ ") { + let candidate = stripPathPrefix(String(line.dropFirst(4))) + if candidate != "/dev/null" { path = candidate } + } else if line.hasPrefix("@@") { + flushHunk() + hunkHeader = line + let starts = hunkStarts(from: line) + oldLine = starts.old + newLine = starts.new + } else if hunkHeader != nil { + switch line.first { + case "+": + lines.append( + DiffLine( + kind: .addition, text: String(line.dropFirst()), oldNumber: nil, + newNumber: newLine)) + newLine += 1 + case "-": + lines.append( + DiffLine( + kind: .deletion, text: String(line.dropFirst()), oldNumber: oldLine, + newNumber: nil)) + oldLine += 1 + case "\\": + break // "\ No newline at end of file" + default: + let text = line.hasPrefix(" ") ? String(line.dropFirst()) : line + lines.append( + DiffLine( + kind: .context, text: text, oldNumber: oldLine, newNumber: newLine)) + oldLine += 1 + newLine += 1 + } + } + } + flushFile() + return files + } + + /// `diff --git a/foo b/foo` -> `foo` (prefers the `b/` side). + private static func gitPath(from line: String) -> String? { + if let range = line.range(of: " b/") { + return String(line[range.upperBound...]) + } + if let range = line.range(of: " a/") { + return String(line[range.upperBound...]) + } + return nil + } + + /// Strips a leading `a/` or `b/`, a trailing tab-timestamp, and passes + /// `/dev/null` through unchanged. + private static func stripPathPrefix(_ raw: String) -> String { + var value = raw + if let tab = value.firstIndex(of: "\t") { + value = String(value[.. (old: Int, new: Int) { + var old = 0 + var new = 0 + let tokens = header.split(separator: " ") + for token in tokens { + if token.hasPrefix("-") { + old = Int(token.dropFirst().split(separator: ",").first ?? "") ?? 0 + } else if token.hasPrefix("+") { + new = Int(token.dropFirst().split(separator: ",").first ?? "") ?? 0 + } + } + return (old, new) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/Model/MockBackend.swift b/apps/mac/Sources/SergeCodeMac/Model/MockBackend.swift new file mode 100644 index 00000000000..9a5f9f4815c --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/Model/MockBackend.swift @@ -0,0 +1,842 @@ +import Foundation + +// Deterministic-but-alive fake backend for UI work without the Node sidecar. +// All mutable state lives behind an actor; the public API is the BackendService +// protocol (Sendable, async). Events are pushed through an AsyncStream whose +// continuation is stored on the actor so any method can emit. + +public final class MockBackend: BackendService, @unchecked Sendable { + public let events: AsyncStream + private let continuation: AsyncStream.Continuation + private let state: MockState + + public init() { + let (stream, continuation) = AsyncStream.makeStream() + self.events = stream + self.continuation = continuation + self.state = MockState(emit: { continuation.yield($0) }) + } + + public func start() async { + await state.start() + } + + public func stop() async { + continuation.finish() + } + + public func projects() async throws -> [Project] { + await state.projects() + } + + public func threads() async throws -> [ChatThread] { + await state.threads() + } + + public func timeline(threadID: String) async throws -> [TimelineItem] { + await state.timeline(threadID: threadID) + } + + public func providers() async throws -> [ProviderInstance] { + await state.providers() + } + + public func createThread(projectID: String, provider: ProviderKind) async throws -> ChatThread { + await state.createThread(projectID: projectID, provider: provider) + } + + public func archiveThread(id: String) async throws { + await state.archiveThread(id: id) + } + + public func unarchiveThread(id: String) async throws { + await state.unarchiveThread(id: id) + } + + public func deleteThread(id: String) async throws { + await state.deleteThread(id: id) + } + + public func settings() async throws -> AppSettings { + await state.settings + } + + public func updateSettings(_ settings: AppSettings) async throws -> AppSettings { + await state.updateSettings(settings) + } + + public func refreshProviders() async throws {} + + public func updateProvider(instanceID: String) async throws {} + + public func watchVcsStatus(projectID: String) async throws { + await state.emitVcsStatus(projectID: projectID) + } + + public func listBranches(projectID: String, query: String?) async throws -> [BranchRef] { + [ + BranchRef(name: "main", isCurrent: false, isDefault: true, isRemote: false), + BranchRef(name: "feat/native-mac-app", isCurrent: true, isDefault: false, isRemote: false), + BranchRef(name: "fix/sidebar-scroll", isCurrent: false, isDefault: false, isRemote: false), + ] + .filter { branch in + query.map { branch.name.localizedCaseInsensitiveContains($0) } ?? true + } + } + + public func switchBranch(projectID: String, name: String) async throws { + await state.emitVcsStatus(projectID: projectID, branch: name) + } + + public func createBranch(projectID: String, name: String) async throws { + await state.emitVcsStatus(projectID: projectID, branch: name) + } + + public func pull(projectID: String) async throws {} + + public func runGitAction( + projectID: String, action: GitAction, commitMessage: String? + ) async throws -> GitActionOutcome { + try? await Task.sleep(nanoseconds: 400_000_000) + return GitActionOutcome( + success: true, title: "\(action.displayName) finished", + detail: commitMessage, + prURL: action == .commitPushPR ? "https://github.com/SergeSerb2/SergeCode/pull/1" : nil) + } + + public func sendMessage( + threadID: String, text: String, attachments: [OutgoingAttachment] + ) async throws { + await state.sendMessage(threadID: threadID, text: text, attachments: attachments) + } + + public func searchWorkspace(projectID: String, query: String) async throws -> [WorkspaceEntry] { + await state.searchWorkspace(query: query) + } + + public func listWorkspace(projectID: String, subpath: String) async throws -> [WorkspaceEntry] { + await state.searchWorkspace(query: "") + } + + public func readWorkspaceFile(projectID: String, path: String) async throws -> FilePreview { + FilePreview( + path: path, + contents: "// mock contents of \(path)\nimport Foundation\n\nlet answer = 42\n", + truncated: false) + } + + public func openInEditor( + projectID: String, subpath: String?, editor: ExternalEditor + ) async throws {} + + public func cancelTurn(threadID: String) async throws { + await state.cancelTurn(threadID: threadID) + } + + public func respondToApproval(id: String, approve: Bool) async throws { + await state.respondToApproval(id: id, approve: approve) + } + + public func models() async throws -> [ModelOption] { + await state.models() + } + + public func respondToUserInput(id: String, answers: [String: [String]]) async throws { + await state.respondToUserInput(id: id, answers: answers) + } + + public func setRuntimeMode(threadID: String, mode: ThreadRuntimeMode) async throws { + await state.setRuntimeMode(threadID: threadID, mode: mode) + } + + public func setInteractionMode(threadID: String, mode: ThreadInteractionMode) async throws { + await state.setInteractionMode(threadID: threadID, mode: mode) + } + + public func setModel(threadID: String, model: ModelOption) async throws { + await state.setModel(threadID: threadID, model: model) + } + + public func implementPlan(threadID: String, planID: String) async throws { + await state.sendMessage(threadID: threadID, text: "Implement the proposed plan.") + } + + public func diff(threadID: String) async throws -> [DiffFile] { + await state.diff(threadID: threadID) + } + + public func checkpoints(threadID: String) async throws -> [Checkpoint] { + await state.checkpoints(threadID: threadID) + } + + public func restoreCheckpoint(id: String) async throws { + await state.restoreCheckpoint(id: id) + } + + public func addProject(path: String) async throws -> Project { + await state.addProject(path: path) + } +} + +// MARK: - Actor-isolated mutable state + demo data + +private actor MockState { + private let emit: @Sendable (BackendEvent) -> Void + + private var projectsByID: [String: Project] = [:] + private var threadsByID: [String: ChatThread] = [:] + private var timelinesByThread: [String: [TimelineItem]] = [:] + private var diffsByThread: [String: [DiffFile]] = [:] + private var checkpointsByThread: [String: [Checkpoint]] = [:] + private var approvalsByID: [String: ApprovalRequest] = [:] + private var providerList: [ProviderInstance] = [] + + private var started = false + private var counter = 0 + + init(emit: @escaping @Sendable (BackendEvent) -> Void) { + self.emit = emit + // `seed()` used to run as an instance method here, but Swift 6 forbids + // calling actor-isolated instance methods from a synchronous actor + // init (the actor isn't considered "isolated" yet at that point). + // Moved the seed computation into a `nonisolated static` factory that + // builds the same values without touching `self`, then assigned here. + let seed = MockState.makeSeed() + self.projectsByID = seed.projects + self.threadsByID = seed.threads + self.timelinesByThread = seed.timelines + self.diffsByThread = seed.diffs + self.checkpointsByThread = seed.checkpoints + self.approvalsByID = seed.approvals + self.providerList = seed.providers + } + + private func nextID(_ prefix: String) -> String { + counter += 1 + return "\(prefix)-\(counter)" + } + + // MARK: Lifecycle + + func start() async { + guard !started else { return } + started = true + emit(.connection(.launchingServer)) + try? await Task.sleep(nanoseconds: 250_000_000) + emit(.connection(.connecting)) + try? await Task.sleep(nanoseconds: 250_000_000) + emit(.connection(.ready)) + emit(.providersChanged(providerList)) + for thread in threadsByID.values { + emit(.threadUpserted(thread)) + } + for approval in approvalsByID.values { + emit(.approvalRequested(approval)) + } + emit( + .contextWindowUpdated( + threadID: "thread-1", + status: ContextWindowStatus(usedTokens: 72_000, maxTokens: 200_000))) + emit( + .planProgressUpdated( + threadID: "thread-1", + progress: PlanProgress( + steps: [ + PlanStep(id: 0, title: "Reproduce the scroll jump", status: .completed), + PlanStep(id: 1, title: "Pin sort to explicit reorder", status: .inProgress), + PlanStep(id: 2, title: "Verify with 200-thread seed", status: .pending), + ], + explanation: nil))) + } + + // MARK: Reads + + func projects() -> [Project] { + Array(projectsByID.values).sorted { $0.name < $1.name } + } + + func threads() -> [ChatThread] { + Array(threadsByID.values) + } + + func timeline(threadID: String) -> [TimelineItem] { + timelinesByThread[threadID] ?? [] + } + + func providers() -> [ProviderInstance] { + providerList + } + + func diff(threadID: String) -> [DiffFile] { + diffsByThread[threadID] ?? [] + } + + func checkpoints(threadID: String) -> [Checkpoint] { + checkpointsByThread[threadID] ?? [] + } + + // MARK: Commands + + func createThread(projectID: String, provider: ProviderKind) -> ChatThread { + let thread = ChatThread( + id: nextID("thread"), + projectID: projectID, + title: "New \(provider.displayName) thread", + provider: provider, + status: .idle, + updatedAt: Date() + ) + threadsByID[thread.id] = thread + timelinesByThread[thread.id] = [] + diffsByThread[thread.id] = [] + checkpointsByThread[thread.id] = [] + emit(.threadUpserted(thread)) + return thread + } + + func archiveThread(id: String) { + guard var thread = threadsByID[id] else { return } + thread.status = .archived + thread.updatedAt = Date() + threadsByID[id] = thread + emit(.threadUpserted(thread)) + } + + func unarchiveThread(id: String) { + guard var thread = threadsByID[id], thread.status == .archived else { return } + thread.status = .idle + thread.updatedAt = Date() + threadsByID[id] = thread + emit(.threadUpserted(thread)) + } + + func deleteThread(id: String) { + guard threadsByID.removeValue(forKey: id) != nil else { return } + timelinesByThread[id] = nil + emit(.threadRemoved(id: id)) + } + + private(set) var settings = AppSettings( + assistantStreaming: true, providerUpdateChecks: true, defaultEnvMode: .local, + newWorktreesStartFromOrigin: false, addProjectBaseDirectory: "~/Documents/Dev") + + func updateSettings(_ new: AppSettings) -> AppSettings { + settings = new + return new + } + + func searchWorkspace(query: String) -> [WorkspaceEntry] { + let all = [ + WorkspaceEntry(path: "Sources/SergeCodeMac/Model/AppModel.swift", isDirectory: false), + WorkspaceEntry(path: "Sources/SergeCodeMac/Model/LiveBackend.swift", isDirectory: false), + WorkspaceEntry(path: "Sources/SergeCodeMac/UI/Chat", isDirectory: true), + WorkspaceEntry(path: "Sources/T3Kit/T3Client.swift", isDirectory: false), + WorkspaceEntry(path: "Tests/T3KitTests/T3KitTests.swift", isDirectory: false), + WorkspaceEntry(path: "Package.swift", isDirectory: false), + ] + let lowered = query.lowercased() + return lowered.isEmpty ? all : all.filter { $0.path.lowercased().contains(lowered) } + } + + func sendMessage(threadID: String, text: String, attachments: [OutgoingAttachment] = []) async { + guard var thread = threadsByID[threadID] else { return } + + let attachmentSuffix = + attachments.isEmpty + ? "" : "\n\n(\(attachments.count) attachment\(attachments.count == 1 ? "" : "s"))" + let userItem = TimelineItem.userMessage( + id: nextID("msg"), text: text + attachmentSuffix, at: Date()) + timelinesByThread[threadID, default: []].append(userItem) + emit(.timelineAppended(threadID: threadID, item: userItem)) + + thread.status = .running + thread.updatedAt = Date() + threadsByID[threadID] = thread + emit(.threadUpserted(thread)) + + let messageID = nextID("asst") + let reply = MockState.canned(for: text) + let chunks = MockState.chunk(reply, size: 24) + + // Seed the streaming message so the timeline has a placeholder before + // the first delta lands. + let placeholder = TimelineItem.assistantMessage(id: messageID, markdown: "", isStreaming: true, at: Date()) + timelinesByThread[threadID, default: []].append(placeholder) + emit(.timelineAppended(threadID: threadID, item: placeholder)) + + for chunk in chunks { + try? await Task.sleep(nanoseconds: 80_000_000) + emit(.assistantDelta(threadID: threadID, messageID: messageID, delta: chunk)) + } + emit(.assistantCompleted(threadID: threadID, messageID: messageID, markdown: reply)) + + guard var finishedThread = threadsByID[threadID] else { return } + finishedThread.status = .idle + finishedThread.updatedAt = Date() + threadsByID[threadID] = finishedThread + emit(.threadUpserted(finishedThread)) + } + + func cancelTurn(threadID: String) { + guard var thread = threadsByID[threadID] else { return } + thread.status = .idle + thread.updatedAt = Date() + threadsByID[threadID] = thread + emit(.threadUpserted(thread)) + let notice = TimelineItem.notice(id: nextID("notice"), text: "Turn cancelled.", at: Date()) + timelinesByThread[threadID, default: []].append(notice) + emit(.timelineAppended(threadID: threadID, item: notice)) + } + + func models() -> [ModelOption] { + [ + ModelOption( + instanceID: "provider-claude", modelID: "claude-fable-5", + displayName: "Fable 5", provider: .claude, isDefault: true), + ModelOption( + instanceID: "provider-claude", modelID: "claude-opus-4-8", + displayName: "Opus 4.8", provider: .claude, isDefault: false), + ModelOption( + instanceID: "provider-codex", modelID: "gpt-5.2-codex", + displayName: "GPT-5.2 Codex", provider: .codex, isDefault: true), + ModelOption( + instanceID: "provider-cursor", modelID: "composer-2", + displayName: "Composer 2", provider: .cursor, isDefault: true), + ] + } + + func emitVcsStatus(projectID: String, branch: String = "feat/native-mac-app") { + emit( + .vcsStatusChanged( + projectID: projectID, + status: VcsStatus( + isRepo: true, branch: branch, isDefaultBranch: branch == "main", + changedFileCount: 3, insertions: 120, deletions: 14, aheadCount: 2, + behindCount: 0, hasUpstream: true))) + } + + func respondToUserInput(id: String, answers: [String: [String]]) { + emit(.userInputResolved(id: id)) + let summary = answers.values.flatMap { $0 }.joined(separator: ", ") + let notice = TimelineItem.notice( + id: nextID("notice"), text: "Answered: \(summary)", at: Date()) + // The mock seeds its one user-input request on thread-1. + timelinesByThread["thread-1", default: []].append(notice) + emit(.timelineAppended(threadID: "thread-1", item: notice)) + } + + func setRuntimeMode(threadID: String, mode: ThreadRuntimeMode) { + guard var thread = threadsByID[threadID] else { return } + thread.runtimeMode = mode + threadsByID[threadID] = thread + emit(.threadUpserted(thread)) + } + + func setInteractionMode(threadID: String, mode: ThreadInteractionMode) { + guard var thread = threadsByID[threadID] else { return } + thread.interactionMode = mode + threadsByID[threadID] = thread + emit(.threadUpserted(thread)) + } + + func setModel(threadID: String, model: ModelOption) { + guard var thread = threadsByID[threadID] else { return } + thread.modelInstanceID = model.instanceID + thread.modelID = model.modelID + thread.provider = model.provider + threadsByID[threadID] = thread + emit(.threadUpserted(thread)) + } + + func respondToApproval(id: String, approve: Bool) { + guard let approval = approvalsByID.removeValue(forKey: id) else { return } + emit(.approvalResolved(id: id)) + + guard var thread = threadsByID[approval.threadID] else { return } + thread.status = approve ? .running : .idle + thread.updatedAt = Date() + threadsByID[approval.threadID] = thread + emit(.threadUpserted(thread)) + + let follow = TimelineItem.toolEvent( + id: nextID("tool"), + name: approval.kind == .command ? "run_command" : "edit_file", + detail: approve ? "Approved: \(approval.title)" : "Denied: \(approval.title)", + status: approve ? .succeeded : .failed, + at: Date() + ) + timelinesByThread[approval.threadID, default: []].append(follow) + emit(.timelineAppended(threadID: approval.threadID, item: follow)) + + if approve { + emit(.diffInvalidated(threadID: approval.threadID)) + } + } + + func restoreCheckpoint(id: String) { + guard let checkpoint = checkpointsByThread.values.flatMap({ $0 }).first(where: { $0.id == id }) else { return } + let notice = TimelineItem.notice( + id: nextID("notice"), + text: "Restored checkpoint “\(checkpoint.label)”.", + at: Date() + ) + timelinesByThread[checkpoint.threadID, default: []].append(notice) + emit(.timelineAppended(threadID: checkpoint.threadID, item: notice)) + emit(.diffInvalidated(threadID: checkpoint.threadID)) + } + + func addProject(path: String) -> Project { + let name = (path as NSString).lastPathComponent + let project = Project(id: nextID("project"), name: name.isEmpty ? path : name, path: path) + projectsByID[project.id] = project + return project + } + + // MARK: - Seed data + + /// Plain-data bundle returned by `makeSeed()`. Only exists so seeding can + /// run as a `nonisolated static` factory (no access to actor-isolated + /// `self`) and still be assigned to stored properties from within `init`. + private struct Seed { + var projects: [String: Project] + var threads: [String: ChatThread] + var timelines: [String: [TimelineItem]] + var diffs: [String: [DiffFile]] + var checkpoints: [String: [Checkpoint]] + var approvals: [String: ApprovalRequest] + var providers: [ProviderInstance] + } + + private static func makeSeed() -> Seed { + let now = Date() + + var projectsByID: [String: Project] = [:] + var threadsByID: [String: ChatThread] = [:] + var timelinesByThread: [String: [TimelineItem]] = [:] + var diffsByThread: [String: [DiffFile]] = [:] + var checkpointsByThread: [String: [Checkpoint]] = [:] + var approvalsByID: [String: ApprovalRequest] = [:] + + let projectA = Project(id: "project-1", name: "SergeCode", path: "/Users/serge/Documents/Dev/SergeCode") + let projectB = Project(id: "project-2", name: "marketing-site", path: "/Users/serge/Documents/Dev/marketing-site") + projectsByID[projectA.id] = projectA + projectsByID[projectB.id] = projectB + + let providerList: [ProviderInstance] = [ + ProviderInstance(id: "provider-claude", kind: .claude, availability: .available, version: "1.4.2"), + ProviderInstance(id: "provider-codex", kind: .codex, availability: .available, version: "0.9.0"), + ProviderInstance(id: "provider-cursor", kind: .cursor, availability: .authRequired, version: nil), + ProviderInstance(id: "provider-opencode", kind: .opencode, availability: .missing, version: nil), + ] + + let thread1 = ChatThread( + id: "thread-1", + projectID: projectA.id, + title: "Fix sidebar scroll jank", + provider: .claude, + status: .running, + updatedAt: now.addingTimeInterval(-60) + ) + let thread2 = ChatThread( + id: "thread-2", + projectID: projectA.id, + title: "Wire up MockBackend", + provider: .codex, + status: .waitingApproval, + updatedAt: now.addingTimeInterval(-300) + ) + let thread3 = ChatThread( + id: "thread-3", + projectID: projectB.id, + title: "Rewrite pricing copy", + provider: .cursor, + status: .idle, + updatedAt: now.addingTimeInterval(-3_600) + ) + let thread4 = ChatThread( + id: "thread-4", + projectID: projectB.id, + title: "Investigate build error", + provider: .opencode, + status: .error, + updatedAt: now.addingTimeInterval(-7_200) + ) + + for thread in [thread1, thread2, thread3, thread4] { + threadsByID[thread.id] = thread + } + + timelinesByThread[thread1.id] = MockState.timelineForSidebarThread(at: now) + timelinesByThread[thread2.id] = MockState.timelineForApprovalThread(at: now) + timelinesByThread[thread3.id] = MockState.timelineForPricingThread(at: now) + timelinesByThread[thread4.id] = MockState.timelineForErrorThread(at: now) + + diffsByThread[thread1.id] = MockState.diffForSidebarThread() + diffsByThread[thread2.id] = MockState.diffForMockBackendThread() + diffsByThread[thread3.id] = MockState.diffForPricingThread() + diffsByThread[thread4.id] = MockState.diffForErrorThread() + + checkpointsByThread[thread1.id] = [ + Checkpoint(id: "ckpt-1a", threadID: thread1.id, label: "Before scroll refactor", createdAt: now.addingTimeInterval(-600)), + Checkpoint(id: "ckpt-1b", threadID: thread1.id, label: "After ScrollView fix", createdAt: now.addingTimeInterval(-120)), + ] + checkpointsByThread[thread2.id] = [ + Checkpoint(id: "ckpt-2a", threadID: thread2.id, label: "Initial MockBackend skeleton", createdAt: now.addingTimeInterval(-900)), + ] + checkpointsByThread[thread3.id] = [ + Checkpoint(id: "ckpt-3a", threadID: thread3.id, label: "First pricing draft", createdAt: now.addingTimeInterval(-4_000)), + ] + checkpointsByThread[thread4.id] = [] + + let approval = ApprovalRequest( + id: "approval-1", + threadID: thread2.id, + kind: .command, + title: "Run swift build --package-path apps/mac", + detail: "Codex wants to run a build to confirm MockBackend compiles before continuing.", + createdAt: now.addingTimeInterval(-30) + ) + approvalsByID[approval.id] = approval + + return Seed( + projects: projectsByID, + threads: threadsByID, + timelines: timelinesByThread, + diffs: diffsByThread, + checkpoints: checkpointsByThread, + approvals: approvalsByID, + providers: providerList + ) + } + + private static func timelineForSidebarThread(at now: Date) -> [TimelineItem] { + [ + .userMessage(id: "t1-u1", text: "The sidebar list jumps around when new threads arrive. Can you fix it?", at: now.addingTimeInterval(-500)), + .assistantMessage( + id: "t1-a1", + markdown: """ + Looked at `SidebarView`. The list re-sorts on every `threadUpserted` \ + event, which causes the scroll offset to jump. I'll pin the sort to \ + only run when the thread list actually changes order. + + ```swift + threads.sort { $0.updatedAt > $1.updatedAt } + ``` + + I'll wrap this in an `if needsResort` check. + """, + isStreaming: false, + at: now.addingTimeInterval(-480) + ), + .toolEvent(id: "t1-tool1", name: "read_file", detail: "Sources/SergeCodeMac/Views/SidebarView.swift", status: .succeeded, at: now.addingTimeInterval(-470)), + .toolEvent(id: "t1-tool2", name: "edit_file", detail: "Sources/SergeCodeMac/Model/AppModel.swift", status: .succeeded, at: now.addingTimeInterval(-200)), + .checkpoint(Checkpoint(id: "ckpt-1a", threadID: "thread-1", label: "Before scroll refactor", createdAt: now.addingTimeInterval(-600))), + .userMessage(id: "t1-u2", text: "Nice, that feels a lot smoother now.", at: now.addingTimeInterval(-90)), + .plan(ProposedPlan( + id: "t1-plan1", + threadID: "thread-1", + markdown: """ + ## Plan: stop the sidebar scroll jumping + 1. Pin the sort to run only on explicit reorder events. + 2. Keep scroll anchored to the selected row during upserts. + """, + isImplemented: false, + createdAt: now.addingTimeInterval(-80) + )), + .userInput(UserInputRequest( + id: "input-1", + threadID: "thread-1", + questions: [ + UserInputQuestionItem( + id: "q1", + header: "Sort strategy", + question: "Should archived threads keep their position or sink to the bottom?", + options: [ + UserInputOption(label: "Keep position", detail: "Least surprising while a thread is open"), + UserInputOption(label: "Sink to bottom", detail: "Keeps active work on top"), + ], + multiSelect: false), + ], + createdAt: now.addingTimeInterval(-40) + )), + ] + } + + private static func timelineForApprovalThread(at now: Date) -> [TimelineItem] { + [ + .userMessage(id: "t2-u1", text: "Build out MockBackend so we can develop the UI without the Node sidecar.", at: now.addingTimeInterval(-950)), + .assistantMessage( + id: "t2-a1", + markdown: """ + Sketched `MockBackend` with seeded projects, threads, and a fake \ + streaming reply. Before I run the build to sanity-check it, I need \ + your go-ahead since this shells out. + """, + isStreaming: false, + at: now.addingTimeInterval(-900) + ), + .toolEvent(id: "t2-tool1", name: "write_file", detail: "Sources/SergeCodeMac/Model/MockBackend.swift", status: .succeeded, at: now.addingTimeInterval(-890)), + .checkpoint(Checkpoint(id: "ckpt-2a", threadID: "thread-2", label: "Initial MockBackend skeleton", createdAt: now.addingTimeInterval(-900))), + .approval(ApprovalRequest( + id: "approval-1", + threadID: "thread-2", + kind: .command, + title: "Run swift build --package-path apps/mac", + detail: "Codex wants to run a build to confirm MockBackend compiles before continuing.", + createdAt: now.addingTimeInterval(-30) + )), + ] + } + + private static func timelineForPricingThread(at now: Date) -> [TimelineItem] { + [ + .userMessage(id: "t3-u1", text: "Rewrite the pricing page copy to sound less salesy.", at: now.addingTimeInterval(-4_200)), + .assistantMessage( + id: "t3-a1", + markdown: """ + Done — trimmed the adjectives and led each tier with what the \ + customer can actually do, not how "powerful" it is. + """, + isStreaming: false, + at: now.addingTimeInterval(-4_100) + ), + .toolEvent(id: "t3-tool1", name: "edit_file", detail: "src/pages/pricing.tsx", status: .succeeded, at: now.addingTimeInterval(-4_050)), + .checkpoint(Checkpoint(id: "ckpt-3a", threadID: "thread-3", label: "First pricing draft", createdAt: now.addingTimeInterval(-4_000))), + .notice(id: "t3-n1", text: "Thread idle for 1 hour.", at: now.addingTimeInterval(-3_600)), + ] + } + + private static func timelineForErrorThread(at now: Date) -> [TimelineItem] { + [ + .userMessage(id: "t4-u1", text: "The build is failing on CI, can you take a look?", at: now.addingTimeInterval(-7_500)), + .toolEvent(id: "t4-tool1", name: "run_command", detail: "npm run build", status: .failed, at: now.addingTimeInterval(-7_400)), + .assistantMessage( + id: "t4-a1", + markdown: """ + The build fails with `Cannot find module '@t3tools/contracts'`. \ + That package isn't declared as a workspace dependency in this \ + package's `package.json`. I hit an error trying to install it \ + directly, so I stopped rather than guess at the fix. + """, + isStreaming: false, + at: now.addingTimeInterval(-7_350) + ), + .notice(id: "t4-n1", text: "OpenCode provider became unavailable mid-session.", at: now.addingTimeInterval(-7_200)), + ] + } + + private static func diffForSidebarThread() -> [DiffFile] { + [ + DiffFile( + path: "Sources/SergeCodeMac/Views/SidebarView.swift", + status: .modified, + hunks: [ + DiffHunk(header: "@@ -40,7 +40,10 @@ struct SidebarView", lines: [ + DiffLine(kind: .context, text: " List(model.threads) { thread in", oldNumber: 40, newNumber: 40), + DiffLine(kind: .deletion, text: " ThreadRow(thread: thread)", oldNumber: 41, newNumber: nil), + DiffLine(kind: .addition, text: " ThreadRow(thread: thread)", oldNumber: nil, newNumber: 41), + DiffLine(kind: .addition, text: " .id(thread.id)", oldNumber: nil, newNumber: 42), + DiffLine(kind: .context, text: " }", oldNumber: 42, newNumber: 43), + ]), + ] + ), + DiffFile( + path: "Sources/SergeCodeMac/Model/AppModel.swift", + status: .modified, + hunks: [ + DiffHunk(header: "@@ -63,7 +63,9 @@ private func apply", lines: [ + DiffLine(kind: .context, text: " if let index = threads.firstIndex(where: { $0.id == thread.id }) {", oldNumber: 63, newNumber: 63), + DiffLine(kind: .context, text: " threads[index] = thread", oldNumber: 64, newNumber: 64), + DiffLine(kind: .addition, text: " threads.sort { $0.updatedAt > $1.updatedAt }", oldNumber: nil, newNumber: 65), + DiffLine(kind: .context, text: " } else {", oldNumber: 65, newNumber: 66), + ]), + ] + ), + ] + } + + private static func diffForMockBackendThread() -> [DiffFile] { + [ + DiffFile( + path: "Sources/SergeCodeMac/Model/MockBackend.swift", + status: .added, + hunks: [ + DiffHunk(header: "@@ -0,0 +1,12 @@", lines: [ + DiffLine(kind: .addition, text: "import Foundation", oldNumber: nil, newNumber: 1), + DiffLine(kind: .addition, text: "", oldNumber: nil, newNumber: 2), + DiffLine(kind: .addition, text: "public final class MockBackend: BackendService {", oldNumber: nil, newNumber: 3), + DiffLine(kind: .addition, text: " // seeded demo state lives here", oldNumber: nil, newNumber: 4), + DiffLine(kind: .addition, text: "}", oldNumber: nil, newNumber: 5), + ]), + ] + ), + ] + } + + private static func diffForPricingThread() -> [DiffFile] { + [ + DiffFile( + path: "src/pages/pricing.tsx", + status: .modified, + hunks: [ + DiffHunk(header: "@@ -12,8 +12,8 @@ export function PricingPage", lines: [ + DiffLine(kind: .context, text: "

Pro

", oldNumber: 12, newNumber: 12), + DiffLine(kind: .deletion, text: "

Unlock the full power of our platform.

", oldNumber: 13, newNumber: nil), + DiffLine(kind: .addition, text: "

Run unlimited projects and invite your whole team.

", oldNumber: nil, newNumber: 13), + DiffLine(kind: .context, text: " ", oldNumber: 14, newNumber: 14), + ]), + ] + ), + ] + } + + private static func diffForErrorThread() -> [DiffFile] { + [ + DiffFile( + path: "package.json", + status: .modified, + hunks: [ + DiffHunk(header: "@@ -8,6 +8,7 @@", lines: [ + DiffLine(kind: .context, text: " \"dependencies\": {", oldNumber: 8, newNumber: 8), + DiffLine(kind: .context, text: " \"react\": \"^18.3.0\",", oldNumber: 9, newNumber: 9), + DiffLine(kind: .deletion, text: " \"react-dom\": \"^18.3.0\"", oldNumber: 10, newNumber: nil), + DiffLine(kind: .addition, text: " \"react-dom\": \"^18.3.0\",", oldNumber: nil, newNumber: 10), + DiffLine(kind: .addition, text: " \"@t3tools/contracts\": \"workspace:*\"", oldNumber: nil, newNumber: 11), + DiffLine(kind: .context, text: " },", oldNumber: 11, newNumber: 12), + ]), + ] + ), + ] + } + + private static func canned(for text: String) -> String { + """ + Got it — working on “\(text)”. + + Here's a quick summary of what I'll do: + 1. Inspect the relevant files. + 2. Make a small, focused change. + 3. Report back with a diff. + + ```swift + // example + func handle() { + print("done") + } + ``` + + Let me know if you'd like a different approach. + """ + } + + private static func chunk(_ text: String, size: Int) -> [String] { + var result: [String] = [] + var current = text.startIndex + while current < text.endIndex { + let end = text.index(current, offsetBy: size, limitedBy: text.endIndex) ?? text.endIndex + result.append(String(text[current..` property-wrapper struct is still +// public, so this shim delegates to it. Use `@UIState` everywhere in this app +// instead of `@State`; for custom environment values, write manual +// `EnvironmentKey` conformances instead of `@Entry`. +@propertyWrapper +public struct UIState: DynamicProperty { + private var storage: State + + public init(wrappedValue: Value) { + storage = State(initialValue: wrappedValue) + } + + public init(initialValue: Value) { + storage = State(initialValue: initialValue) + } + + public var wrappedValue: Value { + get { storage.wrappedValue } + nonmutating set { storage.wrappedValue = newValue } + } + + public var projectedValue: Binding { + storage.projectedValue + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/ApprovalCard.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/ApprovalCard.swift new file mode 100644 index 00000000000..fcb417a9dfd --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/ApprovalCard.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// Inline approval prompt rendered as a timeline item. This is chrome (an +/// interactive control), not reading content, so it's one of the few things +/// inside the timeline allowed to sit on Liquid Glass. +public struct ApprovalCard: View { + let request: ApprovalRequest + let onRespond: (Bool) -> Void + + public init(request: ApprovalRequest, onRespond: @escaping (Bool) -> Void) { + self.request = request + self.onRespond = onRespond + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(request.title, systemImage: icon) + .font(.callout.weight(.semibold)) + + if !request.detail.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + Text(request.detail) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + // The command/diff detail is reading content even inside a + // glass card, so it gets the same opaque treatment as code + // blocks in assistant messages. + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } + + HStack { + Spacer() + Button("Deny", role: .cancel) { + onRespond(false) + } + .buttonStyle(.glass) + + Button("Approve") { + onRespond(true) + } + .buttonStyle(.glass) + .tint(.green) + } + } + .padding(14) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 16)) + } + + private var icon: String { + switch request.kind { + case .command: "terminal" + case .fileEdit: "pencil" + case .other: "questionmark.circle" + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatHeaderView.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatHeaderView.swift new file mode 100644 index 00000000000..c9d27d304b6 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatHeaderView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// Top chrome bar: thread title, provider + status badges, cancel control. +/// Chrome, so it's the one part of ChatScreen allowed on Liquid Glass. +struct ChatHeaderView: View { + let thread: ChatThread + let model: AppModel + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(thread.title) + .font(.headline) + .lineLimit(1) + HStack(spacing: 10) { + ProviderBadge(provider: thread.provider) + StatusBadge(status: thread.status) + } + } + + Spacer() + + if thread.status == .running { + Button(role: .destructive) { + Task { await model.cancelCurrentTurn() } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(.glass) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .glassEffect(.regular, in: .rect(cornerRadius: 0)) + } +} + +private struct ProviderBadge: View { + let provider: ProviderKind + + var body: some View { + Label(provider.displayName, systemImage: icon) + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(.secondary) + } + + private var icon: String { + switch provider { + case .claude: "sparkle" + case .codex: "chevron.left.forwardslash.chevron.right" + case .cursor: "cursorarrow" + case .opencode: "shippingbox" + } + } +} + +private struct StatusBadge: View { + let status: ThreadStatus + + var body: some View { + Label(text, systemImage: icon) + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(color) + } + + private var text: String { + switch status { + case .idle: "Idle" + case .running: "Running" + case .waitingApproval: "Needs approval" + case .error: "Error" + case .archived: "Archived" + } + } + + private var icon: String { + switch status { + case .idle: "circle" + case .running: "bolt.fill" + case .waitingApproval: "exclamationmark.circle.fill" + case .error: "xmark.octagon.fill" + case .archived: "archivebox.fill" + } + } + + private var color: Color { + switch status { + case .idle: .secondary + case .running: .accentColor + case .waitingApproval: .orange + case .error: .red + case .archived: .secondary + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatScreen.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatScreen.swift new file mode 100644 index 00000000000..1a626223522 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatScreen.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Main chat surface: header, scrollable timeline, composer. Owns nothing +/// but layout/wiring — all state lives on `AppModel`. +public struct ChatScreen: View { + let model: AppModel + + @UIState private var isPinnedToBottom = true + + public init(model: AppModel) { + self.model = model + } + + public var body: some View { + VStack(spacing: 0) { + if let thread = model.selectedThread { + ChatHeaderView(thread: thread, model: model) + Divider() + VcsToolbar(model: model) + ChatTimelineScrollView(model: model, isPinnedToBottom: $isPinnedToBottom) + ComposerBar(model: model) + } else { + ChatEmptyStateView() + } + } + .background(.background) + .task(id: model.selectedThreadID) { + isPinnedToBottom = true + guard let threadID = model.selectedThreadID else { return } + async let vcs: Void = model.watchVcsStatus() + await model.loadTimelineIfNeeded(threadID: threadID) + await vcs + } + } +} + +private struct ChatEmptyStateView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("Select a thread to start chatting") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.background) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatTimelineRow.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatTimelineRow.swift new file mode 100644 index 00000000000..3dbba4f9b6e --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatTimelineRow.swift @@ -0,0 +1,150 @@ +import SwiftUI + +/// Dispatches a single `TimelineItem` to its row view. +struct ChatTimelineRowView: View { + let item: TimelineItem + let model: AppModel + + var body: some View { + switch item { + case .userMessage(_, let text, _): + UserMessageBubble(text: text) + case .assistantMessage(_, let markdown, let isStreaming, _): + AssistantMarkdownView(markdown: markdown, isStreaming: isStreaming) + case .toolEvent(_, let name, let detail, let status, _): + ToolEventRow(name: name, detail: detail, status: status) + case .approval(let request): + ApprovalCard(request: request) { approve in + Task { await model.respond(to: request, approve: approve) } + } + case .userInput(let request): + UserInputCard(request: request) { answers in + Task { await model.respond(to: request, answers: answers) } + } + case .plan(let plan): + PlanCard(plan: plan) { + Task { await model.implementPlan(plan) } + } + case .checkpoint(let checkpoint): + CheckpointRow(checkpoint: checkpoint, model: model) + case .notice(_, let text, _): + NoticeRow(text: text) + } + } +} + +/// Right-aligned solid bubble for the user's own messages. +private struct UserMessageBubble: View { + let text: String + + var body: some View { + HStack { + Spacer(minLength: 48) + Text(text) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 16)) + .foregroundStyle(.white) + } + } +} + +/// Compact, expandable row for a single tool invocation. +private struct ToolEventRow: View { + let name: String + let detail: String + let status: ToolEventStatus + + @UIState private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + withAnimation(.snappy) { isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + statusIcon + Text(name) + .font(.callout) + Spacer() + if !detail.isEmpty { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + } + } + .buttonStyle(.plain) + .disabled(detail.isEmpty) + + if isExpanded && !detail.isEmpty { + Text(detail) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) + } + + @ViewBuilder + private var statusIcon: some View { + switch status { + case .running: + Image(systemName: "circle.dotted") + .symbolEffect(.pulse) + .foregroundStyle(.secondary) + case .succeeded: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + } + } +} + +/// Subtle divider row marking a restorable checkpoint. +private struct CheckpointRow: View { + let checkpoint: Checkpoint + let model: AppModel + + var body: some View { + HStack(spacing: 10) { + VStack { Divider() } + Label(checkpoint.label, systemImage: "clock.arrow.circlepath") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(1) + Button("Restore") { + Task { await model.restoreCheckpoint(checkpoint) } + } + .font(.caption) + .buttonStyle(.plain) + .foregroundStyle(.tint) + VStack { Divider() } + } + .padding(.vertical, 4) + } +} + +/// Centered secondary-text system notice. +private struct NoticeRow: View { + let text: String + + var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatTimelineScrollView.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatTimelineScrollView.swift new file mode 100644 index 00000000000..0ec5b43489b --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/ChatTimelineScrollView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// Scrollable timeline body. Pins to the bottom as new items/deltas arrive, +/// but backs off the moment the user scrolls up so they can read history +/// without fighting an autoscroll. +struct ChatTimelineScrollView: View { + let model: AppModel + @Binding var isPinnedToBottom: Bool + + private static let bottomAnchorID = "chat-timeline-bottom-anchor" + + var body: some View { + let items = model.selectedTimeline() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + ForEach(items) { item in + ChatTimelineRowView(item: item, model: model) + .id(item.id) + } + Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + // Opaque reading surface — no glass behind timeline content. + .background(.background) + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y + geometry.containerSize.height + >= geometry.contentSize.height - 60 + } action: { _, pinned in + isPinnedToBottom = pinned + } + .onChange(of: changeToken(for: items)) { _, _ in + guard isPinnedToBottom else { return } + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + .onAppear { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + } + + /// Cheap signature that changes on append *and* on in-place streaming + /// deltas (which don't change `items.count`), so the scroll-to-bottom + /// `onChange` fires in both cases. + private func changeToken(for items: [TimelineItem]) -> String { + guard let last = items.last else { return "empty" } + switch last { + case .assistantMessage(let id, let markdown, let isStreaming, _): + return "\(items.count)|\(id)|\(markdown.count)|\(isStreaming)" + default: + return "\(items.count)|\(last.id)" + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/MarkdownContent.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/MarkdownContent.swift new file mode 100644 index 00000000000..17b045e9a02 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/MarkdownContent.swift @@ -0,0 +1,152 @@ +import Foundation +import SwiftUI + +// Markdown rendering for assistant messages. AttributedString(markdown:) has +// no concept of fenced code blocks (it either fails to parse them or mangles +// them), so we split the raw text into prose/code segments ourselves and only +// hand the prose segments to AttributedString. + +enum MarkdownSegment: Identifiable { + case prose(String) + case code(language: String?, code: String) + + var id: String { + switch self { + case .prose(let text): "prose-\(text.hashValue)" + case .code(let language, let code): "code-\(language ?? "")-\(code.hashValue)" + } + } +} + +/// Splits `markdown` on ``` fences into alternating prose / code segments. +/// Tolerates an unterminated trailing fence (the common case mid-stream while +/// an assistant message is still arriving) by flushing whatever code has +/// arrived so far. +func parseMarkdownSegments(_ markdown: String) -> [MarkdownSegment] { + var segments: [MarkdownSegment] = [] + var proseLines: [Substring] = [] + var codeLines: [Substring] = [] + var codeLanguage: String? + var inCode = false + + func flushProse() { + guard !proseLines.isEmpty else { return } + let text = proseLines.joined(separator: "\n") + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + segments.append(.prose(text)) + } + proseLines.removeAll() + } + + func flushCode() { + let code = codeLines.joined(separator: "\n") + segments.append(.code(language: codeLanguage, code: code)) + codeLines.removeAll() + codeLanguage = nil + } + + for line in markdown.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("```") { + if inCode { + flushCode() + inCode = false + } else { + flushProse() + let lang = trimmed.dropFirst(3).trimmingCharacters(in: .whitespaces) + codeLanguage = lang.isEmpty ? nil : lang + inCode = true + } + continue + } + if inCode { + codeLines.append(line) + } else { + proseLines.append(line) + } + } + + if inCode { + flushCode() + } else { + flushProse() + } + return segments +} + +/// Full-width assistant message body: parsed markdown segments plus a +/// streaming indicator. Always rendered on an opaque background — never +/// glass, this is long-form reading content. +struct AssistantMarkdownView: View { + let markdown: String + let isStreaming: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(parseMarkdownSegments(markdown)) { segment in + switch segment { + case .prose(let text): + MarkdownProseText(text) + case .code(let language, let code): + MarkdownCodeBlock(language: language, code: code) + } + } + if isStreaming { + Image(systemName: "ellipsis") + .symbolEffect(.variableColor.iterative) + .foregroundStyle(.secondary) + .accessibilityLabel("Assistant is responding") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct MarkdownProseText: View { + private let raw: String + + init(_ raw: String) { + self.raw = raw + } + + var body: some View { + Text(attributed) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var attributed: AttributedString { + let options = AttributedString.MarkdownParsingOptions( + allowsExtendedAttributes: true, + interpretedSyntax: .full, + failurePolicy: .returnPartiallyParsedIfPossible + ) + return (try? AttributedString(markdown: raw, options: options)) ?? AttributedString(raw) + } +} + +private struct MarkdownCodeBlock: View { + let language: String? + let code: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if let language, !language.isEmpty { + Text(language.uppercased()) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + ScrollView(.horizontal, showsIndicators: false) { + Text(code) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + // Solid opaque fill — code blocks live inside long-form assistant + // text, so no glass/material here per Liquid Glass content rules. + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.separator, lineWidth: 1)) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/PlanCard.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/PlanCard.swift new file mode 100644 index 00000000000..cfa3b43546a --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/PlanCard.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// A proposed plan (plan mode output) rendered as a timeline item, with an +/// action to start the implementation turn. The plan body is long-form +/// reading content, so it stays opaque; only the card frame is glass. +public struct PlanCard: View { + let plan: ProposedPlan + let onImplement: () -> Void + + @UIState private var isExpanded = true + + public init(plan: ProposedPlan, onImplement: @escaping () -> Void) { + self.plan = plan + self.onImplement = onImplement + } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + withAnimation(.snappy) { isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + Label("Proposed plan", systemImage: "list.clipboard") + .font(.callout.weight(.semibold)) + if plan.isImplemented { + Text("Implemented") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.green.opacity(0.15), in: Capsule()) + .foregroundStyle(.green) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + } + .buttonStyle(.plain) + + if isExpanded { + AssistantMarkdownView(markdown: plan.markdown, isStreaming: false) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color(nsColor: .textBackgroundColor), + in: RoundedRectangle(cornerRadius: 8)) + + if !plan.isImplemented { + HStack { + Spacer() + Button("Implement plan") { + onImplement() + } + .buttonStyle(.glass) + .tint(.accentColor) + } + } + } + } + .padding(14) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 16)) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Chat/UserInputCard.swift b/apps/mac/Sources/SergeCodeMac/UI/Chat/UserInputCard.swift new file mode 100644 index 00000000000..4b4e015cfa2 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Chat/UserInputCard.swift @@ -0,0 +1,131 @@ +import SwiftUI + +/// Inline provider-question prompt (`user-input.requested`) rendered as a +/// timeline item. Interactive chrome, so it sits on Liquid Glass like +/// `ApprovalCard`. +public struct UserInputCard: View { + let request: UserInputRequest + let onSubmit: ([String: [String]]) -> Void + + @UIState private var selections: [String: Set] = [:] + @UIState private var freeform: [String: String] = [:] + + public init(request: UserInputRequest, onSubmit: @escaping ([String: [String]]) -> Void) { + self.request = request + self.onSubmit = onSubmit + } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label("Input needed", systemImage: "questionmark.bubble") + .font(.callout.weight(.semibold)) + + ForEach(request.questions) { question in + questionSection(question) + } + + HStack { + Spacer() + Button("Submit") { + onSubmit(collectAnswers()) + } + .buttonStyle(.glass) + .tint(.accentColor) + .disabled(!isComplete) + } + } + .padding(14) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 16)) + } + + @ViewBuilder + private func questionSection(_ question: UserInputQuestionItem) -> some View { + VStack(alignment: .leading, spacing: 6) { + if !question.header.isEmpty { + Text(question.header) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + Text(question.question) + .font(.callout) + .textSelection(.enabled) + + if question.options.isEmpty { + TextField( + "Your answer", + text: Binding( + get: { freeform[question.id] ?? "" }, + set: { freeform[question.id] = $0 })) + .textFieldStyle(.roundedBorder) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(question.options, id: \.label) { option in + optionRow(option, question: question) + } + } + } + } + } + + private func optionRow(_ option: UserInputOption, question: UserInputQuestionItem) -> some View { + let isSelected = selections[question.id]?.contains(option.label) ?? false + return Button { + toggle(option.label, question: question) + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image( + systemName: isSelected + ? (question.multiSelect ? "checkmark.square.fill" : "largecircle.fill.circle") + : (question.multiSelect ? "square" : "circle")) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(option.label) + .font(.callout) + if let detail = option.detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 2) + } + + private func toggle(_ label: String, question: UserInputQuestionItem) { + var current = selections[question.id] ?? [] + if question.multiSelect { + if current.contains(label) { current.remove(label) } else { current.insert(label) } + } else { + current = current.contains(label) ? [] : [label] + } + selections[question.id] = current + } + + private var isComplete: Bool { + request.questions.allSatisfy { question in + if question.options.isEmpty { + return !(freeform[question.id] ?? "").trimmingCharacters(in: .whitespaces).isEmpty + } + return !(selections[question.id] ?? []).isEmpty + } + } + + private func collectAnswers() -> [String: [String]] { + var answers: [String: [String]] = [:] + for question in request.questions { + if question.options.isEmpty { + let text = (freeform[question.id] ?? "").trimmingCharacters(in: .whitespaces) + if !text.isEmpty { answers[question.id] = [text] } + } else if let selected = selections[question.id], !selected.isEmpty { + // Preserve the option order the provider offered. + answers[question.id] = question.options.map(\.label).filter(selected.contains) + } + } + return answers + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Composer/ComposerBar.swift b/apps/mac/Sources/SergeCodeMac/UI/Composer/ComposerBar.swift new file mode 100644 index 00000000000..29423a0cd43 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Composer/ComposerBar.swift @@ -0,0 +1,337 @@ +import SwiftUI +import UniformTypeIdentifiers + +// Floating glass composer — the primary text-entry surface for the selected +// thread. Chrome-only glass; the text itself is typed over an opaque +// TextEditor background so long drafts stay readable. +// +// Adornments: image attachments (wire cap: 8 files / 10 MB each), +// `@`-mention file search backed by projects.searchEntries, and a `/` +// command menu (mode built-ins + provider-native slash commands). +public struct ComposerBar: View { + private let model: AppModel + + @UIState private var draft: String = "" + @UIState private var attachments: [OutgoingAttachment] = [] + @UIState private var showFileImporter = false + @UIState private var attachmentError: String? + + @UIState private var mentionResults: [WorkspaceEntry] = [] + @UIState private var mentionQuery: String? + @UIState private var mentionSearchTask: Task? + + private static let maxAttachments = 8 + private static let maxAttachmentBytes = 10 * 1024 * 1024 + + public init(model: AppModel) { + self.model = model + } + + private var trimmedDraft: String { + draft.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSend: Bool { + (!trimmedDraft.isEmpty || !attachments.isEmpty) && model.connection == .ready + } + + /// Provider slash commands + mode built-ins, filtered by the `/token` + /// the draft currently starts with (nil when the draft isn't one). + private var slashMatches: [SlashCommandItem]? { + guard draft.hasPrefix("/"), !draft.contains("\n") else { return nil } + let token = draft.dropFirst() + guard !token.contains(" ") else { return nil } + let query = token.lowercased() + let builtIns = [ + SlashCommandItem(name: "plan", detail: "Switch this thread to plan mode", builtIn: .plan), + SlashCommandItem(name: "default", detail: "Leave plan mode", builtIn: .normal), + ] + let provider = model.selectedThreadSlashCommands.map { + SlashCommandItem(name: $0.name, detail: $0.detail, builtIn: nil) + } + let all = builtIns + provider + let matches = query.isEmpty ? all : all.filter { $0.name.lowercased().hasPrefix(query) } + return matches.isEmpty ? nil : matches + } + + public var body: some View { + VStack(alignment: .leading, spacing: 6) { + if let matches = slashMatches { + SuggestionList(items: matches.map { item in + SuggestionRow( + id: "slash-\(item.name)", icon: "slash.circle", + title: "/\(item.name)", subtitle: item.detail + ) { applySlashCommand(item) } + }) + } else if let query = mentionQuery, !mentionResults.isEmpty { + SuggestionList(items: mentionResults.map { entry in + SuggestionRow( + id: entry.id, icon: entry.isDirectory ? "folder" : "doc.text", + title: entry.path, subtitle: nil + ) { insertMention(entry, replacing: query) } + }) + } + + if let thread = model.selectedThread { + ComposerControlsRow(thread: thread, model: model) + } + + if !attachments.isEmpty { + AttachmentChipsRow(attachments: attachments) { id in + attachments.removeAll { $0.id == id } + } + } + + if let attachmentError { + Text(attachmentError) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 4) + } + + GlassEffectContainer { + HStack(alignment: .bottom, spacing: 10) { + Button { + showFileImporter = true + } label: { + Image(systemName: "paperclip") + } + .buttonStyle(.glass) + .disabled(attachments.count >= Self.maxAttachments) + .help("Attach images") + + TextEditor(text: $draft) + .font(.body) + .scrollContentBackground(.hidden) + .frame(minHeight: 22, maxHeight: 120) + .fixedSize(horizontal: false, vertical: true) + .overlay(alignment: .topLeading) { + if draft.isEmpty { + Text("Message… (@ to mention files, / for commands)") + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 5) + .allowsHitTesting(false) + } + } + .onChange(of: draft) { _, newValue in + updateMentionSearch(for: newValue) + } + + Button { + send() + } label: { + Image(systemName: "paperplane.fill") + } + .buttonStyle(.glass) + .disabled(!canSend) + .keyboardShortcut(.return, modifiers: .command) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + .glassEffect(.regular, in: .rect(cornerRadius: 16)) + } + .fileImporter( + isPresented: $showFileImporter, allowedContentTypes: [.image], + allowsMultipleSelection: true + ) { result in + if case .success(let urls) = result { + attach(urls: urls) + } + } + } + + // MARK: - Sending + + private func send() { + guard canSend else { return } + let text = trimmedDraft + let outgoing = attachments + draft = "" + attachments = [] + attachmentError = nil + mentionQuery = nil + mentionResults = [] + Task { await model.send(text: text, attachments: outgoing) } + } + + // MARK: - Slash commands + + private func applySlashCommand(_ item: SlashCommandItem) { + switch item.builtIn { + case .plan: + draft = "" + Task { await model.setInteractionMode(.plan) } + case .normal: + draft = "" + Task { await model.setInteractionMode(.normal) } + case nil: + // Provider command: round-trips as plain message text. + draft = "/\(item.name) " + } + } + + // MARK: - @-mentions + + /// Extracts the trailing `@token` being typed (nil when the caret isn't + /// in one) and kicks a debounced workspace search. + private func updateMentionSearch(for text: String) { + mentionSearchTask?.cancel() + guard let token = Self.trailingMentionToken(in: text) else { + mentionQuery = nil + mentionResults = [] + return + } + mentionQuery = token + mentionSearchTask = Task { + try? await Task.sleep(nanoseconds: 150_000_000) + guard !Task.isCancelled else { return } + let results = await model.searchWorkspace(query: String(token.dropFirst())) + guard !Task.isCancelled else { return } + mentionResults = results + } + } + + /// `"fix @app"` -> `"@app"`; nil when the draft doesn't end in an + /// `@`-token (an `@` must start the draft or follow whitespace). + static func trailingMentionToken(in text: String) -> String? { + guard let atIndex = text.lastIndex(of: "@") else { return nil } + if atIndex > text.startIndex { + let before = text[text.index(before: atIndex)] + guard before.isWhitespace || before.isNewline else { return nil } + } + let token = text[atIndex...] + guard !token.contains(where: { $0.isWhitespace || $0.isNewline }) else { return nil } + return String(token) + } + + private func insertMention(_ entry: WorkspaceEntry, replacing token: String) { + guard draft.hasSuffix(token) else { return } + draft = String(draft.dropLast(token.count)) + "@" + entry.path + " " + mentionQuery = nil + mentionResults = [] + } + + // MARK: - Attachments + + private func attach(urls: [URL]) { + attachmentError = nil + for url in urls { + guard attachments.count < Self.maxAttachments else { + attachmentError = "At most \(Self.maxAttachments) attachments per message." + break + } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url) else { + attachmentError = "Could not read \(url.lastPathComponent)." + continue + } + guard data.count <= Self.maxAttachmentBytes else { + attachmentError = "\(url.lastPathComponent) is over the 10 MB attachment limit." + continue + } + let mimeType = + UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "image/png" + guard mimeType.hasPrefix("image/") else { + attachmentError = "\(url.lastPathComponent) is not an image." + continue + } + attachments.append( + OutgoingAttachment( + id: UUID().uuidString, name: url.lastPathComponent, mimeType: mimeType, + sizeBytes: data.count, + dataURL: "data:\(mimeType);base64,\(data.base64EncodedString())")) + } + } +} + +// MARK: - Pieces + +private enum BuiltInSlashAction { + case plan, normal +} + +private struct SlashCommandItem { + var name: String + var detail: String? + var builtIn: BuiltInSlashAction? +} + +private struct SuggestionRow: Identifiable { + var id: String + var icon: String + var title: String + var subtitle: String? + var action: () -> Void +} + +/// Shared popover-style list for slash-command and mention suggestions. +private struct SuggestionList: View { + let items: [SuggestionRow] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(items.prefix(8)) { item in + Button(action: item.action) { + HStack(spacing: 8) { + Image(systemName: item.icon) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(item.title) + .font(.callout) + .lineLimit(1) + if let subtitle = item.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary, lineWidth: 1)) + } +} + +/// Horizontal strip of staged attachments with remove buttons. +private struct AttachmentChipsRow: View { + let attachments: [OutgoingAttachment] + let onRemove: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(attachments) { attachment in + HStack(spacing: 4) { + Image(systemName: "photo") + .font(.caption) + Text(attachment.name) + .font(.caption) + .lineLimit(1) + Button { + onRemove(attachment.id) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary.opacity(0.5), in: Capsule()) + } + } + .padding(.horizontal, 4) + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Composer/ComposerControls.swift b/apps/mac/Sources/SergeCodeMac/UI/Composer/ComposerControls.swift new file mode 100644 index 00000000000..f4016e8847d --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Composer/ComposerControls.swift @@ -0,0 +1,156 @@ +import SwiftUI + +/// The compact control strip above the composer input: model picker, +/// runtime-mode picker, plan-mode toggle, and the context-window meter. +struct ComposerControlsRow: View { + let thread: ChatThread + let model: AppModel + + var body: some View { + HStack(spacing: 10) { + ModelPickerMenu(thread: thread, model: model) + RuntimeModeMenu(thread: thread, model: model) + PlanModeToggle(thread: thread, model: model) + Spacer() + if let status = model.contextWindows[thread.id] { + ContextMeterView(status: status) + } + } + .padding(.horizontal, 4) + } +} + +/// Menu listing every (provider instance, model) pair, grouped by provider. +private struct ModelPickerMenu: View { + let thread: ChatThread + let model: AppModel + + var body: some View { + Menu { + ForEach(ProviderKind.allCases) { kind in + let options = model.models.filter { $0.provider == kind } + if !options.isEmpty { + Section(kind.displayName) { + ForEach(options) { option in + Button { + Task { await model.setModel(option) } + } label: { + if isCurrent(option) { + Label(option.displayName, systemImage: "checkmark") + } else { + Text(option.displayName) + } + } + } + } + } + } + } label: { + Label(currentModelName, systemImage: "cpu") + .font(.caption) + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(model.models.isEmpty) + } + + private func isCurrent(_ option: ModelOption) -> Bool { + option.instanceID == thread.modelInstanceID && option.modelID == thread.modelID + } + + private var currentModelName: String { + if let current = model.models.first(where: isCurrent(_:)) { + return current.displayName + } + return thread.modelID ?? thread.provider.displayName + } +} + +/// Menu selecting how much the agent may do without asking. +private struct RuntimeModeMenu: View { + let thread: ChatThread + let model: AppModel + + var body: some View { + Menu { + ForEach(ThreadRuntimeMode.allCases) { mode in + Button { + Task { await model.setRuntimeMode(mode) } + } label: { + if mode == thread.runtimeMode { + Label(mode.displayName, systemImage: "checkmark") + } else { + Label(mode.displayName, systemImage: mode.symbolName) + } + } + } + } label: { + Label(thread.runtimeMode.displayName, systemImage: thread.runtimeMode.symbolName) + .font(.caption) + } + .menuStyle(.borderlessButton) + .fixedSize() + } +} + +/// One-tap plan-mode toggle (mirrors the web client's /plan `/default`). +private struct PlanModeToggle: View { + let thread: ChatThread + let model: AppModel + + private var isPlan: Bool { thread.interactionMode == .plan } + + var body: some View { + Button { + Task { await model.setInteractionMode(isPlan ? .normal : .plan) } + } label: { + Label("Plan", systemImage: isPlan ? "list.clipboard.fill" : "list.clipboard") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(isPlan ? Color.accentColor : Color.secondary) + .help(isPlan ? "Plan mode on — the agent proposes a plan instead of editing" : "Turn on plan mode") + } +} + +/// Compact context-window usage meter (ring + percent). +struct ContextMeterView: View { + let status: ContextWindowStatus + + var body: some View { + if let fraction = status.usedFraction { + HStack(spacing: 5) { + Circle() + .trim(from: 0, to: max(0.02, fraction)) + .stroke(meterColor(fraction), style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .background(Circle().stroke(.quaternary, lineWidth: 2.5)) + .frame(width: 12, height: 12) + Text("\(Int((fraction * 100).rounded()))%") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .help(helpText) + } else { + Label("\(status.usedTokens.formatted()) tokens", systemImage: "gauge") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func meterColor(_ fraction: Double) -> Color { + switch fraction { + case ..<0.7: .green + case ..<0.9: .orange + default: .red + } + } + + private var helpText: String { + if let maxTokens = status.maxTokens { + return "\(status.usedTokens.formatted()) of \(maxTokens.formatted()) context tokens used" + } + return "\(status.usedTokens.formatted()) context tokens used" + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Diff/CheckpointListView.swift b/apps/mac/Sources/SergeCodeMac/UI/Diff/CheckpointListView.swift new file mode 100644 index 00000000000..6bd20f7099b --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Diff/CheckpointListView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +// Checkpoint list for a thread: restore any prior checkpoint after a +// confirmation dialog. Content stays opaque; only the header bar is glass. + +public struct CheckpointListView: View { + private let model: AppModel + private let threadID: String + + @UIState private var pendingRestore: Checkpoint? + @UIState private var isConfirmingRestore = false + + public init(model: AppModel, threadID: String) { + self.model = model + self.threadID = threadID + } + + private var checkpoints: [Checkpoint] { + model.checkpoints[threadID] ?? [] + } + + public var body: some View { + VStack(spacing: 0) { + header + Divider() + if checkpoints.isEmpty { + emptyState + } else { + list + } + } + .task(id: threadID) { + await model.refreshCheckpoints(threadID: threadID) + } + .confirmationDialog( + "Restore Checkpoint?", + isPresented: $isConfirmingRestore, + presenting: pendingRestore + ) { checkpoint in + Button("Restore", role: .destructive) { + Task { await model.restoreCheckpoint(checkpoint) } + } + Button("Cancel", role: .cancel) {} + } message: { checkpoint in + Text("This restores the thread to \"\(checkpoint.label)\". Changes made since then will be lost.") + } + } + + // MARK: - Header + + private var header: some View { + HStack { + Label("Checkpoints", systemImage: "clock.arrow.circlepath") + .font(.headline) + if !checkpoints.isEmpty { + Text("\(checkpoints.count)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Button { + Task { await model.refreshCheckpoints(threadID: threadID) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .labelStyle(.iconOnly) + } + .buttonStyle(.glass) + .help("Refresh checkpoints") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .glassEffect(.regular, in: .rect(cornerRadius: 12)) + .padding(8) + } + + // MARK: - List + + private var list: some View { + List(checkpoints) { checkpoint in + row(checkpoint) + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + } + + private func row(_ checkpoint: Checkpoint) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(checkpoint.label) + .font(.body) + Text(checkpoint.createdAt, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Restore") { + pendingRestore = checkpoint + isConfirmingRestore = true + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } + + // MARK: - Empty state + + private var emptyState: some View { + ContentUnavailableView( + "No Checkpoints", + systemImage: "clock.arrow.circlepath", + description: Text("Checkpoints created during this thread will appear here.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Diff/DiffPanelView.swift b/apps/mac/Sources/SergeCodeMac/UI/Diff/DiffPanelView.swift new file mode 100644 index 00000000000..bac8da6441c --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Diff/DiffPanelView.swift @@ -0,0 +1,252 @@ +import SwiftUI + +// Diff panel: file list + unified diff for the selected file. Content layers +// (the diff text itself) stay opaque per the Liquid Glass rules — only the +// header bar is glass chrome. + +public struct DiffPanelView: View { + private let model: AppModel + private let threadID: String + + @UIState private var selectedPath: String? + + public init(model: AppModel, threadID: String) { + self.model = model + self.threadID = threadID + } + + private var files: [DiffFile] { + model.diffs[threadID] ?? [] + } + + private var selectedFile: DiffFile? { + if let selectedPath, let match = files.first(where: { $0.path == selectedPath }) { + return match + } + return files.first + } + + public var body: some View { + VStack(spacing: 0) { + header + Divider() + if files.isEmpty { + emptyState + } else { + HSplitView { + fileList + .frame(minWidth: 220, idealWidth: 260, maxWidth: 340, maxHeight: .infinity) + diffDetail + .frame(minWidth: 360, maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .task(id: threadID) { + await model.refreshDiff(threadID: threadID) + } + } + + // MARK: - Header + + private var header: some View { + HStack { + Label("Changes", systemImage: "arrow.left.arrow.right") + .font(.headline) + if !files.isEmpty { + Text("\(files.count) file\(files.count == 1 ? "" : "s")") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Button { + Task { await model.refreshDiff(threadID: threadID) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .labelStyle(.iconOnly) + } + .buttonStyle(.glass) + .help("Refresh diff") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .glassEffect(.regular, in: .rect(cornerRadius: 12)) + .padding(8) + } + + // MARK: - File list + + private var fileList: some View { + let selectionBinding = Binding( + get: { selectedFile?.path }, + set: { selectedPath = $0 } + ) + return List(selection: selectionBinding) { + ForEach(files) { file in + fileRow(file) + .tag(file.path) + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + + private func fileRow(_ file: DiffFile) -> some View { + HStack(spacing: 8) { + Circle() + .fill(statusColor(file.status)) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(file.path) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Text(statusLabel(file.status)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 4) + HStack(spacing: 6) { + let additions = file.additionCount + let deletions = file.deletionCount + if additions > 0 { + Text("+\(additions)") + .foregroundStyle(.green) + } + if deletions > 0 { + Text("-\(deletions)") + .foregroundStyle(.red) + } + } + .font(.caption.monospacedDigit()) + } + .padding(.vertical, 2) + } + + private func statusColor(_ status: DiffFileStatus) -> Color { + switch status { + case .added: .green + case .modified: .orange + case .deleted: .red + case .renamed: .blue + } + } + + private func statusLabel(_ status: DiffFileStatus) -> String { + switch status { + case .added: "Added" + case .modified: "Modified" + case .deleted: "Deleted" + case .renamed: "Renamed" + } + } + + // MARK: - Diff detail + + @ViewBuilder + private var diffDetail: some View { + if let file = selectedFile { + GeometryReader { proxy in + ScrollView(.vertical) { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 0) { + ForEach(file.hunks) { hunk in + hunkHeaderView(hunk.header) + ForEach(hunk.lines) { line in + DiffLineRowView(line: line) + } + } + } + .frame(minWidth: proxy.size.width, alignment: .leading) + } + } + .background(.background) + } + } else { + emptyState + } + } + + private func hunkHeaderView(_ header: String) -> some View { + Text(header) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.12)) + } + + // MARK: - Empty state + + private var emptyState: some View { + ContentUnavailableView( + "No Changes", + systemImage: "doc.text.magnifyingglass", + description: Text("This thread has no pending diff.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Diff line row + +private struct DiffLineRowView: View { + let line: DiffLine + + var body: some View { + HStack(spacing: 0) { + gutter(line.oldNumber) + gutter(line.newNumber) + Text(marker + " " + line.text) + .font(.system(.body, design: .monospaced)) + .fixedSize(horizontal: true, vertical: false) + .padding(.leading, 6) + Spacer(minLength: 12) + } + .padding(.vertical, 1) + .background(backgroundColor) + } + + private var marker: String { + switch line.kind { + case .addition: "+" + case .deletion: "-" + case .context: " " + } + } + + private var backgroundColor: Color { + switch line.kind { + case .addition: Color.green.opacity(0.12) + case .deletion: Color.red.opacity(0.12) + case .context: Color.clear + } + } + + private func gutter(_ number: Int?) -> some View { + Text(number.map(String.init) ?? "") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 40, alignment: .trailing) + } +} + +private extension DiffFile { + var additionCount: Int { + hunks.reduce(0) { count, hunk in + count + hunk.lines.filter { + if case .addition = $0.kind { return true } + return false + }.count + } + } + + var deletionCount: Int { + hunks.reduce(0) { count, hunk in + count + hunk.lines.filter { + if case .deletion = $0.kind { return true } + return false + }.count + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Settings/SettingsScene.swift b/apps/mac/Sources/SergeCodeMac/UI/Settings/SettingsScene.swift new file mode 100644 index 00000000000..c5fcdcd345a --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Settings/SettingsScene.swift @@ -0,0 +1,384 @@ +import SwiftUI + +// Settings window content: `Settings { SettingsScene(model: model) }` in App.swift. +// Standard macOS Form-based settings — glass is not used here (this is a +// utility window, not a chrome surface over long-form content). +public struct SettingsScene: View { + private let model: AppModel + + public init(model: AppModel) { + self.model = model + } + + public var body: some View { + TabView { + GeneralSettingsTab(model: model) + .tabItem { Label("General", systemImage: "gearshape") } + + ProvidersSettingsTab(model: model) + .tabItem { Label("Providers", systemImage: "puzzlepiece.extension") } + + ArchiveSettingsTab(model: model) + .tabItem { Label("Archive", systemImage: "archivebox") } + + ConnectionSettingsTab(model: model) + .tabItem { Label("Connection", systemImage: "network") } + } + .frame(width: 560, height: 420) + } +} + +// MARK: - General + +private struct GeneralSettingsTab: View { + let model: AppModel + @UIState private var draft: AppSettings? + + private var appVersion: String { + let shortVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + switch (shortVersion, build) { + case let (.some(v), .some(b)): + return "\(v) (\(b))" + case let (.some(v), .none): + return v + default: + return "—" + } + } + + var body: some View { + Form { + if let settings = draft { + Section("Behaviour") { + Toggle( + "Stream assistant replies", + isOn: binding(settings, \.assistantStreaming)) + Toggle( + "Check for provider CLI updates", + isOn: binding(settings, \.providerUpdateChecks)) + } + + Section("New threads") { + Picker("Run threads in", selection: binding(settings, \.defaultEnvMode)) { + ForEach(ProjectEnvMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + Toggle( + "Start new worktrees from origin", + isOn: binding(settings, \.newWorktreesStartFromOrigin)) + TextField( + "Default projects directory", + text: binding(settings, \.addProjectBaseDirectory)) + } + } else { + Section { + if model.connection == .ready { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Connect to the server to edit settings.") + .foregroundStyle(.secondary) + } + } + } + + Section { + LabeledContent("Appearance", value: "Follows System") + .help("SergeCode does not offer a manual light/dark override; it follows macOS.") + LabeledContent("Version", value: appVersion) + } + } + .formStyle(.grouped) + .padding() + .task { + await model.loadSettings() + draft = model.settings + } + } + + /// Edit-in-place binding that persists the whole subset patch on change. + private func binding( + _ current: AppSettings, _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { (draft ?? current)[keyPath: keyPath] }, + set: { newValue in + var next = draft ?? current + next[keyPath: keyPath] = newValue + draft = next + Task { await model.saveSettings(next) } + }) + } +} + +// MARK: - Archive + +private struct ArchiveSettingsTab: View { + let model: AppModel + + var body: some View { + Form { + Section("Archived threads") { + if model.archivedThreads.isEmpty { + Text("No archived threads.") + .foregroundStyle(.secondary) + } else { + ForEach(model.archivedThreads) { thread in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(thread.title) + .lineLimit(1) + Text(thread.provider.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Unarchive") { + Task { await model.unarchiveThread(thread) } + } + Button("Delete", role: .destructive) { + Task { await model.deleteThread(thread) } + } + } + .padding(.vertical, 2) + } + } + } + } + .formStyle(.grouped) + .padding() + } +} + +// MARK: - Providers + +private struct ProvidersSettingsTab: View { + let model: AppModel + @UIState private var isRefreshing = false + + var body: some View { + Form { + Section { + if model.providers.isEmpty { + ContentUnavailableView( + "No Providers Found", + systemImage: "puzzlepiece.extension", + description: Text("Refresh to detect installed agent CLIs.") + ) + } else { + ForEach(model.providers) { provider in + ProviderRow(provider: provider, model: model) + } + } + } header: { + HStack { + Text("Installed Providers") + Spacer() + Button { + refresh() + } label: { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + .disabled(isRefreshing) + } + } + } + .formStyle(.grouped) + .padding() + } + + private func refresh() { + guard !isRefreshing else { return } + isRefreshing = true + Task { + // Ask the server to re-probe installed CLIs (not just re-read + // the cached list), then re-pull local state. + await model.refreshProviders() + await model.refreshAll() + isRefreshing = false + } + } +} + +private struct ProviderRow: View { + let provider: ProviderInstance + let model: AppModel + @UIState private var isUpdating = false + + var body: some View { + HStack { + Image(systemName: provider.kind.symbolName) + .frame(width: 20) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(provider.kind.displayName) + if let version = provider.version { + Text(version) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if provider.availability == .available { + Button { + guard !isUpdating else { return } + isUpdating = true + Task { + await model.updateProvider(instanceID: provider.id) + isUpdating = false + } + } label: { + if isUpdating { + ProgressView() + .controlSize(.small) + } else { + Text("Update CLI") + } + } + .controlSize(.small) + .disabled(isUpdating) + .help("Run \(provider.kind.cliCommand)'s own updater") + } + + AvailabilityBadge(availability: provider.availability, kind: provider.kind) + } + .padding(.vertical, 2) + } +} + +private struct AvailabilityBadge: View { + let availability: ProviderAvailability + let kind: ProviderKind + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + Label(title, systemImage: symbolName) + .labelStyle(.titleAndIcon) + .foregroundStyle(color) + .font(.callout) + + if let hint { + Text(hint) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + + private var title: String { + switch availability { + case .available: "Available" + case .authRequired: "Sign-in Required" + case .missing: "Not Installed" + } + } + + private var symbolName: String { + switch availability { + case .available: "checkmark.circle.fill" + case .authRequired: "key.fill" + case .missing: "xmark.circle" + } + } + + private var color: Color { + switch availability { + case .available: .green + case .authRequired: .orange + case .missing: .secondary + } + } + + private var hint: String? { + switch availability { + case .available: nil + case .authRequired: "run \(kind.cliCommand) login in Terminal" + case .missing: "install \(kind.cliCommand) to use this provider" + } + } +} + +private extension ProviderKind { + var symbolName: String { + switch self { + case .claude: "sparkles" + case .codex: "chevron.left.forwardslash.chevron.right" + case .cursor: "cursorarrow" + case .opencode: "curlybraces" + } + } + + var cliCommand: String { + switch self { + case .claude: "claude" + case .codex: "codex" + case .cursor: "cursor-agent" + case .opencode: "opencode" + } + } +} + +// MARK: - Connection + +private struct ConnectionSettingsTab: View { + let model: AppModel + + var body: some View { + Form { + Section { + LabeledContent("Status") { + Label(model.connection.statusText, systemImage: model.connection.symbolName) + .foregroundStyle(model.connection.statusColor) + } + } + + Section("Server") { + LabeledContent("Host", value: "127.0.0.1 (loopback)") + LabeledContent("Mode", value: "desktop-managed-local") + Text("The t3 server runs as a supervised local child process; no remote or cloud connection is used in v1.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .padding() + } +} + +private extension ConnectionPhase { + var statusText: String { + switch self { + case .launchingServer: "Launching Server…" + case .connecting: "Connecting…" + case .ready: "Connected" + case .reconnecting(let attempt): "Reconnecting (attempt \(attempt))…" + case .failed(let message): "Failed: \(message)" + } + } + + var symbolName: String { + switch self { + case .launchingServer, .connecting, .reconnecting: "arrow.triangle.2.circlepath" + case .ready: "checkmark.circle.fill" + case .failed: "exclamationmark.triangle.fill" + } + } + + var statusColor: Color { + switch self { + case .launchingServer, .connecting, .reconnecting: .secondary + case .ready: .green + case .failed: .red + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/ConnectionStatusPill.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/ConnectionStatusPill.swift new file mode 100644 index 00000000000..27db5dd981a --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/ConnectionStatusPill.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// Glass capsule summarizing the sidecar/websocket connection phase, shown +/// in the toolbar. +struct ConnectionStatusPill: View { + let phase: ConnectionPhase + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(tint) + .frame(width: 6, height: 6) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .glassEffect(.regular, in: .capsule) + .fixedSize() + } + + private var label: String { + switch phase { + case .launchingServer: "Launching…" + case .connecting: "Connecting…" + case .ready: "Connected" + case .reconnecting(let attempt): "Reconnecting (\(attempt))…" + case .failed(let message): "Error: \(message)" + } + } + + private var tint: Color { + switch phase { + case .ready: .green + case .launchingServer, .connecting, .reconnecting: .yellow + case .failed: .red + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/EmptyStateView.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/EmptyStateView.swift new file mode 100644 index 00000000000..5a3faaec94a --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/EmptyStateView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Glass empty-state hero shown as the detail column when no thread is +/// selected. +struct EmptyStateView: View { + var onNewSession: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "sparkles") + .font(.system(size: 44)) + .foregroundStyle(.tint) + Text("SergeCode") + .font(.largeTitle.bold()) + Text("Select a session, or start a new one.") + .foregroundStyle(.secondary) + Button("New Session", action: onNewSession) + .buttonStyle(.glass) + .controlSize(.large) + } + .padding(32) + .glassEffect(.regular, in: .rect(cornerRadius: 24)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/FileBrowserView.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/FileBrowserView.swift new file mode 100644 index 00000000000..a642a2a595a --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/FileBrowserView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +/// Workspace file browser for the inspector's Files tab: breadcrumb +/// navigation over projects.listEntries, inline file preview via +/// projects.readFile, and an open-in-editor menu. +struct FileBrowserView: View { + let model: AppModel + let threadID: String + + @UIState private var subpath = "" + @UIState private var entries: [WorkspaceEntry] = [] + @UIState private var isLoading = false + @UIState private var preview: FilePreview? + + var body: some View { + VStack(spacing: 0) { + breadcrumbBar + Divider() + if let preview { + FilePreviewPane(preview: preview, model: model) { + self.preview = nil + } + } else { + entryList + } + } + .task(id: threadID) { + subpath = "" + preview = nil + await reload() + } + } + + private var breadcrumbBar: some View { + HStack(spacing: 6) { + Button { + preview = nil + subpath = "" + Task { await reload() } + } label: { + Image(systemName: "house") + .font(.caption) + } + .buttonStyle(.plain) + + if !currentPath.isEmpty { + Text(currentPath) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + } + + Spacer() + + if !subpath.isEmpty || preview != nil { + Button { + goUp() + } label: { + Label("Up", systemImage: "chevron.up") + .font(.caption) + } + .buttonStyle(.plain) + } + + Menu { + ForEach(ExternalEditor.allCases) { editor in + Button("Open in \(editor.displayName)") { + Task { + await model.openInEditor( + subpath: preview?.path ?? (subpath.isEmpty ? nil : subpath), + editor: editor) + } + } + } + } label: { + Image(systemName: "arrow.up.forward.app") + .font(.caption) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Open in external editor") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var currentPath: String { + preview?.path ?? subpath + } + + @ViewBuilder + private var entryList: some View { + if isLoading && entries.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if entries.isEmpty { + ContentUnavailableView( + "Empty directory", systemImage: "folder", + description: Text("No entries here.")) + } else { + List(entries) { entry in + Button { + open(entry) + } label: { + HStack(spacing: 8) { + Image(systemName: entry.isDirectory ? "folder.fill" : "doc.text") + .foregroundStyle(entry.isDirectory ? Color.accentColor : .secondary) + .frame(width: 16) + Text(displayName(entry)) + .font(.callout) + .lineLimit(1) + Spacer(minLength: 0) + if entry.isDirectory { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .listStyle(.inset) + } + } + + /// Entries come back project-relative; show just the last component. + private func displayName(_ entry: WorkspaceEntry) -> String { + (entry.path as NSString).lastPathComponent + } + + private func open(_ entry: WorkspaceEntry) { + if entry.isDirectory { + subpath = entry.path + Task { await reload() } + } else { + Task { preview = await model.readWorkspaceFile(path: entry.path) } + } + } + + private func goUp() { + if preview != nil { + preview = nil + return + } + let parent = (subpath as NSString).deletingLastPathComponent + subpath = parent + Task { await reload() } + } + + private func reload() async { + isLoading = true + entries = await model.listWorkspace(subpath: subpath) + isLoading = false + } +} + +/// Read-only monospaced file preview (opaque background — reading surface). +private struct FilePreviewPane: View { + let preview: FilePreview + let model: AppModel + let onClose: () -> Void + + var body: some View { + VStack(spacing: 0) { + if preview.truncated { + Text("File truncated — open in an editor for the full contents.") + .font(.caption) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + ScrollView([.vertical, .horizontal]) { + Text(preview.contents) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .background(Color(nsColor: .textBackgroundColor)) + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/InspectorPanel.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/InspectorPanel.swift new file mode 100644 index 00000000000..b548da20944 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/InspectorPanel.swift @@ -0,0 +1,106 @@ +import SwiftUI + +/// Tab picker hosting DiffPanelView / CheckpointListView for a thread, +/// presented inside the trailing inspector. +struct InspectorPanel: View { + let model: AppModel + let threadID: String + + @UIState private var tab: InspectorTab = .diff + + var body: some View { + VStack(spacing: 0) { + Picker("Inspector Tab", selection: $tab) { + ForEach(InspectorTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(12) + + Divider() + + Group { + switch tab { + case .diff: + DiffPanelView(model: model, threadID: threadID) + case .checkpoints: + CheckpointListView(model: model, threadID: threadID) + case .plan: + PlanProgressView(model: model, threadID: threadID) + case .files: + FileBrowserView(model: model, threadID: threadID) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +private enum InspectorTab: String, CaseIterable, Identifiable, Hashable { + case diff + case checkpoints + case plan + case files + + var id: String { rawValue } + + var title: String { + switch self { + case .diff: "Diff" + case .checkpoints: "Checkpoints" + case .plan: "Plan" + case .files: "Files" + } + } +} + +/// The agent's live in-turn todo list (`turn.plan.updated` activities). +struct PlanProgressView: View { + let model: AppModel + let threadID: String + + var body: some View { + if let progress = model.planProgress[threadID], !progress.steps.isEmpty { + List { + if let explanation = progress.explanation, !explanation.isEmpty { + Text(explanation) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(progress.steps) { step in + HStack(alignment: .firstTextBaseline, spacing: 8) { + statusIcon(step.status) + Text(step.title) + .font(.callout) + .strikethrough(step.status == .completed, color: .secondary) + .foregroundStyle(step.status == .completed ? .secondary : .primary) + } + } + } + .listStyle(.inset) + } else { + ContentUnavailableView( + "No plan yet", + systemImage: "list.bullet.clipboard", + description: Text("The agent's todo list appears here while it works.")) + } + } + + @ViewBuilder + private func statusIcon(_ status: PlanStepStatus) -> some View { + switch status { + case .pending: + Image(systemName: "circle") + .foregroundStyle(.secondary) + case .inProgress: + Image(systemName: "circle.dotted") + .symbolEffect(.pulse) + .foregroundStyle(Color.accentColor) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/NewSessionSheet.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/NewSessionSheet.swift new file mode 100644 index 00000000000..7ab83da69a3 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/NewSessionSheet.swift @@ -0,0 +1,135 @@ +import AppKit +import SwiftUI + +/// Glass sheet for starting a new session: pick an existing project or add +/// a new one via a native folder picker (or typed path), choose a provider, +/// and create the thread. +struct NewSessionSheet: View { + let model: AppModel + @Binding var isPresented: Bool + + @UIState private var mode: Mode = .existing + @UIState private var selectedProjectID: String? + @UIState private var provider: ProviderKind = .claude + @UIState private var newProjectPath: String = "" + @UIState private var isBusy = false + + private enum Mode: String, CaseIterable, Identifiable { + case existing = "Existing Project" + case new = "New Project" + var id: String { rawValue } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New Session") + .font(.title2.bold()) + + Picker("", selection: $mode) { + ForEach(Mode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .onChange(of: mode) { selectedProjectID = nil } + + switch mode { + case .existing: + Picker("Project", selection: $selectedProjectID) { + Text("Select a project").tag(String?.none) + ForEach(model.projects) { project in + Text(project.name).tag(Optional(project.id)) + } + } + case .new: + HStack(spacing: 8) { + TextField("Project folder", text: $newProjectPath) + .textFieldStyle(.roundedBorder) + Button { + pickFolder() + } label: { + Label("Browse…", systemImage: "folder") + } + .buttonStyle(.glass) + } + } + + Picker("Provider", selection: $provider) { + ForEach(ProviderKind.allCases) { kind in + Text(kind.displayName).tag(kind) + } + } + + HStack { + Spacer() + Button("Cancel") { isPresented = false } + .buttonStyle(.glass) + Button("Create") { + Task { await create() } + } + .buttonStyle(.glass) + .tint(.accentColor) + .disabled(isBusy || !canCreate) + } + } + .padding(24) + .frame(width: 420) + .glassEffect(.regular, in: .rect(cornerRadius: 20)) + .task { + // Warm the settings so the folder picker can open at the + // configured default projects directory. + if model.settings == nil { + await model.loadSettings() + } + } + } + + /// Native directory chooser; fills the path field with the selection. + private func pickFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.message = "Choose the project folder" + let typed = newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines) + let base = model.settings?.addProjectBaseDirectory ?? "" + if !typed.isEmpty { + panel.directoryURL = URL( + fileURLWithPath: (typed as NSString).expandingTildeInPath, isDirectory: true) + } else if !base.isEmpty { + panel.directoryURL = URL( + fileURLWithPath: (base as NSString).expandingTildeInPath, isDirectory: true) + } + if panel.runModal() == .OK, let url = panel.url { + newProjectPath = url.path + } + } + + private var canCreate: Bool { + switch mode { + case .existing: selectedProjectID != nil + case .new: !newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func create() async { + isBusy = true + defer { isBusy = false } + switch mode { + case .existing: + guard let projectID = selectedProjectID else { return } + await model.createThread(projectID: projectID, provider: provider) + case .new: + let path = newProjectPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return } + await model.addProject(path: path) + if let project = model.projects.first(where: { $0.path == path }) { + await model.createThread(projectID: project.id, provider: provider) + } + } + isPresented = false + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/SidebarView.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/SidebarView.swift new file mode 100644 index 00000000000..edcbb486a05 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/SidebarView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Thread list sidebar. Not owned by any of the original six feature agents +/// (a gap in the disjoint-file split — `ContentView.swift`'s `RootView` +/// referenced it but nothing defined it) so it was added during integration +/// to close out the `NavigationSplitView` contract. Lists threads grouped by +/// project, newest-updated first (matches `AppModel.threads`' existing sort), +/// with a status dot and provider label per row. +struct SidebarView: View { + let model: AppModel + + var body: some View { + List(selection: Binding( + get: { model.selectedThreadID }, + set: { model.selectedThreadID = $0 } + )) { + ForEach(model.projects) { project in + // Archived threads live in Settings > Archive, not the sidebar. + let threadsForProject = model.threads.filter { + $0.projectID == project.id && $0.status != .archived + } + if !threadsForProject.isEmpty { + Section(project.name) { + ForEach(threadsForProject) { thread in + SidebarThreadRow(thread: thread) + .tag(thread.id) + .contextMenu { + Button("Archive") { + Task { await model.archiveThread(thread) } + } + Button("Delete", role: .destructive) { + Task { await model.deleteThread(thread) } + } + } + } + } + } + } + } + .listStyle(.sidebar) + .navigationTitle("SergeCode") + } +} + +private struct SidebarThreadRow: View { + let thread: ChatThread + + var body: some View { + HStack(spacing: 8) { + Circle() + .fill(statusTint) + .frame(width: 6, height: 6) + VStack(alignment: .leading, spacing: 2) { + Text(thread.title) + .lineLimit(1) + Text(thread.provider.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + private var statusTint: Color { + switch thread.status { + case .idle: .secondary + case .running: .green + case .waitingApproval: .yellow + case .error: .red + case .archived: .gray + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/ThreadDetailView.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/ThreadDetailView.swift new file mode 100644 index 00000000000..cc74685a955 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/ThreadDetailView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +/// Detail column for a selected thread: chat timeline with a toggleable +/// trailing inspector hosting the diff/checkpoint tab picker. +struct ThreadDetailView: View { + let model: AppModel + let thread: ChatThread + @Binding var showInspector: Bool + + var body: some View { + ChatScreen(model: model) + .navigationTitle(thread.title) + .inspector(isPresented: $showInspector) { + InspectorPanel(model: model, threadID: thread.id) + .inspectorColumnWidth(min: 280, ideal: 340, max: 420) + } + } +} diff --git a/apps/mac/Sources/SergeCodeMac/UI/Shell/VcsToolbar.swift b/apps/mac/Sources/SergeCodeMac/UI/Shell/VcsToolbar.swift new file mode 100644 index 00000000000..b89ba204411 --- /dev/null +++ b/apps/mac/Sources/SergeCodeMac/UI/Shell/VcsToolbar.swift @@ -0,0 +1,232 @@ +import AppKit +import SwiftUI + +/// Slim git status strip under the chat header: current branch (switch/ +/// create/pull menu), working-tree and ahead/behind chips, PR link, and the +/// stacked commit/push/PR action menu. +struct VcsToolbar: View { + let model: AppModel + + @UIState private var branches: [BranchRef] = [] + @UIState private var showNewBranchPrompt = false + @UIState private var newBranchName = "" + @UIState private var pendingAction: GitAction? + @UIState private var commitMessage = "" + @UIState private var isRunningAction = false + + var body: some View { + if let status = model.selectedProjectVcsStatus(), status.isRepo { + VStack(spacing: 0) { + HStack(spacing: 12) { + branchMenu(status) + statusChips(status) + Spacer() + if let prURL = status.prURL, let url = URL(string: prURL) { + Button { + NSWorkspace.shared.open(url) + } label: { + Label( + status.prNumber.map { "PR #\($0)" } ?? "PR", + systemImage: "arrow.triangle.pull") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + .help(status.prTitle ?? "Open pull request") + } + actionMenu(status) + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + + if let outcome = model.lastGitActionOutcome { + outcomeBanner(outcome) + } + + Divider() + } + .alert("New branch", isPresented: $showNewBranchPrompt) { + TextField("Branch name", text: $newBranchName) + Button("Create") { + let name = newBranchName.trimmingCharacters(in: .whitespaces) + newBranchName = "" + guard !name.isEmpty else { return } + Task { await model.createBranch(name) } + } + Button("Cancel", role: .cancel) { newBranchName = "" } + } + .sheet(isPresented: commitSheetBinding) { + commitMessageSheet + } + } + } + + // MARK: - Branch menu + + private func branchMenu(_ status: VcsStatus) -> some View { + Menu { + ForEach(branches) { branch in + Button { + Task { await model.switchBranch(branch.name) } + } label: { + if branch.isCurrent { + Label(branch.name, systemImage: "checkmark") + } else { + Text(branch.name) + } + } + .disabled(branch.isCurrent) + } + Divider() + Button("New Branch…") { showNewBranchPrompt = true } + Button("Pull") { Task { await model.pull() } } + } label: { + Label(status.branch ?? "no branch", systemImage: "arrow.triangle.branch") + .font(.caption) + } + .menuStyle(.borderlessButton) + .fixedSize() + .onAppear { + Task { branches = await model.listBranches(query: nil) } + } + } + + // MARK: - Status chips + + @ViewBuilder + private func statusChips(_ status: VcsStatus) -> some View { + if status.changedFileCount > 0 { + Text("\(status.changedFileCount) changed +\(status.insertions) −\(status.deletions)") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + if status.aheadCount > 0 || status.behindCount > 0 { + HStack(spacing: 2) { + if status.aheadCount > 0 { + Label("\(status.aheadCount)", systemImage: "arrow.up") + } + if status.behindCount > 0 { + Label("\(status.behindCount)", systemImage: "arrow.down") + } + } + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .help("Commits ahead/behind upstream") + } + } + + // MARK: - Actions + + private func actionMenu(_ status: VcsStatus) -> some View { + Menu { + ForEach(GitAction.allCases) { action in + Button(action.displayName) { + if action.needsCommitMessage { + commitMessage = "" + pendingAction = action + } else { + run(action, message: nil) + } + } + } + } label: { + if isRunningAction { + ProgressView() + .controlSize(.small) + } else { + Label("Git", systemImage: "arrow.up.circle") + .font(.caption) + } + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(isRunningAction) + } + + private var commitSheetBinding: Binding { + Binding( + get: { pendingAction != nil }, + set: { if !$0 { pendingAction = nil } }) + } + + private var commitMessageSheet: some View { + VStack(alignment: .leading, spacing: 12) { + Text(pendingAction?.displayName ?? "Commit") + .font(.headline) + TextEditor(text: $commitMessage) + .font(.body) + .frame(width: 380, height: 90) + .overlay(alignment: .topLeading) { + if commitMessage.isEmpty { + Text("Commit message (optional — server generates one if empty)") + .foregroundStyle(.tertiary) + .padding(.top, 1) + .padding(.leading, 4) + .allowsHitTesting(false) + } + } + HStack { + Spacer() + Button("Cancel", role: .cancel) { pendingAction = nil } + Button("Run") { + let action = pendingAction + pendingAction = nil + let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) + if let action { + run(action, message: message.isEmpty ? nil : message) + } + } + .keyboardShortcut(.return, modifiers: .command) + } + } + .padding(20) + } + + private func run(_ action: GitAction, message: String?) { + guard !isRunningAction else { return } + isRunningAction = true + Task { + await model.runGitAction(action, commitMessage: message) + isRunningAction = false + } + } + + // MARK: - Outcome banner + + private func outcomeBanner(_ outcome: GitActionOutcome) -> some View { + HStack(spacing: 8) { + Image(systemName: outcome.success ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundStyle(outcome.success ? .green : .red) + VStack(alignment: .leading, spacing: 1) { + Text(outcome.title) + .font(.caption) + if let detail = outcome.detail, !detail.isEmpty { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + if let prURL = outcome.prURL, let url = URL(string: prURL) { + Button("Open PR") { NSWorkspace.shared.open(url) } + .font(.caption) + .buttonStyle(.plain) + .foregroundStyle(.tint) + } + Spacer() + Button { + model.lastGitActionOutcome = nil + } label: { + Image(systemName: "xmark") + .font(.caption2) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 5) + .background(.quaternary.opacity(0.3)) + } +} diff --git a/apps/mac/Sources/SidecarKit/BootstrapEnvelope.swift b/apps/mac/Sources/SidecarKit/BootstrapEnvelope.swift new file mode 100644 index 00000000000..71b18c1b2cd --- /dev/null +++ b/apps/mac/Sources/SidecarKit/BootstrapEnvelope.swift @@ -0,0 +1,97 @@ +import Darwin +import Foundation + +/// Codable mirror of `DesktopBackendBootstrap` from +/// `packages/contracts/src/desktopBootstrap.ts`. Field names, optionality, +/// and JSON shape must match the TS `Schema.Struct` exactly — the Node +/// server decodes this exact schema off the bootstrap file descriptor +/// (see `apps/server/src/bootstrap.ts` + `apps/server/src/cli/config.ts`). +/// +/// `Encodable`'s synthesized conformance calls `encodeIfPresent` for each +/// `Optional`-typed stored property, which omits the key entirely when the +/// value is `nil` — matching the TS side's `Schema.optional(...)`, which +/// serializes an `undefined` field as an absent key rather than `null`. +public struct BootstrapEnvelope: Codable, Sendable, Equatable { + /// Always "desktop" — the only literal the TS schema accepts. + public var mode: String + public var noBrowser: Bool + public var port: Int + /// Omitted when the backend runs under a different filesystem root than + /// the caller's own base dir (mirrors the WSL case in the TS reference, + /// where t3Home is deliberately left out so the Linux side doesn't + /// inherit a Windows-mapped path). SidecarKit always runs in-process on + /// the same machine, so callers normally pass this. + public var t3Home: String? + public var host: String + public var desktopBootstrapToken: String + public var tailscaleServeEnabled: Bool + public var tailscaleServePort: Int + public var otlpTracesUrl: String? + public var otlpMetricsUrl: String? + + public init( + port: Int, + t3Home: String? = nil, + host: String, + desktopBootstrapToken: String, + noBrowser: Bool = true, + tailscaleServeEnabled: Bool = false, + tailscaleServePort: Int = 443, + otlpTracesUrl: String? = nil, + otlpMetricsUrl: String? = nil + ) { + self.mode = "desktop" + self.noBrowser = noBrowser + self.port = port + self.t3Home = t3Home + self.host = host + self.desktopBootstrapToken = desktopBootstrapToken + self.tailscaleServeEnabled = tailscaleServeEnabled + self.tailscaleServePort = tailscaleServePort + self.otlpTracesUrl = otlpTracesUrl + self.otlpMetricsUrl = otlpMetricsUrl + } + + /// Encodes the envelope as a single line of JSON (no trailing newline), + /// suitable for writing to the server's `--bootstrap-fd` stream. The TS + /// side writes `${bootstrapJson}\n` to the child's stdin/fd3; callers of + /// this method are expected to append their own newline the same way + /// (see `ServerProcess`). + public func encodeLine() throws -> String { + let data = try JSONEncoder().encode(self) + guard let json = String(data: data, encoding: .utf8) else { + throw BootstrapEnvelopeError.encodingFailed + } + return json + } +} + +public enum BootstrapEnvelopeError: Error, Sendable { + case encodingFailed +} + +/// Generates the desktop bootstrap token exchanged for a session/bearer +/// token by the local HTTP auth API (`desktop-managed-local` policy). +/// Mirrors the TS side's `crypto.randomBytes(24)` call, but uses 32+ bytes +/// (256 bits) of CSPRNG output for a wider security margin, base64url +/// encoded (RFC 4648 §5) with padding stripped. +public enum BootstrapTokenGenerator { + public static func generate(byteCount: Int = 32) -> String { + precondition(byteCount >= 32, "bootstrap token must use at least 32 bytes of entropy") + var bytes = [UInt8](repeating: 0, count: byteCount) + bytes.withUnsafeMutableBytes { buffer in + arc4random_buf(buffer.baseAddress, buffer.count) + } + return Data(bytes).base64URLEncodedString() + } +} + +extension Data { + /// Standard base64 re-encoded as base64url (RFC 4648 §5), no padding. + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/apps/mac/Sources/SidecarKit/NodeRuntimeLocator.swift b/apps/mac/Sources/SidecarKit/NodeRuntimeLocator.swift new file mode 100644 index 00000000000..c2a16d466d8 --- /dev/null +++ b/apps/mac/Sources/SidecarKit/NodeRuntimeLocator.swift @@ -0,0 +1,188 @@ +import Foundation + +/// A parsed `major.minor.patch` version, e.g. from `node --version` output +/// ("v22.16.0"). Pre-release/build metadata suffixes (`-rc.1`, `+build`) +/// are dropped since the engines predicate below only ever compares the +/// numeric triple. +public struct SemanticVersion: Equatable, Sendable { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + public init?(parsing raw: String) { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("v") { + text.removeFirst() + } + if let metadataIndex = text.firstIndex(where: { $0 == "-" || $0 == "+" }) { + text = String(text[..= 2, + let major = Int(parts[0]), + let minor = Int(parts[1]) + else { + return nil + } + let patch = parts.count >= 3 ? (Int(parts[2]) ?? 0) : 0 + + self.major = major + self.minor = minor + self.patch = patch + } +} + +/// Locates a usable `node` binary for spawning the t3 server, mirroring the +/// dev-build expectation in ARCHITECTURE.md: "Dev builds locate node via +/// `/usr/bin/env node`" — except SidecarKit needs a concrete absolute path +/// (Foundation's `Process` does not consult `$PATH`), so it probes an +/// interactive login shell instead of relying on its own inherited +/// environment. PATH repair beyond that is the server's own job +/// (`os-jank.ts fixPath()`), not this locator's. +public struct NodeRuntimeLocator: Sendable { + public struct LocatedNode: Sendable, Equatable { + public let path: String + public let version: SemanticVersion + } + + public enum LocatorError: Error, Sendable, Equatable { + /// No candidate path yielded an executable `node` satisfying the + /// required engines range. + case notFound + } + + private let environment: [String: String] + private nonisolated(unsafe) let fileManager: FileManager + private let runVersionProbe: @Sendable (String) -> String? + + /// - Parameters: + /// - environment: Process environment to read `SERGECODE_NODE`/`HOME` + /// from. Defaults to the real process environment; tests can inject + /// a fixture dictionary. + /// - runVersionProbe: Runs ` --version` and returns its trimmed + /// stdout, or `nil` on failure. Defaults to actually spawning the + /// binary; tests can stub this out to avoid touching the filesystem + /// or spawning processes. + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + runVersionProbe: (@Sendable (String) -> String?)? = nil + ) { + self.environment = environment + self.fileManager = fileManager + self.runVersionProbe = runVersionProbe ?? NodeRuntimeLocator.spawnVersionProbe + } + + /// Finds a usable node binary, in priority order: + /// 1. `$SERGECODE_NODE` override + /// 2. login-shell PATH probe (`/bin/zsh -ilc 'command -v node'`) + /// 3. common install paths (`~/.local/bin/node`, `/opt/homebrew/bin/node`, + /// `/usr/local/bin/node`) + /// + /// Each candidate must exist, be executable, and report a version + /// satisfying the server's engines range (`^22.16 || ^23.11 || >=24.10`). + public func locate() throws -> LocatedNode { + var candidates: [String] = [] + if let override = environment["SERGECODE_NODE"], !override.isEmpty { + candidates.append(override) + } + if let shellNode = probeLoginShellPath() { + candidates.append(shellNode) + } + candidates.append(contentsOf: commonCandidatePaths()) + + for candidate in candidates { + guard fileManager.isExecutableFile(atPath: candidate) else { continue } + guard let rawVersion = runVersionProbe(candidate), + let version = SemanticVersion(parsing: rawVersion), + Self.satisfiesEngineRange(version) + else { continue } + return LocatedNode(path: candidate, version: version) + } + + throw LocatorError.notFound + } + + private func probeLoginShellPath() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-ilc", "command -v node"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func commonCandidatePaths() -> [String] { + let home = environment["HOME"] ?? NSHomeDirectory() + return [ + "\(home)/.local/bin/node", + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + ] + } + + private static func spawnVersionProbe(path: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = ["--version"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { return nil } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Pure predicate mirroring the server's `engines.node` range from + /// `apps/server/package.json`: `^22.16 || ^23.11 || >=24.10`. No I/O, + /// no process spawn — safe to unit test directly. + public static func satisfiesEngineRange(_ version: SemanticVersion) -> Bool { + switch version.major { + case ..<22: + return false + case 22: + return (version.minor, version.patch) >= (16, 0) + case 23: + return (version.minor, version.patch) >= (11, 0) + case 24: + return (version.minor, version.patch) >= (10, 0) + default: + return true + } + } + + /// Convenience overload for callers holding raw `node --version` output. + public static func versionSatisfies(_ rawVersion: String) -> Bool { + guard let version = SemanticVersion(parsing: rawVersion) else { return false } + return satisfiesEngineRange(version) + } +} diff --git a/apps/mac/Sources/SidecarKit/ServerProcess.swift b/apps/mac/Sources/SidecarKit/ServerProcess.swift new file mode 100644 index 00000000000..faaf11e1c19 --- /dev/null +++ b/apps/mac/Sources/SidecarKit/ServerProcess.swift @@ -0,0 +1,395 @@ +import Darwin +import Foundation + +/// Lifecycle states emitted on `ServerProcess.states()`. `restartAttempt` +/// (0-based) is the number of restarts already attempted for the current +/// desired-running episode; it resets to 0 once a run reaches `.ready`. +public enum SidecarState: Sendable, Equatable { + case idle + case launching(restartAttempt: Int) + case ready(pid: Int32) + case crashed(reason: String, restartAttempt: Int) + case stopped +} + +/// Supervises exactly one t3 server child process, porting +/// `apps/desktop/src/backend/DesktopBackendManager.ts`'s lifecycle: spawn, +/// bootstrap delivery over stdin, readiness polling, crash-restart with +/// exponential backoff, and graceful shutdown. +/// +/// One `ServerProcess` instance owns one child at a time. Callers drive it +/// with `start()`/`stop()` and observe `states()`. +public actor ServerProcess { + private static let readinessPath = "/.well-known/t3/environment" + private static let readinessTimeout: TimeInterval = 60 + private static let readinessPollInterval: TimeInterval = 0.1 + private static let terminateGrace: TimeInterval = 2 + private static let initialRestartDelay: TimeInterval = 0.5 + private static let maxRestartDelay: TimeInterval = 10 + + private let config: SidecarConfig + private let bootstrapToken: String + private let fileManager: FileManager + + private var process: Process? + private var currentRunID = UUID() + private var desiredRunning = false + private var restartAttempt = 0 + private var restartTask: Task? + /// App Nap suppression token held while the sidecar is desired-running. + private var activityToken: NSObjectProtocol? + private var currentState: SidecarState = .idle + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + /// Bumped by every `start()`/`stop()` entry. `stop()` captures its value + /// on entry and re-checks it after each `await` before mutating terminal + /// state (`process`, `.stopped`), so a `start()` that races in during + /// `stop()`'s teardown wins instead of being silently clobbered. + private var operationGeneration = 0 + + public init( + config: SidecarConfig, + bootstrapToken: String, + fileManager: FileManager = .default + ) { + self.config = config + self.bootstrapToken = bootstrapToken + self.fileManager = fileManager + } + + /// A fresh subscription always immediately receives the current state, + /// then every subsequent transition. Multiple subscribers are supported; + /// each gets its own continuation. + public func states() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream(of: SidecarState.self) + continuation.onTermination = { [weak self] _ in + Task { await self?.removeStateContinuation(id: id) } + } + stateContinuations[id] = continuation + continuation.yield(currentState) + return stream + } + + private func removeStateContinuation(id: UUID) { + stateContinuations.removeValue(forKey: id) + } + + deinit { + for continuation in stateContinuations.values { + continuation.finish() + } + } + + public func snapshot() -> SidecarState { + currentState + } + + /// Starts the supervised child if it isn't already desired-running. + /// Idempotent: calling `start()` again while already running is a no-op, + /// mirroring the TS manager's `start` (which returns early when + /// `Option.isSome(current.active)`). + public func start() async { + guard !desiredRunning else { return } + desiredRunning = true + restartAttempt = 0 + // App Nap / RunningBoard suspends the whole process coalition of a + // Finder/`open`-launched app that looks idle — including the spawned + // node child (observed as the sidecar sitting in `T` state, never + // booting). Hold an activity assertion for the sidecar's lifetime + // so the coalition stays runnable. + if activityToken == nil { + activityToken = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .automaticTerminationDisabled], + reason: "t3 server sidecar running") + } + // Supersede any stop() currently unwinding through its await points + // so its teardown no-ops instead of clobbering this run once it + // resumes (see `operationGeneration` doc comment above). + operationGeneration += 1 + await launch() + } + + /// SIGTERM, then SIGKILL after a 2s grace period if the process hasn't + /// exited on its own. + public func stop() async { + desiredRunning = false + restartTask?.cancel() + restartTask = nil + if let token = activityToken { + activityToken = nil + ProcessInfo.processInfo.endActivity(token) + } + // Invalidate any in-flight readiness poll / termination callback + // from the run being stopped so they no-op instead of racing a + // subsequent start(). + currentRunID = UUID() + operationGeneration += 1 + let myGeneration = operationGeneration + + guard let runningProcess = process, runningProcess.isRunning else { + guard myGeneration == operationGeneration else { return } + process = nil + emit(.stopped) + return + } + + runningProcess.terminate() // sends SIGTERM + let exitedGracefully = await waitForExit(of: runningProcess, timeout: Self.terminateGrace) + if !exitedGracefully { + kill(runningProcess.processIdentifier, SIGKILL) + _ = await waitForExit(of: runningProcess, timeout: Self.terminateGrace) + } + + // If a start() raced in while we were awaiting the child's exit, it + // will have bumped `operationGeneration` past what we captured; bail + // out instead of nulling out / marking `.stopped` the new run it + // just spun up. + guard myGeneration == operationGeneration else { return } + process = nil + emit(.stopped) + } + + // MARK: - Launch + + private func launch() async { + let runID = UUID() + currentRunID = runID + emit(.launching(restartAttempt: restartAttempt)) + + let envelope = BootstrapEnvelope( + port: config.port, + t3Home: config.baseDir, + host: config.host, + desktopBootstrapToken: bootstrapToken, + noBrowser: config.noBrowser + ) + + let bootstrapLine: String + do { + bootstrapLine = try envelope.encodeLine() + } catch { + failLaunch(reason: "failed to encode bootstrap envelope: \(error)") + return + } + + let logHandles: (stdout: FileHandle, stderr: FileHandle) + do { + logHandles = try makeLogHandles() + } catch { + failLaunch(reason: "failed to open sidecar log files: \(error)") + return + } + + let childProcess = Process() + childProcess.executableURL = URL(fileURLWithPath: config.nodePath) + childProcess.arguments = [ + config.entryPath, + "--mode", "desktop", + "--bootstrap-fd", "0", + "--port", String(config.port), + "--host", config.host, + "--base-dir", config.baseDir, + "--no-browser", + ] + + let stdinPipe = Pipe() + childProcess.standardInput = stdinPipe + childProcess.standardOutput = logHandles.stdout + childProcess.standardError = logHandles.stderr + + childProcess.terminationHandler = { [weak self] terminated in + let status = terminated.terminationStatus + let uncaughtSignal = terminated.terminationReason == .uncaughtSignal + Task { await self?.handleTermination(runID: runID, status: status, uncaughtSignal: uncaughtSignal) } + } + + do { + try childProcess.run() + } catch { + failLaunch(reason: "failed to spawn node at \(config.nodePath): \(error)") + return + } + + process = childProcess + + // Write the bootstrap envelope as one JSON line, then close stdin. + // This mirrors the TS reference: the desktop backend pipes a + // single-chunk `Stream` into the child's stdin sink with + // `endOnDone: true` (the default in + // NodeChildProcessSpawner's `resolveStdinOption`), so the writable + // — and therefore the child's stdin — is ended as soon as that one + // chunk has been written. The server only ever reads one line off + // the bootstrap fd (`readBootstrapEnvelope` in + // apps/server/src/bootstrap.ts), so closing stdin afterward matches + // upstream behavior and cannot truncate the envelope. + do { + let data = Data((bootstrapLine + "\n").utf8) + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + } catch { + // The child may have already exited (e.g. immediate crash) + // before we could write; the termination handler above will + // observe the exit and drive the restart loop. Swallow here. + } + stdinPipe.fileHandleForWriting.closeFile() + + Task { await self.pollReadiness(runID: runID, pid: childProcess.processIdentifier) } + } + + private func failLaunch(reason: String) { + emit(.crashed(reason: reason, restartAttempt: restartAttempt)) + scheduleRestartIfNeeded(reason: reason) + } + + // MARK: - Readiness + + private func pollReadiness(runID: UUID, pid: Int32) async { + guard let url = URL(string: "http://\(config.host):\(config.port)\(Self.readinessPath)") else { + return + } + let deadline = Date().addingTimeInterval(Self.readinessTimeout) + + while Date() < deadline { + guard runID == currentRunID else { return } + if await probeReady(url: url) { + guard runID == currentRunID else { return } + restartAttempt = 0 + emit(.ready(pid: pid)) + return + } + try? await Task.sleep(nanoseconds: UInt64(Self.readinessPollInterval * 1_000_000_000)) + } + + guard runID == currentRunID else { return } + let reason = "timed out waiting for readiness at \(url.absoluteString)" + emit(.crashed(reason: reason, restartAttempt: restartAttempt)) + // A readiness timeout does not mean the child died — it is still + // running and holding the port. Terminate it before restarting so + // the next launch() doesn't leak/orphan it (Foundation does not + // kill a child when its `Process` reference is dropped) and doesn't + // fail to bind the still-occupied port. + await terminateRun(runID: runID) + scheduleRestartIfNeeded(reason: reason) + } + + /// Terminates the child belonging to `runID` (if it is still the + /// current run) and invalidates that run's id first, so the process's + /// `terminationHandler` — which fires asynchronously off this kill — + /// observes a stale `runID` and no-ops instead of driving its own + /// duplicate crash/restart cycle. + private func terminateRun(runID: UUID) async { + guard runID == currentRunID, let runningProcess = process else { return } + currentRunID = UUID() + process = nil + guard runningProcess.isRunning else { return } + runningProcess.terminate() + let exitedGracefully = await waitForExit(of: runningProcess, timeout: Self.terminateGrace) + if !exitedGracefully { + kill(runningProcess.processIdentifier, SIGKILL) + _ = await waitForExit(of: runningProcess, timeout: Self.terminateGrace) + } + } + + private func probeReady(url: URL) async -> Bool { + var request = URLRequest(url: url) + request.timeoutInterval = 1 + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { return false } + return (200..<300).contains(http.statusCode) + } catch { + return false + } + } + + // MARK: - Termination + restart + + private func handleTermination(runID: UUID, status: Int32, uncaughtSignal: Bool) async { + guard runID == currentRunID else { return } + process = nil + + guard desiredRunning else { + emit(.stopped) + return + } + + let reason = uncaughtSignal ? "terminated by signal" : "exited with code \(status)" + emit(.crashed(reason: reason, restartAttempt: restartAttempt)) + scheduleRestartIfNeeded(reason: reason) + } + + private func scheduleRestartIfNeeded(reason: String) { + guard desiredRunning else { return } + let delay = Self.backoffDelay(forAttempt: restartAttempt) + restartAttempt += 1 + restartTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + await self?.restartIfStillDesired() + } + } + + private func restartIfStillDesired() async { + guard desiredRunning else { return } + await launch() + } + + /// Exponential backoff mirroring the TS reference's + /// `Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY)`: + /// 500ms, 1s, 2s, 4s, 8s, capped at 10s from `attempt == 5` onward. + public static func backoffDelay(forAttempt attempt: Int) -> TimeInterval { + let multiplier = pow(2.0, Double(max(attempt, 0))) + return min(initialRestartDelay * multiplier, maxRestartDelay) + } + + // MARK: - Helpers + + private func waitForExit(of process: Process, timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + try? await Task.sleep(nanoseconds: 50_000_000) + } + return !process.isRunning + } + + private func emit(_ state: SidecarState) { + currentState = state + for continuation in stateContinuations.values { + continuation.yield(state) + } + } + + /// Opens (rotating) stdout/stderr log files under + /// `/logs/sidecar/`. Each new run rotates the previous file to + /// `.log.1` before truncating a fresh one, keeping one prior + /// session's output alongside the current one. + private func makeLogHandles() throws -> (stdout: FileHandle, stderr: FileHandle) { + try fileManager.createDirectory( + atPath: config.logDirectory, withIntermediateDirectories: true) + + let stdoutPath = (config.logDirectory as NSString).appendingPathComponent("stdout.log") + let stderrPath = (config.logDirectory as NSString).appendingPathComponent("stderr.log") + rotate(path: stdoutPath) + rotate(path: stderrPath) + + fileManager.createFile(atPath: stdoutPath, contents: nil) + fileManager.createFile(atPath: stderrPath, contents: nil) + + guard let stdout = FileHandle(forWritingAtPath: stdoutPath), + let stderr = FileHandle(forWritingAtPath: stderrPath) + else { + throw ServerProcessError.logFileOpenFailed + } + return (stdout, stderr) + } + + private func rotate(path: String) { + guard fileManager.fileExists(atPath: path) else { return } + let rotatedPath = path + ".1" + try? fileManager.removeItem(atPath: rotatedPath) + try? fileManager.moveItem(atPath: path, toPath: rotatedPath) + } +} + +public enum ServerProcessError: Error, Sendable { + case logFileOpenFailed +} diff --git a/apps/mac/Sources/SidecarKit/SidecarConfig.swift b/apps/mac/Sources/SidecarKit/SidecarConfig.swift new file mode 100644 index 00000000000..3060ec15666 --- /dev/null +++ b/apps/mac/Sources/SidecarKit/SidecarConfig.swift @@ -0,0 +1,134 @@ +import Darwin +import Foundation + +/// Resolves the dev-build default location of the t3 server entry point, +/// mirroring ARCHITECTURE.md's `/apps/server/dist/bin.mjs`. +/// Packaged builds must inject an explicit `entryPath` instead (bundling a +/// Node runtime + server into the .app is a post-v1 packaging task). +public enum SidecarEntryPathResolver { + /// Walks up from this source file's on-disk location + /// (`apps/mac/Sources/SidecarKit/SidecarConfig.swift`) to the repo root, + /// then appends `apps/server/dist/bin.mjs`. Only meaningful for local + /// checkouts where SidecarKit's sources live at their usual path. + public static func devDefaultEntryPath(sourceFile: String = #filePath) -> String { + // `#filePath` is not reliably absolute here: the app-bundle build + // invokes swiftc with `-working-directory /apps`, baking a + // value that resolves to `/apps/apps/...` at runtime — which + // broke the old fixed five-component walk. Walk upward instead and + // take the first ancestor that actually contains the built entry. + var url = URL(fileURLWithPath: sourceFile).deletingLastPathComponent() + for _ in 0..<12 { + let candidate = url.appendingPathComponent("apps/server/dist/bin.mjs") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate.path + } + url.deleteLastPathComponent() + } + // Nothing found (server not built yet / sources moved): fall back to + // the historical repo-root guess so error messages still name a + // plausible path. + var fixed = URL(fileURLWithPath: sourceFile) + for _ in 0..<5 { + fixed.deleteLastPathComponent() + } + return fixed.appendingPathComponent("apps/server/dist/bin.mjs").path + } +} + +/// Picks an available loopback TCP port by binding to port 0 (letting the +/// kernel assign one), reading it back via `getsockname`, then closing the +/// socket. There is an inherent TOCTOU race between closing this probe +/// socket and the caller binding the same port — acceptable for a local dev +/// supervisor where nothing else on the machine is aggressively racing for +/// ports. +public enum FreePortPicker { + public enum PickError: Error, Sendable { + case socketCreationFailed + case bindFailed + case getsocknameFailed + } + + public static func pick(host: String = "127.0.0.1") throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw PickError.socketCreationFailed } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr.s_addr = inet_addr(host) + + let bindResult = withUnsafePointer(to: &addr) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { throw PickError.bindFailed } + + var boundAddr = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let getsocknameResult = withUnsafeMutablePointer(to: &boundAddr) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + getsockname(fd, sockaddrPointer, &length) + } + } + guard getsocknameResult == 0 else { throw PickError.getsocknameFailed } + + return Int(UInt16(bigEndian: boundAddr.sin_port)) + } +} + +/// Everything `ServerProcess` needs to spawn and supervise one t3 server +/// child: binary location, argv-relevant values, and where its logs go. +public struct SidecarConfig: Sendable { + public var nodePath: String + public var entryPath: String + public var port: Int + public var host: String + public var baseDir: String + public var logDirectory: String + public var noBrowser: Bool + + /// - Parameters: + /// - nodePath: Absolute path to a `node` binary, typically from + /// `NodeRuntimeLocator.locate()`. + /// - entryPath: Path to `dist/bin.mjs`. Defaults to the dev-checkout + /// location; packaged builds must override this. + /// - port: Loopback port to bind. Defaults to `FreePortPicker.pick()`; + /// pass a fixed value to override. + /// - baseDir: Server state directory. Defaults to + /// `~/Library/Application Support/SergeCode`, distinct from any + /// Electron install's state dir. + /// - logDirectory: Where rotated stdout/stderr logs land. Defaults to + /// `/logs/sidecar`. + public init( + nodePath: String, + entryPath: String = SidecarEntryPathResolver.devDefaultEntryPath(), + port: Int? = nil, + host: String = "127.0.0.1", + baseDir: String? = nil, + logDirectory: String? = nil, + noBrowser: Bool = true + ) throws { + self.nodePath = nodePath + self.entryPath = entryPath + self.port = try port ?? FreePortPicker.pick(host: host) + self.host = host + let resolvedBaseDir = baseDir ?? SidecarConfig.defaultBaseDir() + self.baseDir = resolvedBaseDir + self.logDirectory = + logDirectory ?? (resolvedBaseDir as NSString).appendingPathComponent("logs/sidecar") + self.noBrowser = noBrowser + } + + /// `~/Library/Application Support/SergeCode` — kept distinct from any + /// Electron desktop install's `T3CODE_HOME` so the two never share a + /// SQLite file. + public static func defaultBaseDir(fileManager: FileManager = .default) -> String { + let appSupport = + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent( + "Library/Application Support") + return appSupport.appendingPathComponent("SergeCode").path + } +} diff --git a/apps/mac/Sources/T3Kit/ActivityPayloads.swift b/apps/mac/Sources/T3Kit/ActivityPayloads.swift new file mode 100644 index 00000000000..fd4582691a0 --- /dev/null +++ b/apps/mac/Sources/T3Kit/ActivityPayloads.swift @@ -0,0 +1,116 @@ +// Typed views over well-known `OrchestrationThreadActivity.payload` shapes. +// The orchestration contract leaves activity payloads as `Schema.Unknown`; +// the real shapes live in packages/contracts/src/providerRuntime.ts and the +// server's ProviderRuntimeIngestion.ts projection. These kinds all arrive +// with tone `.info`, so consumers must dispatch on `activity.kind` (not tone) +// before falling back to generic notice rendering. + +import Foundation + +/// Well-known `OrchestrationThreadActivity.kind` values that carry a typed +/// payload (ProviderRuntimeIngestion.ts). +public enum ActivityKind { + /// Tone `.approval` (unlike the rest): only `approval.requested` is an + /// actionable request; `approval.resolved` records the outcome. + public static let approvalRequested = "approval.requested" + public static let approvalResolved = "approval.resolved" + public static let userInputRequested = "user-input.requested" + public static let userInputResolved = "user-input.resolved" + public static let turnPlanUpdated = "turn.plan.updated" + public static let contextWindowUpdated = "context-window.updated" +} + +// MARK: - user-input.requested / user-input.resolved + +/// `UserInputQuestionOption` (providerRuntime.ts). +public struct UserInputQuestionOption: Decodable, Sendable, Hashable { + public var label: String + public var description: String? +} + +/// `UserInputQuestion` (providerRuntime.ts): one question the provider needs +/// answered before the turn continues. `multiSelect` defaults to false. +public struct UserInputQuestion: Decodable, Sendable, Hashable { + public var id: String + public var header: String + public var question: String + public var options: [UserInputQuestionOption] + public var multiSelect: Bool + + private enum CodingKeys: String, CodingKey { case id, header, question, options, multiSelect } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + header = try c.decode(String.self, forKey: .header) + question = try c.decode(String.self, forKey: .question) + options = try c.decodeIfPresent([UserInputQuestionOption].self, forKey: .options) ?? [] + multiSelect = try c.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false + } +} + +/// Activity payload for kind `user-input.requested`: +/// `{ requestId?, questions: UserInputQuestion[] }`. +public struct UserInputRequestedActivityPayload: Decodable, Sendable { + public var requestId: String? + public var questions: [UserInputQuestion] +} + +/// Activity payload for kind `user-input.resolved`: `{ requestId?, answers }`. +public struct UserInputResolvedActivityPayload: Decodable, Sendable { + public var requestId: String? +} + +// MARK: - turn.plan.updated + +public enum TurnPlanStepStatus: String, Decodable, Sendable { + case pending, inProgress, completed +} + +/// One `{ step, status? }` entry of a `turn.plan.updated` payload. +public struct TurnPlanStep: Decodable, Sendable, Hashable { + public var step: String + public var status: TurnPlanStepStatus? + + private enum CodingKeys: String, CodingKey { case step, status } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + step = try c.decode(String.self, forKey: .step) + // Unknown future status strings degrade to nil rather than failing + // the whole payload decode. + status = try? c.decodeIfPresent(TurnPlanStepStatus.self, forKey: .status) + } +} + +/// Activity payload for kind `turn.plan.updated`: +/// `{ plan: TurnPlanStep[], explanation?: string | null }`. +public struct TurnPlanUpdatedActivityPayload: Decodable, Sendable { + public var plan: [TurnPlanStep] + public var explanation: String? +} + +// MARK: - context-window.updated + +/// Activity payload for kind `context-window.updated` — the flattened +/// `ThreadTokenUsageSnapshot` fields (providerRuntime.ts). Only the fields +/// the UI meter needs are modeled; the rest round-trip through the opaque +/// activity payload untouched. +public struct ContextWindowUpdatedActivityPayload: Decodable, Sendable { + public var usedTokens: Int + public var maxTokens: Int? + public var inputTokens: Int? + public var outputTokens: Int? + public var compactsAutomatically: Bool? +} + +// MARK: - Decode helper + +extension OrchestrationThreadActivity { + /// Decodes this activity's opaque payload as the given typed shape; + /// `nil` when the payload doesn't match (defensive — a provider drift + /// should degrade to generic rendering, not crash). + public func decodePayload(_ type: T.Type) -> T? { + try? payload.decode(as: T.self, using: WireCoding.decoder) + } +} diff --git a/apps/mac/Sources/T3Kit/AuthClient.swift b/apps/mac/Sources/T3Kit/AuthClient.swift new file mode 100644 index 00000000000..70aad8b5838 --- /dev/null +++ b/apps/mac/Sources/T3Kit/AuthClient.swift @@ -0,0 +1,192 @@ +// AuthClient — HTTP auth bootstrap for the t3 server sidecar's +// desktop-managed-local auth policy (wire-protocol.md §1.2, +// ARCHITECTURE.md "Sidecar contract"). +// +// Flow (mirrors `packages/client-runtime/src/authorization/remote.ts` and +// `apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts`): +// 1. POST /oauth/token — RFC 8693 token-exchange grant, trading the +// sidecar's one-shot `desktopBootstrapToken` for a bearer access token +// (`AuthAccessTokenResult`, `packages/contracts/src/environmentHttp.ts:187-194`). +// 2. POST /api/auth/websocket-ticket — mint a short-lived, per-connection +// `wsTicket` (`AuthWebSocketTicketResult`, `:196-200`), authenticated +// with `Authorization: Bearer `. +// 3. Build the final socket URL: wsBaseURL with path forced to `/ws` and +// `?wsTicket=` appended. Call this fresh on every (re)connect — +// tickets are short-lived and not resumable (§risk4, §risk8). + +import Foundation + +/// Configuration required to bootstrap an authenticated WebSocket connection +/// to a locally-spawned t3 server sidecar (§1.2). +public struct AuthConfig: Sendable { + /// e.g. `http://127.0.0.1:3773` — base for the local auth HTTP API. + public let httpBaseURL: URL + /// e.g. `ws://127.0.0.1:3773` — path is forced to `/ws` by `AuthClient`. + public let wsBaseURL: URL + /// One-shot bootstrap credential handed to the sidecar over stdin + /// (SidecarKit's `DesktopBackendBootstrap.desktopBootstrapToken`). + public let desktopBootstrapToken: String + + public init(httpBaseURL: URL, wsBaseURL: URL, desktopBootstrapToken: String) { + self.httpBaseURL = httpBaseURL + self.wsBaseURL = wsBaseURL + self.desktopBootstrapToken = desktopBootstrapToken + } +} + +/// Result of `POST /api/auth/websocket-ticket` (`AuthWebSocketTicketResult`, +/// §1.2). +public struct WsTicket: Sendable { + public let ticket: String + /// ISO-8601 UTC string (§5.1 — `Schema.DateTimeUtc` encodes to a string + /// on the wire, not a numeric epoch). + public let expiresAt: String + + public init(ticket: String, expiresAt: String) { + self.ticket = ticket + self.expiresAt = expiresAt + } +} + +/// Exchanges the sidecar's one-shot bootstrap token for a bearer access +/// token, then mints short-lived per-connection WebSocket tickets, over the +/// sidecar's local HTTP auth API (desktop-managed-local policy, §1.2). No +/// Clerk/pairing/DPoP in v1 (ARCHITECTURE.md). +public actor AuthClient { + private let config: AuthConfig + private let urlSession: URLSession + private var cachedAccessToken: String? + + public init(config: AuthConfig, urlSession: URLSession = .shared) { + self.config = config + self.urlSession = urlSession + } + + /// Exchanges the desktop bootstrap token for a bearer access token via + /// `POST /oauth/token` (RFC 8693 token-exchange grant). The result is + /// cached for the lifetime of this client, matching the reference + /// desktop client's behavior (`DesktopLocalEnvironmentAuth.ts`), since a + /// sidecar process hands out exactly one bootstrap token per launch. + public func acquireAccessToken() async throws -> String { + if let cachedAccessToken { + return cachedAccessToken + } + + let url = config.httpBaseURL.appendingPathComponent("oauth/token") + var request = URLRequest(url: url) + request.httpMethod = "POST" + // `AuthTokenExchangeRequest` is `HttpApiSchema.asFormUrlEncoded()` + // (packages/contracts/src/auth.ts:183), so the server decodes this + // body as `application/x-www-form-urlencoded`, not JSON. + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + // AuthTokenExchangeRequest (packages/contracts/src/environmentHttp.ts:175-183). + let bodyFields: [(String, String)] = [ + ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), + ("subject_token", config.desktopBootstrapToken), + ("subject_token_type", "urn:t3:params:oauth:token-type:environment-bootstrap"), + ("requested_token_type", "urn:ietf:params:oauth:token-type:access_token"), + ("client_label", "SergeCode"), + ("client_device_type", "desktop"), + ] + var comps = URLComponents() + comps.queryItems = bodyFields.map { URLQueryItem(name: $0.0, value: $0.1) } + request.httpBody = comps.percentEncodedQuery?.data(using: .utf8) + + let data = try await perform(request) + + struct TokenResponse: Decodable { + let access_token: String + } + let decoded: TokenResponse + do { + decoded = try JSONDecoder().decode(TokenResponse.self, from: data) + } catch { + throw T3Error.auth("Failed to decode /oauth/token response: \(error)") + } + + cachedAccessToken = decoded.access_token + return decoded.access_token + } + + /// Mints a fresh, short-lived WebSocket ticket via + /// `POST /api/auth/websocket-ticket`. Must be called again on every + /// reconnect — tickets are not reusable across sockets (§risk8). + public func mintWebSocketTicket(accessToken: String) async throws -> WsTicket { + let url = config.httpBaseURL.appendingPathComponent("api/auth/websocket-ticket") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data = try await perform(request) + + struct TicketResponse: Decodable { + let ticket: String + let expiresAt: String + } + do { + let decoded = try JSONDecoder().decode(TicketResponse.self, from: data) + return WsTicket(ticket: decoded.ticket, expiresAt: decoded.expiresAt) + } catch { + throw T3Error.auth("Failed to decode /api/auth/websocket-ticket response: \(error)") + } + } + + /// Convenience: acquire an access token, mint a fresh ticket, and build + /// the final `ws://…/ws?wsTicket=…` socket URL. Call this fresh on every + /// (re)connect attempt — there is no resume token, so a reconnect always + /// means a brand-new ticket (§4.3, §risk4, §risk8). + /// + /// If ticket minting fails while using a cached access token, the cache + /// is cleared and the exchange is retried once with a fresh token, in + /// case the cached token expired (`AuthAccessTokenResult.expires_in`). + public func makeSocketURL() async throws -> URL { + let accessToken = try await acquireAccessToken() + do { + let ticket = try await mintWebSocketTicket(accessToken: accessToken) + return try socketURL(ticket: ticket) + } catch T3Error.auth { + cachedAccessToken = nil + let refreshedToken = try await acquireAccessToken() + let ticket = try await mintWebSocketTicket(accessToken: refreshedToken) + return try socketURL(ticket: ticket) + } + } + + private func socketURL(ticket: WsTicket) throws -> URL { + guard var components = URLComponents(url: config.wsBaseURL, resolvingAgainstBaseURL: false) else { + throw T3Error.auth("Invalid wsBaseURL: \(config.wsBaseURL.absoluteString)") + } + components.path = "/ws" + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "wsTicket", value: ticket.ticket)) + components.queryItems = queryItems + + guard let url = components.url else { + throw T3Error.auth("Failed to construct socket URL from \(config.wsBaseURL.absoluteString)") + } + return url + } + + private func perform(_ request: URLRequest) async throws -> Data { + let requestURL = request.url?.absoluteString ?? "" + let data: Data + let response: URLResponse + do { + (data, response) = try await urlSession.data(for: request) + } catch { + throw T3Error.auth("HTTP request to \(requestURL) failed: \(error)") + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw T3Error.auth("Non-HTTP response from \(requestURL)") + } + guard (200..<300).contains(httpResponse.statusCode) else { + let bodyText = String(data: data, encoding: .utf8) ?? "" + throw T3Error.auth("HTTP \(httpResponse.statusCode) from \(requestURL): \(bodyText)") + } + return data + } +} diff --git a/apps/mac/Sources/T3Kit/JSONValue.swift b/apps/mac/Sources/T3Kit/JSONValue.swift new file mode 100644 index 00000000000..4f489c760ac --- /dev/null +++ b/apps/mac/Sources/T3Kit/JSONValue.swift @@ -0,0 +1,101 @@ +// JSONValue.swift +// Free-form JSON value for opaque/untyped wire fields: Schema.Option payloads +// (§5.4), OrchestrationThreadActivity.payload (Schema.Unknown, §11), defect +// JSON (§5.6), and any Request/Exit payload the caller wants to pass through +// without a fully-typed model. + +import Foundation + +public enum JSONValue: Codable, Sendable, Hashable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value while decoding JSONValue" + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } + + /// Object member access; `nil` if this isn't an object or the key is absent. + public subscript(_ key: String) -> JSONValue? { + guard case let .object(dict) = self else { return nil } + return dict[key] + } + + public var stringValue: String? { + if case let .string(value) = self { return value } + return nil + } + + public var intValue: Int? { + switch self { + case let .int(value): + return value + case let .double(value): + return Int(exactly: value) + default: + return nil + } + } + + public var arrayValue: [JSONValue]? { + if case let .array(value) = self { return value } + return nil + } + + public var objectValue: [String: JSONValue]? { + if case let .object(value) = self { return value } + return nil + } + + /// Re-encode this subtree and decode it into a typed model. Used to promote + /// an opaque `JSONValue` (e.g. a Chunk value or Exit payload) into a + /// concrete RPC-method-specific type one layer up. + public func decode(as type: T.Type, using decoder: JSONDecoder) throws -> T { + let data = try JSONEncoder().encode(self) + return try decoder.decode(T.self, from: data) + } +} diff --git a/apps/mac/Sources/T3Kit/OrchestrationMapping.swift b/apps/mac/Sources/T3Kit/OrchestrationMapping.swift new file mode 100644 index 00000000000..20f5a336fb4 --- /dev/null +++ b/apps/mac/Sources/T3Kit/OrchestrationMapping.swift @@ -0,0 +1,144 @@ +// Mapping helpers: turn wire-shaped orchestration models (ISO date strings, +// interleaved messages/activities/proposed-plans, wire enums) into +// UI-agnostic native Swift values. T3Kit cannot depend on SergeCodeMac (the +// reverse is true, per Package.swift), so this file does not produce +// SergeCodeMac.Entities types directly — it does the wire-quirk +// interpretation (date parsing, chronological merge of a thread's three +// separate append-only lists, best-effort extraction from opaque +// `Schema.Unknown` activity payloads) so the app layer's BackendService +// adapter only has to do a thin, mechanical conversion into its own +// `Project`/`ChatThread`/`TimelineItem`/... types. + +import Foundation + +public enum WireDate { + // `ISO8601DateFormatter` is a `class` and not `Sendable`, but these + // instances are configured once at initialization and only ever used + // for read-only `date(from:)` parsing afterward, so shared concurrent + // access is safe in practice; `nonisolated(unsafe)` opts out of the + // compiler's (overly conservative) global-actor-isolation suggestion. + nonisolated(unsafe) private static let withFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + nonisolated(unsafe) private static let whole: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// Parses an `IsoDateTime` wire string (§5.1). Tries the fractional- + /// seconds form first (the server's actual `DateTime.formatIso` output), + /// falling back to whole seconds for leniency. + public static func parse(_ iso: String) -> Date? { + withFractional.date(from: iso) ?? whole.date(from: iso) + } +} + +/// A single, chronologically-ordered entry in a thread's UI timeline, +/// merged from `OrchestrationThread.messages` + `.activities` + +/// `.proposedPlans` + `.checkpoints` (each a separate append-only wire list). +public enum T3TimelineEntry: Sendable { + case userMessage(id: String, text: String, at: Date) + case assistantMessage(id: String, markdown: String, isStreaming: Bool, at: Date) + /// `OrchestrationThreadActivity` with a tone other than `.approval` + /// (`.info`/`.tool`/`.error`) — tool events and inline notices. + case activity(OrchestrationThreadActivity, at: Date) + /// `OrchestrationThreadActivity` with tone `.approval`. `payload` is + /// `Schema.Unknown` on the wire (provider-specific); `bestEffortRequestId` + /// is a heuristic extraction from common key names and MUST be verified + /// against the actual provider-runtime payload shape before being wired + /// to `T3Client.respondToApproval` — see the file-level open question in + /// this slice's summary. + case approvalActivity(OrchestrationThreadActivity, bestEffortRequestId: String?, at: Date) + case checkpoint(OrchestrationCheckpointSummary, at: Date) + case proposedPlan(OrchestrationProposedPlan, at: Date) + + public var sortDate: Date { + switch self { + case .userMessage(_, _, let at), + .assistantMessage(_, _, _, let at), + .activity(_, let at), + .approvalActivity(_, _, let at), + .checkpoint(_, let at), + .proposedPlan(_, let at): + return at + } + } +} + +public enum OrchestrationMapping { + /// Flattens one thread's message/activity/plan/checkpoint lists into a + /// single chronological timeline. Entries with an unparseable `createdAt` + /// are dropped (defensive: a malformed date should not crash the UI). + public static func timeline(for thread: OrchestrationThread) -> [T3TimelineEntry] { + var entries: [T3TimelineEntry] = [] + entries.reserveCapacity( + thread.messages.count + thread.activities.count + thread.proposedPlans.count + + thread.checkpoints.count) + + for message in thread.messages { + guard let at = WireDate.parse(message.createdAt) else { continue } + switch message.role { + case .user: + entries.append(.userMessage(id: message.id, text: message.text, at: at)) + case .assistant: + entries.append( + .assistantMessage(id: message.id, markdown: message.text, isStreaming: message.streaming, at: at)) + case .system: + // System messages have no dedicated UI timeline case today; + // surface them as a tool-tone activity-shaped notice instead + // of silently dropping them. + entries.append( + .activity( + OrchestrationThreadActivity( + id: message.id, tone: .info, kind: "system-message", summary: message.text, + payload: .null, turnId: message.turnId, createdAt: message.createdAt), + at: at)) + } + } + + for activity in thread.activities { + guard let at = WireDate.parse(activity.createdAt) else { continue } + if activity.tone == .approval { + entries.append( + .approvalActivity(activity, bestEffortRequestId: extractRequestId(from: activity.payload), at: at)) + } else { + entries.append(.activity(activity, at: at)) + } + } + + for plan in thread.proposedPlans { + guard let at = WireDate.parse(plan.createdAt) else { continue } + entries.append(.proposedPlan(plan, at: at)) + } + + for checkpoint in thread.checkpoints { + guard let at = WireDate.parse(checkpoint.completedAt) else { continue } + entries.append(.checkpoint(checkpoint, at: at)) + } + + return entries.sorted { $0.sortDate < $1.sortDate } + } + + /// Best-effort extraction of a `requestId` from an opaque + /// `OrchestrationThreadActivity.payload` (tone `.approval`). Tries the + /// common key spellings; returns `nil` if none match, in which case the + /// caller must fall back to some other correlation strategy (or this + /// helper needs updating once the true payload shape is confirmed). + public static func extractRequestId(from payload: JSONValue) -> String? { + guard let object = payload.objectValue else { return nil } + for key in ["requestId", "request_id", "approvalRequestId"] { + if let value = object[key]?.stringValue { return value } + } + return nil + } + + /// Maps a `ProviderApprovalDecision` from a simple "approve or not" + /// choice, the shape most approval UIs actually offer. + public static func approvalDecision(approve: Bool) -> ProviderApprovalDecision { + approve ? .accept : .decline + } +} diff --git a/apps/mac/Sources/T3Kit/OrchestrationModels.swift b/apps/mac/Sources/T3Kit/OrchestrationModels.swift new file mode 100644 index 00000000000..7e6f2572b98 --- /dev/null +++ b/apps/mac/Sources/T3Kit/OrchestrationModels.swift @@ -0,0 +1,1673 @@ +// Typed wire models for the "orchestration" RPC family (§3.1 of +// docs/wire-protocol.md), hand-ported 1:1 from +// packages/contracts/src/orchestration.ts. This is the v1 method subset: +// dispatchCommand (write path), getTurnDiff/getFullThreadDiff (diff panel), +// replayEvents (reconnect catch-up), getArchivedShellSnapshot/subscribeShell +// (project+thread sidebar), subscribeThread (chat timeline/approvals/ +// checkpoints). Diagnostics/keybindings/source-control RPCs are out of v1 +// scope (see apps/mac/ARCHITECTURE.md) and are not modeled here. +// +// Conventions (see WireCoding.swift / T3Error.swift for shared helpers): +// - IsoDateTime is a plain wire `String` (not Foundation `Date`); see +// OrchestrationMapping.swift for the ISO parsing helper the app layer +// should use when it needs a `Date`. +// - Branded ids (ThreadId, ProjectId, CommandId, ...) are plain `String`. +// - `Schema.NullOr(x)` (required key, value-or-null) -> Swift `Optional`. +// Swift's synthesized *decode* already handles both "absent key" and +// "present but null" via `decodeIfPresent`, so no special decode-side +// handling is needed. Synthesized *encode*, however, uses +// `encodeIfPresent` (which OMITS the key for `nil`) — that is wrong for +// a bare (non-optional) `NullOr` field, where the key must stay present +// with an explicit `null`. The two command structs that carry a +// required `NullOr` field (`ThreadCreateCommand`, +// `ThreadTurnStartBootstrapCreateThread`) therefore implement a manual +// `encode(to:)`. Everywhere else optional-with-omission is correct. +// - `Schema.withDecodingDefault(...)` fields get a manual `init(from:)` +// using `WireCoding`'s `decode(_:forKey:default:)` helper. + +import Foundation + +// MARK: - Shared enums / value types + +public enum RuntimeMode: String, Codable, Sendable { + case approvalRequired = "approval-required" + case autoAcceptEdits = "auto-accept-edits" + case fullAccess = "full-access" + + public static let wireDefault: RuntimeMode = .fullAccess +} + +public enum ProviderInteractionMode: String, Codable, Sendable { + case `default` + case plan + + public static let wireDefault: ProviderInteractionMode = .default +} + +public enum ProviderApprovalDecision: String, Codable, Sendable { + case accept + case acceptForSession + case decline + case cancel +} + +/// `Schema.Record(Schema.String, Schema.Unknown)` — free-form answers keyed +/// by prompt id. +public typealias ProviderUserInputAnswers = [String: JSONValue] + +public enum ProviderApprovalPolicy: String, Codable, Sendable { + case untrusted + case onFailure = "on-failure" + case onRequest = "on-request" + case never +} + +public enum ProviderSandboxMode: String, Codable, Sendable { + case readOnly = "read-only" + case workspaceWrite = "workspace-write" + case dangerFullAccess = "danger-full-access" +} + +/// `ProviderOptionSelectionValue = Union([TrimmedNonEmptyString, Boolean])`. +public enum ProviderOptionSelectionValue: Codable, Sendable, Hashable { + case string(String) + case bool(Bool) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else { + self = .string(try container.decode(String.self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + } + } +} + +public struct ProviderOptionSelection: Codable, Sendable, Hashable { + public var id: String + public var value: ProviderOptionSelectionValue + + public init(id: String, value: ProviderOptionSelectionValue) { + self.id = id + self.value = value + } +} + +/// `ModelSelection` (orchestration.ts). `options` is +/// `Schema.optionalKey(ProviderOptionSelections)`, and +/// `ProviderOptionSelections` itself accepts either the canonical +/// `[ProviderOptionSelection]` array or a legacy free-form record — kept as +/// opaque `JSONValue` here so both shapes round-trip losslessly; use +/// `canonicalOptions`/`init(instanceId:model:canonicalOptions:)` to work +/// with the typed array form. +/// +/// §6 risk 7: always emit `{ instanceId, model }` — never the legacy +/// `{ provider, model }` shape (the legacy-promotion transform is decode-only +/// server-side). +public struct ModelSelection: Codable, Sendable, Hashable { + public var instanceId: String + public var model: String + public var options: JSONValue? + + public init(instanceId: String, model: String, options: JSONValue? = nil) { + self.instanceId = instanceId + self.model = model + self.options = options + } + + public init(instanceId: String, model: String, canonicalOptions: [ProviderOptionSelection]) { + self.instanceId = instanceId + self.model = model + self.options = try? Self.encodeCanonicalOptions(canonicalOptions) + } + + private static func encodeCanonicalOptions(_ options: [ProviderOptionSelection]) throws -> JSONValue { + let data = try WireCoding.encoder.encode(options) + return try WireCoding.decoder.decode(JSONValue.self, from: data) + } + + /// Best-effort decode of `options` as the canonical selection array; + /// `nil` if absent or in the legacy record shape. + public var canonicalOptions: [ProviderOptionSelection]? { + guard let options else { return nil } + return try? options.decode(as: [ProviderOptionSelection].self, using: WireCoding.decoder) + } +} + +// MARK: - Repository identity (environment.ts; referenced by projects) + +public struct RepositoryIdentityLocator: Codable, Sendable, Hashable { + public let source: String = "git-remote" + public var remoteName: String + public var remoteUrl: String + + public init(remoteName: String, remoteUrl: String) { + self.remoteName = remoteName + self.remoteUrl = remoteUrl + } +} + +public struct RepositoryIdentity: Codable, Sendable, Hashable { + public var canonicalKey: String + public var locator: RepositoryIdentityLocator + public var rootPath: String? + + public init(canonicalKey: String, locator: RepositoryIdentityLocator, rootPath: String? = nil) { + self.canonicalKey = canonicalKey + self.locator = locator + self.rootPath = rootPath + } +} + +// MARK: - Project script + +public enum ProjectScriptIcon: String, Codable, Sendable { + case play, test, lint, configure, build, debug +} + +public struct ProjectScript: Codable, Sendable, Hashable { + public var id: String + public var name: String + public var command: String + public var icon: ProjectScriptIcon + public var runOnWorktreeCreate: Bool + public var previewUrl: String? + public var autoOpenPreview: Bool? + + public init( + id: String, name: String, command: String, icon: ProjectScriptIcon, + runOnWorktreeCreate: Bool, previewUrl: String? = nil, autoOpenPreview: Bool? = nil + ) { + self.id = id + self.name = name + self.command = command + self.icon = icon + self.runOnWorktreeCreate = runOnWorktreeCreate + self.previewUrl = previewUrl + self.autoOpenPreview = autoOpenPreview + } +} + +// MARK: - Chat attachments (image-only union for v1; ChatAttachment is a +// Schema.Union([ChatImageAttachment]) today, so the Swift type is a struct +// with a fixed `type` discriminator rather than a speculative enum). + +public struct ChatImageAttachment: Codable, Sendable, Hashable { + public let type: String = "image" + public var id: String + public var name: String + public var mimeType: String + public var sizeBytes: Int + + public init(id: String, name: String, mimeType: String, sizeBytes: Int) { + self.id = id + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + } +} + +public typealias ChatAttachment = ChatImageAttachment + +public struct UploadChatImageAttachment: Codable, Sendable, Hashable { + public let type: String = "image" + public var name: String + public var mimeType: String + public var sizeBytes: Int + public var dataUrl: String + + public init(name: String, mimeType: String, sizeBytes: Int, dataUrl: String) { + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.dataUrl = dataUrl + } +} + +public typealias UploadChatAttachment = UploadChatImageAttachment + +// MARK: - Projects / threads (read models) + +public struct OrchestrationProject: Codable, Sendable { + public var id: String + public var title: String + public var workspaceRoot: String + public var repositoryIdentity: RepositoryIdentity? + public var defaultModelSelection: ModelSelection? + public var scripts: [ProjectScript] + public var createdAt: String + public var updatedAt: String + public var deletedAt: String? + + public init( + id: String, title: String, workspaceRoot: String, + repositoryIdentity: RepositoryIdentity? = nil, defaultModelSelection: ModelSelection? = nil, + scripts: [ProjectScript], createdAt: String, updatedAt: String, deletedAt: String? = nil + ) { + self.id = id + self.title = title + self.workspaceRoot = workspaceRoot + self.repositoryIdentity = repositoryIdentity + self.defaultModelSelection = defaultModelSelection + self.scripts = scripts + self.createdAt = createdAt + self.updatedAt = updatedAt + self.deletedAt = deletedAt + } +} + +public enum OrchestrationMessageRole: String, Codable, Sendable { + case user, assistant, system +} + +public struct OrchestrationMessage: Codable, Sendable { + public var id: String + public var role: OrchestrationMessageRole + public var text: String + public var attachments: [ChatAttachment]? + public var turnId: String? + public var streaming: Bool + public var createdAt: String + public var updatedAt: String + + public init( + id: String, role: OrchestrationMessageRole, text: String, + attachments: [ChatAttachment]? = nil, turnId: String? = nil, streaming: Bool, + createdAt: String, updatedAt: String + ) { + self.id = id + self.role = role + self.text = text + self.attachments = attachments + self.turnId = turnId + self.streaming = streaming + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct SourceProposedPlanReference: Codable, Sendable { + public var threadId: String + public var planId: String + + public init(threadId: String, planId: String) { + self.threadId = threadId + self.planId = planId + } +} + +public struct OrchestrationProposedPlan: Codable, Sendable { + public var id: String + public var turnId: String? + public var planMarkdown: String + public var implementedAt: String? + public var implementationThreadId: String? + public var createdAt: String + public var updatedAt: String + + public init( + id: String, turnId: String? = nil, planMarkdown: String, implementedAt: String? = nil, + implementationThreadId: String? = nil, createdAt: String, updatedAt: String + ) { + self.id = id + self.turnId = turnId + self.planMarkdown = planMarkdown + self.implementedAt = implementedAt + self.implementationThreadId = implementationThreadId + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + private enum CodingKeys: String, CodingKey { + case id, turnId, planMarkdown, implementedAt, implementationThreadId, createdAt, updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + turnId = try container.decodeIfPresent(String.self, forKey: .turnId) + planMarkdown = try container.decode(String.self, forKey: .planMarkdown) + implementedAt = try container.decode(String?.self, forKey: .implementedAt, default: nil) + implementationThreadId = try container.decode( + String?.self, forKey: .implementationThreadId, default: nil) + createdAt = try container.decode(String.self, forKey: .createdAt) + updatedAt = try container.decode(String.self, forKey: .updatedAt) + } +} + +public enum OrchestrationSessionStatus: String, Codable, Sendable { + case idle, starting, running, ready, interrupted, stopped, error +} + +public struct OrchestrationSession: Codable, Sendable { + public var threadId: String + public var status: OrchestrationSessionStatus + public var providerName: String? + public var providerInstanceId: String? + public var runtimeMode: RuntimeMode + public var activeTurnId: String? + public var lastError: String? + public var updatedAt: String + + public init( + threadId: String, status: OrchestrationSessionStatus, providerName: String? = nil, + providerInstanceId: String? = nil, runtimeMode: RuntimeMode = .wireDefault, + activeTurnId: String? = nil, lastError: String? = nil, updatedAt: String + ) { + self.threadId = threadId + self.status = status + self.providerName = providerName + self.providerInstanceId = providerInstanceId + self.runtimeMode = runtimeMode + self.activeTurnId = activeTurnId + self.lastError = lastError + self.updatedAt = updatedAt + } + + private enum CodingKeys: String, CodingKey { + case threadId, status, providerName, providerInstanceId, runtimeMode, activeTurnId, + lastError, updatedAt + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + threadId = try container.decode(String.self, forKey: .threadId) + status = try container.decode(OrchestrationSessionStatus.self, forKey: .status) + providerName = try container.decode(String?.self, forKey: .providerName, default: nil) + providerInstanceId = try container.decodeIfPresent(String.self, forKey: .providerInstanceId) + runtimeMode = try container.decode( + RuntimeMode.self, forKey: .runtimeMode, default: .wireDefault) + activeTurnId = try container.decode(String?.self, forKey: .activeTurnId, default: nil) + lastError = try container.decode(String?.self, forKey: .lastError, default: nil) + updatedAt = try container.decode(String.self, forKey: .updatedAt) + } +} + +public struct OrchestrationCheckpointFile: Codable, Sendable, Hashable { + public var path: String + public var kind: String + public var additions: Int + public var deletions: Int + + public init(path: String, kind: String, additions: Int, deletions: Int) { + self.path = path + self.kind = kind + self.additions = additions + self.deletions = deletions + } +} + +public enum OrchestrationCheckpointStatus: String, Codable, Sendable { + case ready, missing, error +} + +public struct OrchestrationCheckpointSummary: Codable, Sendable { + public var turnId: String + public var checkpointTurnCount: Int + public var checkpointRef: String + public var status: OrchestrationCheckpointStatus + public var files: [OrchestrationCheckpointFile] + public var assistantMessageId: String? + public var completedAt: String + + public init( + turnId: String, checkpointTurnCount: Int, checkpointRef: String, + status: OrchestrationCheckpointStatus, files: [OrchestrationCheckpointFile], + assistantMessageId: String? = nil, completedAt: String + ) { + self.turnId = turnId + self.checkpointTurnCount = checkpointTurnCount + self.checkpointRef = checkpointRef + self.status = status + self.files = files + self.assistantMessageId = assistantMessageId + self.completedAt = completedAt + } +} + +public enum OrchestrationThreadActivityTone: String, Codable, Sendable { + case info, tool, approval, error +} + +public struct OrchestrationThreadActivity: Codable, Sendable { + public var id: String + public var tone: OrchestrationThreadActivityTone + public var kind: String + public var summary: String + /// `Schema.Unknown` on the wire — provider/kind-specific, opaque by design. + public var payload: JSONValue + public var turnId: String? + public var sequence: Int? + public var createdAt: String + + public init( + id: String, tone: OrchestrationThreadActivityTone, kind: String, summary: String, + payload: JSONValue, turnId: String? = nil, sequence: Int? = nil, createdAt: String + ) { + self.id = id + self.tone = tone + self.kind = kind + self.summary = summary + self.payload = payload + self.turnId = turnId + self.sequence = sequence + self.createdAt = createdAt + } +} + +public enum OrchestrationLatestTurnState: String, Codable, Sendable { + case running, interrupted, completed, error +} + +public struct OrchestrationLatestTurn: Codable, Sendable { + public var turnId: String + public var state: OrchestrationLatestTurnState + public var requestedAt: String + public var startedAt: String? + public var completedAt: String? + public var assistantMessageId: String? + var sourceProposedPlan: SourceProposedPlanReference? + + public init( + turnId: String, state: OrchestrationLatestTurnState, requestedAt: String, + startedAt: String? = nil, completedAt: String? = nil, assistantMessageId: String? = nil + ) { + self.turnId = turnId + self.state = state + self.requestedAt = requestedAt + self.startedAt = startedAt + self.completedAt = completedAt + self.assistantMessageId = assistantMessageId + self.sourceProposedPlan = nil + } +} + +public struct OrchestrationThread: Codable, Sendable { + public var id: String + public var projectId: String + public var title: String + public var modelSelection: ModelSelection + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var branch: String? + public var worktreePath: String? + public var latestTurn: OrchestrationLatestTurn? + public var createdAt: String + public var updatedAt: String + public var archivedAt: String? + public var deletedAt: String? + public var messages: [OrchestrationMessage] + public var proposedPlans: [OrchestrationProposedPlan] + public var activities: [OrchestrationThreadActivity] + public var checkpoints: [OrchestrationCheckpointSummary] + public var session: OrchestrationSession? + + private enum CodingKeys: String, CodingKey { + case id, projectId, title, modelSelection, runtimeMode, interactionMode, branch, + worktreePath, latestTurn, createdAt, updatedAt, archivedAt, deletedAt, messages, + proposedPlans, activities, checkpoints, session + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + projectId = try c.decode(String.self, forKey: .projectId) + title = try c.decode(String.self, forKey: .title) + modelSelection = try c.decode(ModelSelection.self, forKey: .modelSelection) + runtimeMode = try c.decode(RuntimeMode.self, forKey: .runtimeMode) + interactionMode = try c.decode( + ProviderInteractionMode.self, forKey: .interactionMode, default: .wireDefault) + branch = try c.decode(String?.self, forKey: .branch, default: nil) + worktreePath = try c.decode(String?.self, forKey: .worktreePath, default: nil) + latestTurn = try c.decode(OrchestrationLatestTurn?.self, forKey: .latestTurn, default: nil) + createdAt = try c.decode(String.self, forKey: .createdAt) + updatedAt = try c.decode(String.self, forKey: .updatedAt) + archivedAt = try c.decode(String?.self, forKey: .archivedAt, default: nil) + deletedAt = try c.decode(String?.self, forKey: .deletedAt, default: nil) + messages = try c.decode([OrchestrationMessage].self, forKey: .messages) + proposedPlans = try c.decode( + [OrchestrationProposedPlan].self, forKey: .proposedPlans, default: []) + activities = try c.decode([OrchestrationThreadActivity].self, forKey: .activities) + checkpoints = try c.decode([OrchestrationCheckpointSummary].self, forKey: .checkpoints) + session = try c.decode(OrchestrationSession?.self, forKey: .session, default: nil) + } +} + +public struct OrchestrationReadModel: Codable, Sendable { + public var snapshotSequence: Int + public var projects: [OrchestrationProject] + public var threads: [OrchestrationThread] + public var updatedAt: String +} + +public struct OrchestrationProjectShell: Codable, Sendable { + public var id: String + public var title: String + public var workspaceRoot: String + public var repositoryIdentity: RepositoryIdentity? + public var defaultModelSelection: ModelSelection? + public var scripts: [ProjectScript] + public var createdAt: String + public var updatedAt: String + + public init( + id: String, title: String, workspaceRoot: String, + repositoryIdentity: RepositoryIdentity? = nil, defaultModelSelection: ModelSelection? = nil, + scripts: [ProjectScript], createdAt: String, updatedAt: String + ) { + self.id = id + self.title = title + self.workspaceRoot = workspaceRoot + self.repositoryIdentity = repositoryIdentity + self.defaultModelSelection = defaultModelSelection + self.scripts = scripts + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct OrchestrationThreadShell: Codable, Sendable { + public var id: String + public var projectId: String + public var title: String + public var modelSelection: ModelSelection + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var branch: String? + public var worktreePath: String? + public var latestTurn: OrchestrationLatestTurn? + public var createdAt: String + public var updatedAt: String + public var archivedAt: String? + public var session: OrchestrationSession? + public var latestUserMessageAt: String? + public var hasPendingApprovals: Bool + public var hasPendingUserInput: Bool + public var hasActionableProposedPlan: Bool + + private enum CodingKeys: String, CodingKey { + case id, projectId, title, modelSelection, runtimeMode, interactionMode, branch, + worktreePath, latestTurn, createdAt, updatedAt, archivedAt, session, + latestUserMessageAt, hasPendingApprovals, hasPendingUserInput, + hasActionableProposedPlan + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + projectId = try c.decode(String.self, forKey: .projectId) + title = try c.decode(String.self, forKey: .title) + modelSelection = try c.decode(ModelSelection.self, forKey: .modelSelection) + runtimeMode = try c.decode(RuntimeMode.self, forKey: .runtimeMode) + interactionMode = try c.decode( + ProviderInteractionMode.self, forKey: .interactionMode, default: .wireDefault) + branch = try c.decode(String?.self, forKey: .branch, default: nil) + worktreePath = try c.decode(String?.self, forKey: .worktreePath, default: nil) + latestTurn = try c.decode(OrchestrationLatestTurn?.self, forKey: .latestTurn, default: nil) + createdAt = try c.decode(String.self, forKey: .createdAt) + updatedAt = try c.decode(String.self, forKey: .updatedAt) + archivedAt = try c.decode(String?.self, forKey: .archivedAt, default: nil) + session = try c.decode(OrchestrationSession?.self, forKey: .session, default: nil) + latestUserMessageAt = try c.decode(String?.self, forKey: .latestUserMessageAt, default: nil) + hasPendingApprovals = try c.decode(Bool.self, forKey: .hasPendingApprovals) + hasPendingUserInput = try c.decode(Bool.self, forKey: .hasPendingUserInput) + hasActionableProposedPlan = try c.decode(Bool.self, forKey: .hasActionableProposedPlan) + } +} + +public struct OrchestrationShellSnapshot: Codable, Sendable { + public var snapshotSequence: Int + public var projects: [OrchestrationProjectShell] + public var threads: [OrchestrationThreadShell] + public var updatedAt: String +} + +/// `OrchestrationShellStreamEvent` union, discriminated by `kind`. +public enum OrchestrationShellStreamEvent: Sendable { + case projectUpserted(sequence: Int, project: OrchestrationProjectShell) + case projectRemoved(sequence: Int, projectId: String) + case threadUpserted(sequence: Int, thread: OrchestrationThreadShell) + case threadRemoved(sequence: Int, threadId: String) +} + +extension OrchestrationShellStreamEvent: Codable { + private enum CodingKeys: String, CodingKey { + case kind, sequence, project, projectId, thread, threadId + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let kind = try c.decode(String.self, forKey: .kind) + let sequence = try c.decode(Int.self, forKey: .sequence) + switch kind { + case "project-upserted": + self = .projectUpserted( + sequence: sequence, project: try c.decode(OrchestrationProjectShell.self, forKey: .project)) + case "project-removed": + self = .projectRemoved(sequence: sequence, projectId: try c.decode(String.self, forKey: .projectId)) + case "thread-upserted": + self = .threadUpserted( + sequence: sequence, thread: try c.decode(OrchestrationThreadShell.self, forKey: .thread)) + case "thread-removed": + self = .threadRemoved(sequence: sequence, threadId: try c.decode(String.self, forKey: .threadId)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, in: c, debugDescription: "Unknown OrchestrationShellStreamEvent kind: \(kind)") + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .projectUpserted(let sequence, let project): + try c.encode("project-upserted", forKey: .kind) + try c.encode(sequence, forKey: .sequence) + try c.encode(project, forKey: .project) + case .projectRemoved(let sequence, let projectId): + try c.encode("project-removed", forKey: .kind) + try c.encode(sequence, forKey: .sequence) + try c.encode(projectId, forKey: .projectId) + case .threadUpserted(let sequence, let thread): + try c.encode("thread-upserted", forKey: .kind) + try c.encode(sequence, forKey: .sequence) + try c.encode(thread, forKey: .thread) + case .threadRemoved(let sequence, let threadId): + try c.encode("thread-removed", forKey: .kind) + try c.encode(sequence, forKey: .sequence) + try c.encode(threadId, forKey: .threadId) + } + } +} + +/// `orchestration.subscribeShell` stream item: `{kind:"snapshot",snapshot}` | +/// `OrchestrationShellStreamEvent`. +public enum OrchestrationShellStreamItem: Sendable { + case snapshot(OrchestrationShellSnapshot) + case event(OrchestrationShellStreamEvent) +} + +extension OrchestrationShellStreamItem: Codable { + private enum CodingKeys: String, CodingKey { case kind, snapshot } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + if try c.decode(String.self, forKey: .kind) == "snapshot" { + self = .snapshot(try c.decode(OrchestrationShellSnapshot.self, forKey: .snapshot)) + } else { + self = .event(try OrchestrationShellStreamEvent(from: decoder)) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .snapshot(let snapshot): + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode("snapshot", forKey: .kind) + try c.encode(snapshot, forKey: .snapshot) + case .event(let event): + try event.encode(to: encoder) + } + } +} + +public struct OrchestrationThreadDetailSnapshot: Codable, Sendable { + public var snapshotSequence: Int + public var thread: OrchestrationThread +} + +// MARK: - Client-dispatchable commands (orchestration.dispatchCommand payload) +// +// `ClientOrchestrationCommand` (encode-only from the client's POV: these are +// only ever sent, never received back verbatim). Mirrors +// `packages/contracts/src/orchestration.ts` `ClientOrchestrationCommand` +// exactly, using the client-facing `ClientThreadTurnStartCommand` variant +// (upload attachments) for `thread.turn.start`. + +public struct ProjectCreateCommand: Encodable, Sendable { + public let type: String = "project.create" + public var commandId: String + public var projectId: String + public var title: String + public var workspaceRoot: String + public var createWorkspaceRootIfMissing: Bool? + public var defaultModelSelection: ModelSelection? + public var createdAt: String + + public init( + commandId: String, projectId: String, title: String, workspaceRoot: String, + createWorkspaceRootIfMissing: Bool? = nil, defaultModelSelection: ModelSelection? = nil, + createdAt: String + ) { + self.commandId = commandId + self.projectId = projectId + self.title = title + self.workspaceRoot = workspaceRoot + self.createWorkspaceRootIfMissing = createWorkspaceRootIfMissing + self.defaultModelSelection = defaultModelSelection + self.createdAt = createdAt + } +} + +public struct ProjectMetaUpdateCommand: Encodable, Sendable { + public let type: String = "project.meta.update" + public var commandId: String + public var projectId: String + public var title: String? + public var workspaceRoot: String? + public var defaultModelSelection: ModelSelection? + public var scripts: [ProjectScript]? + + public init( + commandId: String, projectId: String, title: String? = nil, workspaceRoot: String? = nil, + defaultModelSelection: ModelSelection? = nil, scripts: [ProjectScript]? = nil + ) { + self.commandId = commandId + self.projectId = projectId + self.title = title + self.workspaceRoot = workspaceRoot + self.defaultModelSelection = defaultModelSelection + self.scripts = scripts + } +} + +public struct ProjectDeleteCommand: Encodable, Sendable { + public let type: String = "project.delete" + public var commandId: String + public var projectId: String + public var force: Bool? + + public init(commandId: String, projectId: String, force: Bool? = nil) { + self.commandId = commandId + self.projectId = projectId + self.force = force + } +} + +/// `branch`/`worktreePath` are bare `Schema.NullOr` (required key, may be +/// `null`) — manual `encode(to:)` so `nil` still writes an explicit `null` +/// rather than omitting the key (see file header). +public struct ThreadCreateCommand: Codable, Sendable { + public let type: String = "thread.create" + public var commandId: String + public var threadId: String + public var projectId: String + public var title: String + public var modelSelection: ModelSelection + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var branch: String? + public var worktreePath: String? + public var createdAt: String + + public init( + commandId: String, threadId: String, projectId: String, title: String, + modelSelection: ModelSelection, runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode = .wireDefault, branch: String? = nil, + worktreePath: String? = nil, createdAt: String + ) { + self.commandId = commandId + self.threadId = threadId + self.projectId = projectId + self.title = title + self.modelSelection = modelSelection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + self.branch = branch + self.worktreePath = worktreePath + self.createdAt = createdAt + } + + private enum CodingKeys: String, CodingKey { + case type, commandId, threadId, projectId, title, modelSelection, runtimeMode, + interactionMode, branch, worktreePath, createdAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + commandId = try c.decode(String.self, forKey: .commandId) + threadId = try c.decode(String.self, forKey: .threadId) + projectId = try c.decode(String.self, forKey: .projectId) + title = try c.decode(String.self, forKey: .title) + modelSelection = try c.decode(ModelSelection.self, forKey: .modelSelection) + runtimeMode = try c.decode(RuntimeMode.self, forKey: .runtimeMode) + interactionMode = try c.decode( + ProviderInteractionMode.self, forKey: .interactionMode, default: .wireDefault) + branch = try c.decodeIfPresent(String.self, forKey: .branch) + worktreePath = try c.decodeIfPresent(String.self, forKey: .worktreePath) + createdAt = try c.decode(String.self, forKey: .createdAt) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(type, forKey: .type) + try c.encode(commandId, forKey: .commandId) + try c.encode(threadId, forKey: .threadId) + try c.encode(projectId, forKey: .projectId) + try c.encode(title, forKey: .title) + try c.encode(modelSelection, forKey: .modelSelection) + try c.encode(runtimeMode, forKey: .runtimeMode) + try c.encode(interactionMode, forKey: .interactionMode) + try c.encode(branch, forKey: .branch) // explicit null when nil, not omitted + try c.encode(worktreePath, forKey: .worktreePath) + try c.encode(createdAt, forKey: .createdAt) + } +} + +public struct ThreadDeleteCommand: Encodable, Sendable { + public let type: String = "thread.delete" + public var commandId: String + public var threadId: String + + public init(commandId: String, threadId: String) { + self.commandId = commandId + self.threadId = threadId + } +} + +public struct ThreadArchiveCommand: Encodable, Sendable { + public let type: String = "thread.archive" + public var commandId: String + public var threadId: String + + public init(commandId: String, threadId: String) { + self.commandId = commandId + self.threadId = threadId + } +} + +public struct ThreadUnarchiveCommand: Encodable, Sendable { + public let type: String = "thread.unarchive" + public var commandId: String + public var threadId: String + + public init(commandId: String, threadId: String) { + self.commandId = commandId + self.threadId = threadId + } +} + +public struct ThreadMetaUpdateCommand: Encodable, Sendable { + public let type: String = "thread.meta.update" + public var commandId: String + public var threadId: String + public var title: String? + public var modelSelection: ModelSelection? + public var branch: String?? // optional(NullOr) — outer nil omits the key, inner nil sends null + public var worktreePath: String?? + + /// `branch`/`worktreePath` are `Schema.optional(Schema.NullOr(x))`: + /// leave at the default `nil` to omit the key entirely (don't touch), + /// pass `.some(nil)` to explicitly clear it to `null`, or `.some(value)` + /// to set it. + public init( + commandId: String, threadId: String, title: String? = nil, + modelSelection: ModelSelection? = nil, branch: String?? = nil, + worktreePath: String?? = nil + ) { + self.commandId = commandId + self.threadId = threadId + self.title = title + self.modelSelection = modelSelection + self.branch = branch + self.worktreePath = worktreePath + } + + private enum CodingKeys: String, CodingKey { + case type, commandId, threadId, title, modelSelection, branch, worktreePath + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(type, forKey: .type) + try c.encode(commandId, forKey: .commandId) + try c.encode(threadId, forKey: .threadId) + try c.encodeIfPresent(title, forKey: .title) + try c.encodeIfPresent(modelSelection, forKey: .modelSelection) + if let branch { try c.encode(branch, forKey: .branch) } + if let worktreePath { try c.encode(worktreePath, forKey: .worktreePath) } + } +} + +public struct ThreadRuntimeModeSetCommand: Encodable, Sendable { + public let type: String = "thread.runtime-mode.set" + public var commandId: String + public var threadId: String + public var runtimeMode: RuntimeMode + public var createdAt: String + + public init(commandId: String, threadId: String, runtimeMode: RuntimeMode, createdAt: String) { + self.commandId = commandId + self.threadId = threadId + self.runtimeMode = runtimeMode + self.createdAt = createdAt + } +} + +public struct ThreadInteractionModeSetCommand: Encodable, Sendable { + public let type: String = "thread.interaction-mode.set" + public var commandId: String + public var threadId: String + public var interactionMode: ProviderInteractionMode + public var createdAt: String + + public init( + commandId: String, threadId: String, interactionMode: ProviderInteractionMode, + createdAt: String + ) { + self.commandId = commandId + self.threadId = threadId + self.interactionMode = interactionMode + self.createdAt = createdAt + } +} + +/// Nested in `ThreadTurnStartCommand.bootstrap`; `branch`/`worktreePath` are +/// bare `NullOr` here too, so this also needs a manual `encode(to:)`. +public struct ThreadTurnStartBootstrapCreateThread: Codable, Sendable { + public var projectId: String + public var title: String + public var modelSelection: ModelSelection + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var branch: String? + public var worktreePath: String? + public var createdAt: String + + public init( + projectId: String, title: String, modelSelection: ModelSelection, runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, branch: String? = nil, + worktreePath: String? = nil, createdAt: String + ) { + self.projectId = projectId + self.title = title + self.modelSelection = modelSelection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + self.branch = branch + self.worktreePath = worktreePath + self.createdAt = createdAt + } + + private enum CodingKeys: String, CodingKey { + case projectId, title, modelSelection, runtimeMode, interactionMode, branch, + worktreePath, createdAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + projectId = try c.decode(String.self, forKey: .projectId) + title = try c.decode(String.self, forKey: .title) + modelSelection = try c.decode(ModelSelection.self, forKey: .modelSelection) + runtimeMode = try c.decode(RuntimeMode.self, forKey: .runtimeMode) + interactionMode = try c.decode(ProviderInteractionMode.self, forKey: .interactionMode) + branch = try c.decodeIfPresent(String.self, forKey: .branch) + worktreePath = try c.decodeIfPresent(String.self, forKey: .worktreePath) + createdAt = try c.decode(String.self, forKey: .createdAt) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(projectId, forKey: .projectId) + try c.encode(title, forKey: .title) + try c.encode(modelSelection, forKey: .modelSelection) + try c.encode(runtimeMode, forKey: .runtimeMode) + try c.encode(interactionMode, forKey: .interactionMode) + try c.encode(branch, forKey: .branch) + try c.encode(worktreePath, forKey: .worktreePath) + try c.encode(createdAt, forKey: .createdAt) + } +} + +public struct ThreadTurnStartBootstrapPrepareWorktree: Codable, Sendable { + public var projectCwd: String + public var baseBranch: String + public var branch: String? + public var startFromOrigin: Bool? + + public init(projectCwd: String, baseBranch: String, branch: String? = nil, startFromOrigin: Bool? = nil) { + self.projectCwd = projectCwd + self.baseBranch = baseBranch + self.branch = branch + self.startFromOrigin = startFromOrigin + } +} + +public struct ThreadTurnStartBootstrap: Codable, Sendable { + public var createThread: ThreadTurnStartBootstrapCreateThread? + public var prepareWorktree: ThreadTurnStartBootstrapPrepareWorktree? + public var runSetupScript: Bool? + + public init( + createThread: ThreadTurnStartBootstrapCreateThread? = nil, + prepareWorktree: ThreadTurnStartBootstrapPrepareWorktree? = nil, + runSetupScript: Bool? = nil + ) { + self.createThread = createThread + self.prepareWorktree = prepareWorktree + self.runSetupScript = runSetupScript + } +} + +public struct ChatMessageInput: Encodable, Sendable { + public var messageId: String + public let role: String = "user" + public var text: String + public var attachments: [UploadChatAttachment] + + public init(messageId: String, text: String, attachments: [UploadChatAttachment] = []) { + self.messageId = messageId + self.text = text + self.attachments = attachments + } +} + +/// Client-facing `thread.turn.start` (uses `UploadChatAttachment`, matching +/// `ClientThreadTurnStartCommand` in orchestration.ts — not the server's +/// internal `ThreadTurnStartCommand` variant). +public struct ThreadTurnStartCommand: Encodable, Sendable { + public let type: String = "thread.turn.start" + public var commandId: String + public var threadId: String + public var message: ChatMessageInput + public var modelSelection: ModelSelection? + public var titleSeed: String? + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var bootstrap: ThreadTurnStartBootstrap? + public var sourceProposedPlan: SourceProposedPlanReference? + public var createdAt: String + + public init( + commandId: String, threadId: String, message: ChatMessageInput, + modelSelection: ModelSelection? = nil, titleSeed: String? = nil, + runtimeMode: RuntimeMode = .wireDefault, interactionMode: ProviderInteractionMode = .wireDefault, + bootstrap: ThreadTurnStartBootstrap? = nil, + sourceProposedPlan: SourceProposedPlanReference? = nil, createdAt: String + ) { + self.commandId = commandId + self.threadId = threadId + self.message = message + self.modelSelection = modelSelection + self.titleSeed = titleSeed + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + self.bootstrap = bootstrap + self.sourceProposedPlan = sourceProposedPlan + self.createdAt = createdAt + } +} + +public struct ThreadTurnInterruptCommand: Encodable, Sendable { + public let type: String = "thread.turn.interrupt" + public var commandId: String + public var threadId: String + public var turnId: String? + public var createdAt: String + + public init(commandId: String, threadId: String, turnId: String? = nil, createdAt: String) { + self.commandId = commandId + self.threadId = threadId + self.turnId = turnId + self.createdAt = createdAt + } +} + +public struct ThreadApprovalRespondCommand: Encodable, Sendable { + public let type: String = "thread.approval.respond" + public var commandId: String + public var threadId: String + public var requestId: String + public var decision: ProviderApprovalDecision + public var createdAt: String + + public init( + commandId: String, threadId: String, requestId: String, decision: ProviderApprovalDecision, + createdAt: String + ) { + self.commandId = commandId + self.threadId = threadId + self.requestId = requestId + self.decision = decision + self.createdAt = createdAt + } +} + +public struct ThreadUserInputRespondCommand: Encodable, Sendable { + public let type: String = "thread.user-input.respond" + public var commandId: String + public var threadId: String + public var requestId: String + public var answers: ProviderUserInputAnswers + public var createdAt: String + + public init( + commandId: String, threadId: String, requestId: String, answers: ProviderUserInputAnswers, + createdAt: String + ) { + self.commandId = commandId + self.threadId = threadId + self.requestId = requestId + self.answers = answers + self.createdAt = createdAt + } +} + +public struct ThreadCheckpointRevertCommand: Encodable, Sendable { + public let type: String = "thread.checkpoint.revert" + public var commandId: String + public var threadId: String + public var turnCount: Int + public var createdAt: String + + public init(commandId: String, threadId: String, turnCount: Int, createdAt: String) { + self.commandId = commandId + self.threadId = threadId + self.turnCount = turnCount + self.createdAt = createdAt + } +} + +public struct ThreadSessionStopCommand: Encodable, Sendable { + public let type: String = "thread.session.stop" + public var commandId: String + public var threadId: String + public var createdAt: String + + public init(commandId: String, threadId: String, createdAt: String) { + self.commandId = commandId + self.threadId = threadId + self.createdAt = createdAt + } +} + +/// `orchestration.dispatchCommand` payload — the single write path for +/// projects/threads/turns (§3.1.1). Encode-only. +public enum ClientOrchestrationCommand: Encodable, Sendable { + case projectCreate(ProjectCreateCommand) + case projectMetaUpdate(ProjectMetaUpdateCommand) + case projectDelete(ProjectDeleteCommand) + case threadCreate(ThreadCreateCommand) + case threadDelete(ThreadDeleteCommand) + case threadArchive(ThreadArchiveCommand) + case threadUnarchive(ThreadUnarchiveCommand) + case threadMetaUpdate(ThreadMetaUpdateCommand) + case threadRuntimeModeSet(ThreadRuntimeModeSetCommand) + case threadInteractionModeSet(ThreadInteractionModeSetCommand) + case threadTurnStart(ThreadTurnStartCommand) + case threadTurnInterrupt(ThreadTurnInterruptCommand) + case threadApprovalRespond(ThreadApprovalRespondCommand) + case threadUserInputRespond(ThreadUserInputRespondCommand) + case threadCheckpointRevert(ThreadCheckpointRevertCommand) + case threadSessionStop(ThreadSessionStopCommand) + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .projectCreate(let c): try container.encode(c) + case .projectMetaUpdate(let c): try container.encode(c) + case .projectDelete(let c): try container.encode(c) + case .threadCreate(let c): try container.encode(c) + case .threadDelete(let c): try container.encode(c) + case .threadArchive(let c): try container.encode(c) + case .threadUnarchive(let c): try container.encode(c) + case .threadMetaUpdate(let c): try container.encode(c) + case .threadRuntimeModeSet(let c): try container.encode(c) + case .threadInteractionModeSet(let c): try container.encode(c) + case .threadTurnStart(let c): try container.encode(c) + case .threadTurnInterrupt(let c): try container.encode(c) + case .threadApprovalRespond(let c): try container.encode(c) + case .threadUserInputRespond(let c): try container.encode(c) + case .threadCheckpointRevert(let c): try container.encode(c) + case .threadSessionStop(let c): try container.encode(c) + } + } +} + +public struct DispatchResult: Decodable, Sendable { + public var sequence: Int +} + +// MARK: - Turn diff + +public struct OrchestrationGetTurnDiffInput: Encodable, Sendable { + public var fromTurnCount: Int + public var toTurnCount: Int + public var threadId: String + public var ignoreWhitespace: Bool? + + public init(fromTurnCount: Int, toTurnCount: Int, threadId: String, ignoreWhitespace: Bool? = nil) { + self.fromTurnCount = fromTurnCount + self.toTurnCount = toTurnCount + self.threadId = threadId + self.ignoreWhitespace = ignoreWhitespace + } +} + +public struct OrchestrationGetFullThreadDiffInput: Encodable, Sendable { + public var threadId: String + public var toTurnCount: Int + public var ignoreWhitespace: Bool? + + public init(threadId: String, toTurnCount: Int, ignoreWhitespace: Bool? = nil) { + self.threadId = threadId + self.toTurnCount = toTurnCount + self.ignoreWhitespace = ignoreWhitespace + } +} + +public struct ThreadTurnDiff: Decodable, Sendable { + public var fromTurnCount: Int + public var toTurnCount: Int + public var threadId: String + public var diff: String +} + +public struct OrchestrationReplayEventsInput: Encodable, Sendable { + public var fromSequenceExclusive: Int + + public init(fromSequenceExclusive: Int) { + self.fromSequenceExclusive = fromSequenceExclusive + } +} + +// MARK: - Events (read-only; §3.1.3) + +public enum OrchestrationAggregateKind: String, Codable, Sendable { + case project, thread +} + +public struct OrchestrationEventMetadata: Codable, Sendable { + public var providerTurnId: String? + public var providerItemId: String? + public var adapterKey: String? + public var requestId: String? + public var ingestedAt: String? +} + +public struct ProjectCreatedPayload: Decodable, Sendable { + public var projectId: String + public var title: String + public var workspaceRoot: String + public var repositoryIdentity: RepositoryIdentity? + public var defaultModelSelection: ModelSelection? + public var scripts: [ProjectScript] + public var createdAt: String + public var updatedAt: String +} + +public struct ProjectMetaUpdatedPayload: Decodable, Sendable { + public var projectId: String + public var title: String? + public var workspaceRoot: String? + public var repositoryIdentity: RepositoryIdentity? + public var defaultModelSelection: ModelSelection? + public var scripts: [ProjectScript]? + public var updatedAt: String +} + +public struct ProjectDeletedPayload: Decodable, Sendable { + public var projectId: String + public var deletedAt: String +} + +public struct ThreadCreatedPayload: Decodable, Sendable { + public var threadId: String + public var projectId: String + public var title: String + public var modelSelection: ModelSelection + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var branch: String? + public var worktreePath: String? + public var createdAt: String + public var updatedAt: String + + private enum CodingKeys: String, CodingKey { + case threadId, projectId, title, modelSelection, runtimeMode, interactionMode, branch, + worktreePath, createdAt, updatedAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + threadId = try c.decode(String.self, forKey: .threadId) + projectId = try c.decode(String.self, forKey: .projectId) + title = try c.decode(String.self, forKey: .title) + modelSelection = try c.decode(ModelSelection.self, forKey: .modelSelection) + runtimeMode = try c.decode(RuntimeMode.self, forKey: .runtimeMode, default: .wireDefault) + interactionMode = try c.decode( + ProviderInteractionMode.self, forKey: .interactionMode, default: .wireDefault) + branch = try c.decode(String?.self, forKey: .branch, default: nil) + worktreePath = try c.decode(String?.self, forKey: .worktreePath, default: nil) + createdAt = try c.decode(String.self, forKey: .createdAt) + updatedAt = try c.decode(String.self, forKey: .updatedAt) + } +} + +public struct ThreadDeletedPayload: Decodable, Sendable { + public var threadId: String + public var deletedAt: String +} + +public struct ThreadArchivedPayload: Decodable, Sendable { + public var threadId: String + public var archivedAt: String + public var updatedAt: String +} + +public struct ThreadUnarchivedPayload: Decodable, Sendable { + public var threadId: String + public var updatedAt: String +} + +public struct ThreadMetaUpdatedPayload: Decodable, Sendable { + public var threadId: String + public var title: String? + public var modelSelection: ModelSelection? + public var branch: String? + public var worktreePath: String? + public var updatedAt: String +} + +public struct ThreadRuntimeModeSetPayload: Decodable, Sendable { + public var threadId: String + public var runtimeMode: RuntimeMode + public var updatedAt: String +} + +public struct ThreadInteractionModeSetPayload: Decodable, Sendable { + public var threadId: String + public var interactionMode: ProviderInteractionMode + public var updatedAt: String + + private enum CodingKeys: String, CodingKey { case threadId, interactionMode, updatedAt } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + threadId = try c.decode(String.self, forKey: .threadId) + interactionMode = try c.decode( + ProviderInteractionMode.self, forKey: .interactionMode, default: .wireDefault) + updatedAt = try c.decode(String.self, forKey: .updatedAt) + } +} + +public struct ThreadMessageSentPayload: Decodable, Sendable { + public var threadId: String + public var messageId: String + public var role: OrchestrationMessageRole + public var text: String + public var attachments: [ChatAttachment]? + public var turnId: String? + public var streaming: Bool + public var createdAt: String + public var updatedAt: String +} + +public struct ThreadTurnStartRequestedPayload: Decodable, Sendable { + public var threadId: String + public var messageId: String + public var modelSelection: ModelSelection? + public var titleSeed: String? + public var runtimeMode: RuntimeMode + public var interactionMode: ProviderInteractionMode + public var createdAt: String + + private enum CodingKeys: String, CodingKey { + case threadId, messageId, modelSelection, titleSeed, runtimeMode, interactionMode, createdAt + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + threadId = try c.decode(String.self, forKey: .threadId) + messageId = try c.decode(String.self, forKey: .messageId) + modelSelection = try c.decodeIfPresent(ModelSelection.self, forKey: .modelSelection) + titleSeed = try c.decodeIfPresent(String.self, forKey: .titleSeed) + runtimeMode = try c.decode(RuntimeMode.self, forKey: .runtimeMode, default: .wireDefault) + interactionMode = try c.decode( + ProviderInteractionMode.self, forKey: .interactionMode, default: .wireDefault) + createdAt = try c.decode(String.self, forKey: .createdAt) + } +} + +public struct ThreadTurnInterruptRequestedPayload: Decodable, Sendable { + public var threadId: String + public var turnId: String? + public var createdAt: String +} + +public struct ThreadApprovalResponseRequestedPayload: Decodable, Sendable { + public var threadId: String + public var requestId: String + public var decision: ProviderApprovalDecision + public var createdAt: String +} + +public struct ThreadUserInputResponseRequestedPayload: Decodable, Sendable { + public var threadId: String + public var requestId: String + public var answers: ProviderUserInputAnswers + public var createdAt: String +} + +public struct ThreadCheckpointRevertRequestedPayload: Decodable, Sendable { + public var threadId: String + public var turnCount: Int + public var createdAt: String +} + +public struct ThreadRevertedPayload: Decodable, Sendable { + public var threadId: String + public var turnCount: Int +} + +public struct ThreadSessionStopRequestedPayload: Decodable, Sendable { + public var threadId: String + public var createdAt: String +} + +public struct ThreadSessionSetPayload: Decodable, Sendable { + public var threadId: String + public var session: OrchestrationSession +} + +public struct ThreadProposedPlanUpsertedPayload: Decodable, Sendable { + public var threadId: String + public var proposedPlan: OrchestrationProposedPlan +} + +public struct ThreadTurnDiffCompletedPayload: Decodable, Sendable { + public var threadId: String + public var turnId: String + public var checkpointTurnCount: Int + public var checkpointRef: String + public var status: OrchestrationCheckpointStatus + public var files: [OrchestrationCheckpointFile] + public var assistantMessageId: String? + public var completedAt: String +} + +public struct ThreadActivityAppendedPayload: Decodable, Sendable { + public var threadId: String + public var activity: OrchestrationThreadActivity +} + +/// `OrchestrationEventType` (21 client-visible variants). Kept as string +/// constants rather than a closed Swift enum so an unrecognized future +/// value still decodes as `.other` on `OrchestrationEvent.type` instead of +/// failing the whole decode. +public enum OrchestrationEventType { + public static let projectCreated = "project.created" + public static let projectMetaUpdated = "project.meta-updated" + public static let projectDeleted = "project.deleted" + public static let threadCreated = "thread.created" + public static let threadDeleted = "thread.deleted" + public static let threadArchived = "thread.archived" + public static let threadUnarchived = "thread.unarchived" + public static let threadMetaUpdated = "thread.meta-updated" + public static let threadRuntimeModeSet = "thread.runtime-mode-set" + public static let threadInteractionModeSet = "thread.interaction-mode-set" + public static let threadMessageSent = "thread.message-sent" + public static let threadTurnStartRequested = "thread.turn-start-requested" + public static let threadTurnInterruptRequested = "thread.turn-interrupt-requested" + public static let threadApprovalResponseRequested = "thread.approval-response-requested" + public static let threadUserInputResponseRequested = "thread.user-input-response-requested" + public static let threadCheckpointRevertRequested = "thread.checkpoint-revert-requested" + public static let threadReverted = "thread.reverted" + public static let threadSessionStopRequested = "thread.session-stop-requested" + public static let threadSessionSet = "thread.session-set" + public static let threadProposedPlanUpserted = "thread.proposed-plan-upserted" + public static let threadTurnDiffCompleted = "thread.turn-diff-completed" + public static let threadActivityAppended = "thread.activity-appended" +} + +/// `OrchestrationEvent` — union discriminated by `type` (§5.5), 21 members +/// sharing `EventBaseFields`. Modeled as one struct with a typed `payload` +/// enum rather than 21 nested Swift types, matching how consumers actually +/// switch on it (`switch event.payload { case .threadMessageSent(let p): }`). +public struct OrchestrationEvent: Decodable, Sendable { + public var sequence: Int + public var eventId: String + public var aggregateKind: OrchestrationAggregateKind + public var aggregateId: String + public var occurredAt: String + public var commandId: String? + public var causationEventId: String? + public var correlationId: String? + public var metadata: OrchestrationEventMetadata + public var type: String + public var payload: EventPayload + + public enum EventPayload: Sendable { + case projectCreated(ProjectCreatedPayload) + case projectMetaUpdated(ProjectMetaUpdatedPayload) + case projectDeleted(ProjectDeletedPayload) + case threadCreated(ThreadCreatedPayload) + case threadDeleted(ThreadDeletedPayload) + case threadArchived(ThreadArchivedPayload) + case threadUnarchived(ThreadUnarchivedPayload) + case threadMetaUpdated(ThreadMetaUpdatedPayload) + case threadRuntimeModeSet(ThreadRuntimeModeSetPayload) + case threadInteractionModeSet(ThreadInteractionModeSetPayload) + case threadMessageSent(ThreadMessageSentPayload) + case threadTurnStartRequested(ThreadTurnStartRequestedPayload) + case threadTurnInterruptRequested(ThreadTurnInterruptRequestedPayload) + case threadApprovalResponseRequested(ThreadApprovalResponseRequestedPayload) + case threadUserInputResponseRequested(ThreadUserInputResponseRequestedPayload) + case threadCheckpointRevertRequested(ThreadCheckpointRevertRequestedPayload) + case threadReverted(ThreadRevertedPayload) + case threadSessionStopRequested(ThreadSessionStopRequestedPayload) + case threadSessionSet(ThreadSessionSetPayload) + case threadProposedPlanUpserted(ThreadProposedPlanUpsertedPayload) + case threadTurnDiffCompleted(ThreadTurnDiffCompletedPayload) + case threadActivityAppended(ThreadActivityAppendedPayload) + /// Unrecognized `type` (forward compatibility): raw payload JSON preserved. + case other(type: String, payload: JSONValue) + } + + private enum CodingKeys: String, CodingKey { + case sequence, eventId, aggregateKind, aggregateId, occurredAt, commandId, + causationEventId, correlationId, metadata, type, payload + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + sequence = try c.decode(Int.self, forKey: .sequence) + eventId = try c.decode(String.self, forKey: .eventId) + aggregateKind = try c.decode(OrchestrationAggregateKind.self, forKey: .aggregateKind) + aggregateId = try c.decode(String.self, forKey: .aggregateId) + occurredAt = try c.decode(String.self, forKey: .occurredAt) + commandId = try c.decode(String?.self, forKey: .commandId, default: nil) + causationEventId = try c.decode(String?.self, forKey: .causationEventId, default: nil) + correlationId = try c.decode(String?.self, forKey: .correlationId, default: nil) + metadata = try c.decode(OrchestrationEventMetadata.self, forKey: .metadata) + let eventType = try c.decode(String.self, forKey: .type) + type = eventType + switch eventType { + case OrchestrationEventType.projectCreated: + payload = .projectCreated(try c.decode(ProjectCreatedPayload.self, forKey: .payload)) + case OrchestrationEventType.projectMetaUpdated: + payload = .projectMetaUpdated(try c.decode(ProjectMetaUpdatedPayload.self, forKey: .payload)) + case OrchestrationEventType.projectDeleted: + payload = .projectDeleted(try c.decode(ProjectDeletedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadCreated: + payload = .threadCreated(try c.decode(ThreadCreatedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadDeleted: + payload = .threadDeleted(try c.decode(ThreadDeletedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadArchived: + payload = .threadArchived(try c.decode(ThreadArchivedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadUnarchived: + payload = .threadUnarchived(try c.decode(ThreadUnarchivedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadMetaUpdated: + payload = .threadMetaUpdated(try c.decode(ThreadMetaUpdatedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadRuntimeModeSet: + payload = .threadRuntimeModeSet( + try c.decode(ThreadRuntimeModeSetPayload.self, forKey: .payload)) + case OrchestrationEventType.threadInteractionModeSet: + payload = .threadInteractionModeSet( + try c.decode(ThreadInteractionModeSetPayload.self, forKey: .payload)) + case OrchestrationEventType.threadMessageSent: + payload = .threadMessageSent(try c.decode(ThreadMessageSentPayload.self, forKey: .payload)) + case OrchestrationEventType.threadTurnStartRequested: + payload = .threadTurnStartRequested( + try c.decode(ThreadTurnStartRequestedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadTurnInterruptRequested: + payload = .threadTurnInterruptRequested( + try c.decode(ThreadTurnInterruptRequestedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadApprovalResponseRequested: + payload = .threadApprovalResponseRequested( + try c.decode(ThreadApprovalResponseRequestedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadUserInputResponseRequested: + payload = .threadUserInputResponseRequested( + try c.decode(ThreadUserInputResponseRequestedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadCheckpointRevertRequested: + payload = .threadCheckpointRevertRequested( + try c.decode(ThreadCheckpointRevertRequestedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadReverted: + payload = .threadReverted(try c.decode(ThreadRevertedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadSessionStopRequested: + payload = .threadSessionStopRequested( + try c.decode(ThreadSessionStopRequestedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadSessionSet: + payload = .threadSessionSet(try c.decode(ThreadSessionSetPayload.self, forKey: .payload)) + case OrchestrationEventType.threadProposedPlanUpserted: + payload = .threadProposedPlanUpserted( + try c.decode(ThreadProposedPlanUpsertedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadTurnDiffCompleted: + payload = .threadTurnDiffCompleted( + try c.decode(ThreadTurnDiffCompletedPayload.self, forKey: .payload)) + case OrchestrationEventType.threadActivityAppended: + payload = .threadActivityAppended( + try c.decode(ThreadActivityAppendedPayload.self, forKey: .payload)) + default: + payload = .other(type: eventType, payload: try c.decode(JSONValue.self, forKey: .payload)) + } + } +} + +/// `orchestration.subscribeThread` stream item. +public enum OrchestrationThreadStreamItem: Decodable, Sendable { + case snapshot(OrchestrationThreadDetailSnapshot) + case event(OrchestrationEvent) + + private enum CodingKeys: String, CodingKey { case kind, snapshot, event } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(String.self, forKey: .kind) { + case "snapshot": + self = .snapshot(try c.decode(OrchestrationThreadDetailSnapshot.self, forKey: .snapshot)) + case "event": + self = .event(try c.decode(OrchestrationEvent.self, forKey: .event)) + case let other: + throw DecodingError.dataCorruptedError( + forKey: .kind, in: c, debugDescription: "Unknown OrchestrationThreadStreamItem kind: \(other)") + } + } +} diff --git a/apps/mac/Sources/T3Kit/ProjectRpc.swift b/apps/mac/Sources/T3Kit/ProjectRpc.swift new file mode 100644 index 00000000000..844b60a5bff --- /dev/null +++ b/apps/mac/Sources/T3Kit/ProjectRpc.swift @@ -0,0 +1,104 @@ +// projects.* RPC family (packages/contracts/src/project.ts, wired in +// rpc.ts): workspace file search/list/read used by composer @-mentions and +// the file browser. All non-streaming request/response calls. + +import Foundation + +public enum ProjectEntryKind: String, Codable, Sendable { + case file, directory +} + +/// One `{ path, kind }` result row of listEntries/searchEntries. +public struct ProjectEntry: Codable, Sendable, Hashable { + public var path: String + public var kind: ProjectEntryKind + + public init(path: String, kind: ProjectEntryKind) { + self.path = path + self.kind = kind + } +} + +public struct ProjectSearchEntriesInput: Encodable, Sendable { + public var cwd: String + /// Max 256 chars server-side. + public var query: String + /// Max 200 server-side. + public var limit: Int + + public init(cwd: String, query: String, limit: Int) { + self.cwd = cwd + self.query = query + self.limit = limit + } +} + +public struct ProjectEntriesResult: Decodable, Sendable { + public var entries: [ProjectEntry] + public var truncated: Bool +} + +public struct ProjectListEntriesInput: Encodable, Sendable { + public var cwd: String + + public init(cwd: String) { + self.cwd = cwd + } +} + +public struct ProjectReadFileInput: Encodable, Sendable { + public var cwd: String + /// Max 512 chars server-side. + public var relativePath: String + + public init(cwd: String, relativePath: String) { + self.cwd = cwd + self.relativePath = relativePath + } +} + +public struct ProjectReadFileResult: Decodable, Sendable { + public var relativePath: String + public var contents: String + public var byteLength: Int + public var truncated: Bool +} + +/// `shell.openInEditor` input (editor.ts `LaunchEditorInput`). `editor` is +/// one of the `EDITORS` ids (e.g. "vscode", "cursor", "zed", "file-manager"). +public struct LaunchEditorInput: Encodable, Sendable { + public var cwd: String + public var editor: String + + public init(cwd: String, editor: String) { + self.cwd = cwd + self.editor = editor + } +} + +extension T3Client { + /// Opens a path in an external editor via the server's launcher + /// (void success — errors surface as RPC failures). + public func openInEditor(cwd: String, editor: String) async throws { + let _: JSONValue = try await call( + "shell.openInEditor", LaunchEditorInput(cwd: cwd, editor: editor)) + } + + /// Fuzzy filename search under a project workspace (composer @-mentions). + public func searchEntries(cwd: String, query: String, limit: Int = 20) async throws + -> ProjectEntriesResult + { + try await call( + "projects.searchEntries", ProjectSearchEntriesInput(cwd: cwd, query: query, limit: limit)) + } + + /// Non-recursive-ish entry listing for a workspace directory. + public func listEntries(cwd: String) async throws -> ProjectEntriesResult { + try await call("projects.listEntries", ProjectListEntriesInput(cwd: cwd)) + } + + public func readFile(cwd: String, relativePath: String) async throws -> ProjectReadFileResult { + try await call( + "projects.readFile", ProjectReadFileInput(cwd: cwd, relativePath: relativePath)) + } +} diff --git a/apps/mac/Sources/T3Kit/RpcConnection.swift b/apps/mac/Sources/T3Kit/RpcConnection.swift new file mode 100644 index 00000000000..6d7f85f59c5 --- /dev/null +++ b/apps/mac/Sources/T3Kit/RpcConnection.swift @@ -0,0 +1,417 @@ +// RpcConnection.swift +// The core actor: owns the WebSocket, the request-id counter, the in-flight +// registry, mandatory per-`Chunk` `Ack` backpressure (§2.3 — critical, or a +// stream stalls after its first chunk), the 5s `Ping` heartbeat with ~10s +// dead-connection detection (§1.4), request/stream/interrupt primitives, and +// a connection-state `AsyncStream` for observers. +// +// This layer deals exclusively in `JSONValue` payloads/values (§2, §5). +// RPC-method-specific typing (mapping `tag` -> payload/success Codables) +// happens one layer up, via `JSONValue.decode(as:using:)`. +// +// There is no application-level handshake (§1.3) — a caller may issue any +// RPC as the first request once `connect()` returns. This actor does not +// auto-reconnect (§1.5): reconnection (fresh `AuthClient.makeSocketURL()`, +// a new `RpcConnection`, re-`server.getConfig`, re-`subscribe*`, optionally +// `orchestration.replayEvents`) is a supervisor concern one layer up (§4.3). + +import Foundation + +/// One live (or attempting-to-be-live) WebSocket connection to the t3 +/// server's `/ws` RPC endpoint. +public actor RpcConnection { + + // MARK: Connection state + + public enum ConnectionState: Sendable, Equatable { + case disconnected + case connecting + case connected + case closed(reason: String?) + } + + private enum PendingRequest { + case unary(CheckedContinuation) + case stream(AsyncThrowingStream.Continuation) + } + + private let url: URL + private let urlSession: URLSession + + private var task: URLSessionWebSocketTask? + private var receiveLoopTask: Task? + private var pingLoopTask: Task? + + private var nextRequestId: Int = 0 + private var pending: [String: PendingRequest] = [:] + + /// Whether the most recently sent `Ping` has not yet been answered by a + /// `Pong`. If still true the *next* time the 5s ping tick fires, the + /// connection is treated as dead (§1.4: ~5-10s without a `Pong`). + private var awaitingPong = false + + private var currentState: ConnectionState = .disconnected { + didSet { stateContinuation.yield(currentState) } + } + + /// Connection-state observation stream. Safe to iterate from any + /// isolation context; delivers `.disconnected` immediately to new + /// subscribers as the initial value is buffered. + public nonisolated let stateUpdates: AsyncStream + private nonisolated let stateContinuation: AsyncStream.Continuation + + public init(url: URL, urlSession: URLSession = .shared) { + self.url = url + self.urlSession = urlSession + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + self.stateUpdates = stream + self.stateContinuation = continuation + continuation.yield(.disconnected) + } + + // MARK: Lifecycle + + /// Opens the socket and starts the receive loop + 5s ping heartbeat. + /// Does not itself wait for a successful upgrade handshake — failures + /// surface either as a thrown error from an in-flight `request`/`stream` + /// call or as a `.closed` state transition once the receive loop's first + /// read fails. + public func connect() async throws { + guard task == nil else { return } + currentState = .connecting + let task = urlSession.webSocketTask(with: url) + self.task = task + task.resume() + awaitingPong = false + currentState = .connected + receiveLoopTask = Task { [weak self] in await self?.receiveLoop() } + pingLoopTask = Task { [weak self] in await self?.pingLoop() } + } + + /// Tears down the socket, fails every in-flight request/stream, and + /// moves to `.closed`. Idempotent. + public func disconnect(reason: String? = nil) async { + await teardown(state: .closed(reason: reason), error: T3Error.connectionClosed(reason: reason)) + } + + deinit { + stateContinuation.finish() + } + + // MARK: Public RPC surface + + /// Invokes a non-streaming RPC and awaits its terminal `Exit`. Resolves + /// with the decoded success value, or throws `T3Error.rpc` for a typed + /// `Exit.Failure` (§2.2) — scope errors (`EnvironmentAuthorizationError`) + /// arrive this way too and are not connection-fatal (§risk9). + public func request( + tag: String, + payload: JSONValue, + headers: [[String]] = [], + traceId: String? = nil + ) async throws -> JSONValue { + let id = allocateRequestId() + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Register before the frame is ever sent so a same-actor reentrant + // `Exit`/`Chunk` arriving while the send is in flight always finds + // its pending entry (no lost-response race). + pending[id] = .unary(continuation) + Task { [weak self] in + guard let self else { return } + do { + try await self.send(.request(id: id, tag: tag, payload: payload, headers: headers, traceId: traceId)) + } catch { + await self.failPending(id: id, with: error) + } + } + } + } + + /// Invokes a streaming RPC. The returned stream's first values are + /// typically a snapshot followed by a live tail (§4.2); each element is + /// one value out of a `Chunk`'s (possibly batched) `values` array. Every + /// `Chunk` is `Ack`'d automatically after its values are yielded (§2.3 — + /// mandatory, handled transparently here). The stream finishes normally + /// on `Exit.Success`, throws `T3Error.rpc` on `Exit.Failure`, and — if the + /// consumer stops iterating early (e.g. breaks out of a `for await` loop + /// or the enclosing `Task` is cancelled) — sends `Interrupt` for the + /// request (§2.4). + public func stream( + tag: String, + payload: JSONValue, + headers: [[String]] = [], + traceId: String? = nil + ) async throws -> AsyncThrowingStream { + let id = allocateRequestId() + let (stream, continuation) = AsyncThrowingStream.makeStream() + // Registered before the `await send(...)` suspension point below, so + // there is no window where a fast server response could arrive before + // this entry exists. + pending[id] = .stream(continuation) + continuation.onTermination = { [weak self] _ in + Task { await self?.handleStreamTermination(id: id) } + } + do { + try await send(.request(id: id, tag: tag, payload: payload, headers: headers, traceId: traceId)) + } catch { + pending.removeValue(forKey: id) + throw error + } + return stream + } + + /// Cancels an in-flight request or unsubscribes from a stream (§2.4). + /// Resolves/finishes the pending continuation with `CancellationError` + /// locally and notifies the server; a no-op if `requestId` is unknown + /// (already completed or already interrupted). + public func interrupt(requestId: String) async { + guard let entry = pending.removeValue(forKey: requestId) else { return } + switch entry { + case .unary(let continuation): + continuation.resume(throwing: CancellationError()) + case .stream(let continuation): + continuation.finish(throwing: CancellationError()) + } + try? await send(.interrupt(requestId: requestId)) + } + + /// Sends `{"_tag":"Eof"}` (§2.2). Rarely needed — the reference client + /// never sends it — provided for forward compatibility. + public func sendEof() async throws { + try await send(.eof) + } + + // MARK: Outbound framing + + private func send(_ frame: ClientFrame) async throws { + guard let task else { throw T3Error.notConnected } + let text: String + do { + text = try WireCoding.encodeFrameString(frame) + } catch { + throw T3Error.decoding("Failed to encode outgoing frame: \(error)") + } + do { + try await task.send(.string(text)) + } catch { + throw T3Error.transport("WebSocket send failed: \(error)") + } + } + + // MARK: Receive loop + + private func receiveLoop() async { + guard let task else { return } + while !Task.isCancelled { + let message: URLSessionWebSocketTask.Message + do { + message = try await task.receive() + } catch { + if Task.isCancelled { return } + await teardown(state: .closed(reason: "\(error)"), error: T3Error.transport("WebSocket receive failed: \(error)")) + return + } + + let text: String? + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) + @unknown default: + text = nil + } + guard let text else { continue } + + let frames: [ServerFrame] + do { + frames = try FrameBatch.decode(text) + } catch { + // A single malformed frame doesn't identify which in-flight + // request it belonged to; drop it rather than tearing down + // every other in-flight request/stream on this connection. + // + // A structurally-broken `Chunk` (e.g. an envelope that + // decodes `_tag`/`requestId` fine but fails deeper, such as a + // `Die` cause missing its `defect` key) still latched the + // server-side backpressure gate for that requestId + // (`RpcServer.ts:466-467`); if we never Ack it, that stream + // stalls forever even though the socket is healthy. Recover + // the `_tag`/`requestId` leniently (without requiring the + // rest of the frame to decode) and Ack it so the stream can + // keep moving, even though this batch of values is lost. + await ackLeniently(text) + continue + } + for frame in frames { + await handle(frame) + } + } + } + + /// Best-effort recovery for a WS text frame (or one element of a batch) + /// that failed full `ServerFrame` decoding. If it can still be recognized + /// as a `Chunk` for a known requestId — by parsing only `_tag`/ + /// `requestId` rather than the whole envelope — send its mandatory `Ack` + /// so the server-side backpressure latch doesn't stay closed forever + /// (§2.3). This is deliberately shallow: it does not attempt to salvage + /// per-element frames out of a malformed batch array. + private func ackLeniently(_ text: String) async { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (object["_tag"] as? String) == "Chunk", + let requestId = object["requestId"] as? String + else { return } + try? await send(.ack(requestId: requestId)) + } + + private func handle(_ frame: ServerFrame) async { + switch frame { + case let .chunk(requestId, values): + await handleChunk(requestId: requestId, values: values) + case let .exit(requestId, exit): + handleExit(requestId: requestId, exit: exit) + case let .defect(defect): + // Connection-level: not tied to one request, kills all in-flight + // requests/streams (§2.2). The socket itself is left open — the + // server may still be reachable for new requests. + failAllPending(with: T3Error.rpc(RpcFailure(causes: [.die(defect: defect)]))) + case let .clientProtocolError(error): + // Connection-level, like `Defect`: the server transport has + // abandoned every in-flight request on this connection + // (`RpcClient.ts:780-786`). The socket itself is left open. + failAllPending(with: T3Error.rpc(RpcFailure(causes: [.die(defect: error)]))) + case .pong: + awaitingPong = false + case .clientEnd: + break // Ignored, matching the reference client (§2.2). + case .unknown: + break // Forward-compatible: ignore unrecognized envelope tags. + } + } + + private func handleChunk(requestId: String, values: [JSONValue]) async { + guard let entry = pending[requestId] else { + // No (or no longer any) consumer for this id — still must Ack or + // the server-side latch for this requestId never opens (§2.3). + try? await send(.ack(requestId: requestId)) + return + } + switch entry { + case .stream(let continuation): + for value in values { + continuation.yield(value) + } + do { + try await send(.ack(requestId: requestId)) + } catch { + pending.removeValue(forKey: requestId) + continuation.finish(throwing: T3Error.transport("Failed to send Ack: \(error)")) + } + case .unary(let continuation): + // A non-streaming RPC should never receive a Chunk; surface it as + // a protocol violation rather than silently dropping the values. + pending.removeValue(forKey: requestId) + continuation.resume(throwing: T3Error.unexpectedFrame("Received Chunk for non-streaming request \(requestId)")) + try? await send(.ack(requestId: requestId)) + } + } + + private func handleExit(requestId: String, exit: ExitResult) { + guard let entry = pending.removeValue(forKey: requestId) else { return } + switch entry { + case .unary(let continuation): + switch exit { + case let .success(value): + continuation.resume(returning: value) + case let .failure(failure): + continuation.resume(throwing: T3Error.rpc(failure)) + } + case .stream(let continuation): + switch exit { + case .success: + continuation.finish() + case let .failure(failure): + continuation.finish(throwing: T3Error.rpc(failure)) + } + } + } + + /// Invoked when a stream's `AsyncThrowingStream` terminates from the + /// consumer side (early `break`, enclosing `Task` cancellation, or normal + /// producer-side finish). Only sends `Interrupt` if the request was still + /// tracked as pending — i.e. the consumer walked away before a terminal + /// `Exit` arrived (§2.4). + private func handleStreamTermination(id: String) async { + guard pending.removeValue(forKey: id) != nil else { return } + try? await send(.interrupt(requestId: id)) + } + + // MARK: Heartbeat (§1.4) + + private func pingLoop() async { + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return + } + guard !Task.isCancelled else { return } + + if awaitingPong { + // Previous Ping went unanswered through this whole tick: + // ~5-10s of silence, treat as dead (§1.4, §risk3). + await teardown(state: .closed(reason: "ping timeout"), error: T3Error.pingTimeout) + return + } + awaitingPong = true + do { + try await send(.ping) + } catch { + await teardown(state: .closed(reason: "\(error)"), error: T3Error.transport("Failed to send Ping: \(error)")) + return + } + } + } + + // MARK: Teardown helpers + + private func failPending(id: String, with error: Error) { + guard let entry = pending.removeValue(forKey: id) else { return } + switch entry { + case .unary(let continuation): + continuation.resume(throwing: error) + case .stream(let continuation): + continuation.finish(throwing: error) + } + } + + private func failAllPending(with error: Error) { + let entries = pending + pending.removeAll() + for entry in entries.values { + switch entry { + case .unary(let continuation): + continuation.resume(throwing: error) + case .stream(let continuation): + continuation.finish(throwing: error) + } + } + } + + private func teardown(state: ConnectionState, error: Error) async { + receiveLoopTask?.cancel() + pingLoopTask?.cancel() + receiveLoopTask = nil + pingLoopTask = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + failAllPending(with: error) + currentState = state + } + + private func allocateRequestId() -> String { + defer { nextRequestId += 1 } + return String(nextRequestId) + } +} diff --git a/apps/mac/Sources/T3Kit/RpcConnectionTransport.swift b/apps/mac/Sources/T3Kit/RpcConnectionTransport.swift new file mode 100644 index 00000000000..5152503ce0f --- /dev/null +++ b/apps/mac/Sources/T3Kit/RpcConnectionTransport.swift @@ -0,0 +1,30 @@ +// RpcConnectionTransport.swift +// Adapter conforming `RpcConnection` (the raw WebSocket/effect-RPC actor) to +// `T3RpcTransport`, the minimal contract `T3Client` consumes. The fix-stage +// review noted this adapter did not exist yet. +// +// `RpcConnection` already exposes `request(tag:payload:headers:traceId:)` and +// `stream(tag:payload:headers:traceId:)`. `T3RpcTransport` requires the +// two-argument forms `request(tag:payload:)` / `stream(tag:payload:)`. A Swift +// protocol requirement is NOT satisfied by a method that merely has additional +// defaulted parameters (the full names differ), so these thin forwarders are +// required rather than a bare `extension RpcConnection: T3RpcTransport {}`. +// +// Both `stream` signatures are `async throws` and yield +// `AsyncThrowingStream`: the initial `Request` frame can fail +// to send, and that failure must propagate to the caller synchronously with the +// subscription attempt rather than only surfacing once someone iterates. + +import Foundation + +extension RpcConnection: T3RpcTransport { + public func request(tag: String, payload: JSONValue) async throws -> JSONValue { + try await request(tag: tag, payload: payload, headers: [], traceId: nil) + } + + public func stream( + tag: String, payload: JSONValue + ) async throws -> AsyncThrowingStream { + try await stream(tag: tag, payload: payload, headers: [], traceId: nil) + } +} diff --git a/apps/mac/Sources/T3Kit/ServerMetaRpc.swift b/apps/mac/Sources/T3Kit/ServerMetaRpc.swift new file mode 100644 index 00000000000..581222ce0a4 --- /dev/null +++ b/apps/mac/Sources/T3Kit/ServerMetaRpc.swift @@ -0,0 +1,91 @@ +// server.* mutation RPCs (rpc.ts §3.2): provider refresh/update and the +// settings get/patch pair. Read-side models live in ServerModels.swift. + +import Foundation + +/// `server.refreshProviders` / `server.updateProvider` success payload: +/// the full post-action provider list. +public struct ServerProviderUpdatedPayload: Decodable, Sendable { + public var providers: [ServerProvider] +} + +struct ServerRefreshProvidersInput: Encodable, Sendable { + var instanceId: String? +} + +struct ServerUpdateProviderInput: Encodable, Sendable { + var provider: String + var instanceId: String? +} + +struct ServerUpdateSettingsInput: Encodable, Sendable { + var patch: ServerSettingsPatch +} + +/// `ServerSettingsPatch` (settings.ts) — every key optionalKey; absent keys +/// leave the setting untouched. Only the fields the mac client edits are +/// modeled; the server merges partial patches. +public struct ServerSettingsPatch: Encodable, Sendable { + public var enableAssistantStreaming: Bool? + public var enableProviderUpdateChecks: Bool? + public var defaultThreadEnvMode: ThreadEnvMode? + public var newWorktreesStartFromOrigin: Bool? + public var addProjectBaseDirectory: String? + + public init( + enableAssistantStreaming: Bool? = nil, enableProviderUpdateChecks: Bool? = nil, + defaultThreadEnvMode: ThreadEnvMode? = nil, newWorktreesStartFromOrigin: Bool? = nil, + addProjectBaseDirectory: String? = nil + ) { + self.enableAssistantStreaming = enableAssistantStreaming + self.enableProviderUpdateChecks = enableProviderUpdateChecks + self.defaultThreadEnvMode = defaultThreadEnvMode + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.addProjectBaseDirectory = addProjectBaseDirectory + } + + private enum CodingKeys: String, CodingKey { + case enableAssistantStreaming, enableProviderUpdateChecks, defaultThreadEnvMode, + newWorktreesStartFromOrigin, addProjectBaseDirectory + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encodeIfPresent(enableAssistantStreaming, forKey: .enableAssistantStreaming) + try c.encodeIfPresent(enableProviderUpdateChecks, forKey: .enableProviderUpdateChecks) + try c.encodeIfPresent(defaultThreadEnvMode, forKey: .defaultThreadEnvMode) + try c.encodeIfPresent(newWorktreesStartFromOrigin, forKey: .newWorktreesStartFromOrigin) + try c.encodeIfPresent(addProjectBaseDirectory, forKey: .addProjectBaseDirectory) + } +} + +extension T3Client { + /// Re-probe installed provider CLIs; pass `instanceId` to target one. + @discardableResult + public func refreshProviders(instanceId: String? = nil) async throws + -> ServerProviderUpdatedPayload + { + try await call( + "server.refreshProviders", ServerRefreshProvidersInput(instanceId: instanceId)) + } + + /// Run the provider CLI's own update command (e.g. `npm i -g`). + @discardableResult + public func updateProviderCLI(driver: String, instanceId: String? = nil) async throws + -> ServerProviderUpdatedPayload + { + try await call( + "server.updateProvider", + ServerUpdateProviderInput(provider: driver, instanceId: instanceId)) + } + + public func getSettings() async throws -> ServerSettings { + try await call("server.getSettings", EmptyPayload()) + } + + /// Applies a partial settings patch; returns the merged settings. + @discardableResult + public func updateSettings(patch: ServerSettingsPatch) async throws -> ServerSettings { + try await call("server.updateSettings", ServerUpdateSettingsInput(patch: patch)) + } +} diff --git a/apps/mac/Sources/T3Kit/ServerModels.swift b/apps/mac/Sources/T3Kit/ServerModels.swift new file mode 100644 index 00000000000..9ccad7d6d12 --- /dev/null +++ b/apps/mac/Sources/T3Kit/ServerModels.swift @@ -0,0 +1,538 @@ +// Typed wire models for the "server meta / config / settings" RPC family +// (§3.2 of docs/wire-protocol.md), hand-ported from +// packages/contracts/src/server.ts, settings.ts, environment.ts, auth.ts, +// editor.ts, model.ts. Covers the v1 subset actually needed by +// SergeCodeMac.BackendService: initial sync (`server.getConfig`), live +// provider/settings updates (`subscribeServerConfig`), and provider status +// display. Diagnostics (trace/process), keybindings editing, and +// source-control discovery RPCs are out of v1 scope (ARCHITECTURE.md) and +// are not modeled; their carrier fields on `ServerConfig` are decoded as +// opaque `JSONValue` so the struct still round-trips. + +import Foundation + +// MARK: - Environment / auth descriptors (small, stable; decoded in full) + +public enum ExecutionEnvironmentPlatformOs: String, Codable, Sendable { + case darwin, linux, windows, unknown +} + +public enum ExecutionEnvironmentPlatformArch: String, Codable, Sendable { + case arm64, x64, other +} + +public struct ExecutionEnvironmentPlatform: Codable, Sendable { + public var os: ExecutionEnvironmentPlatformOs + public var arch: ExecutionEnvironmentPlatformArch +} + +public struct ExecutionEnvironmentCapabilities: Codable, Sendable { + public var repositoryIdentity: Bool + + private enum CodingKeys: String, CodingKey { case repositoryIdentity } + + public init(repositoryIdentity: Bool) { + self.repositoryIdentity = repositoryIdentity + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + repositoryIdentity = try c.decode(Bool.self, forKey: .repositoryIdentity, default: false) + } +} + +public struct ExecutionEnvironmentDescriptor: Codable, Sendable { + public var environmentId: String + public var label: String + public var platform: ExecutionEnvironmentPlatform + public var serverVersion: String + public var capabilities: ExecutionEnvironmentCapabilities +} + +public enum ServerAuthPolicy: String, Codable, Sendable { + case desktopManagedLocal = "desktop-managed-local" + case loopbackBrowser = "loopback-browser" + case remoteReachable = "remote-reachable" + case unsafeNoAuth = "unsafe-no-auth" +} + +public enum ServerAuthBootstrapMethod: String, Codable, Sendable { + case desktopBootstrap = "desktop-bootstrap" + case oneTimeToken = "one-time-token" +} + +public enum ServerAuthSessionMethod: String, Codable, Sendable { + case browserSessionCookie = "browser-session-cookie" + case bearerAccessToken = "bearer-access-token" + case dpopAccessToken = "dpop-access-token" +} + +public struct ServerAuthDescriptor: Codable, Sendable { + public var policy: ServerAuthPolicy + public var bootstrapMethods: [ServerAuthBootstrapMethod] + public var sessionMethods: [ServerAuthSessionMethod] + public var sessionCookieName: String +} + +// MARK: - Editors (editor.ts). `EditorId` is a closed literal set today but +// treated as an open string so a server ahead of this client on the editor +// list still decodes. + +public typealias EditorId = String + +// MARK: - Config issues + +public struct ServerConfigIssue: Codable, Sendable { + public var kind: String // "keybindings.malformed-config" | "keybindings.invalid-entry" + public var message: String + public var index: Int? +} + +// MARK: - Provider option descriptors (model.ts; used by ServerProviderModel.capabilities) + +public struct ProviderOptionChoice: Codable, Sendable, Hashable { + public var id: String + public var label: String + public var description: String? + public var isDefault: Bool? +} + +public struct SelectProviderOptionDescriptor: Codable, Sendable { + public let type: String = "select" + public var id: String + public var label: String + public var description: String? + public var options: [ProviderOptionChoice] + public var currentValue: String? + public var promptInjectedValues: [String]? + + private enum CodingKeys: String, CodingKey { + case type, id, label, description, options, currentValue, promptInjectedValues + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + label = try c.decode(String.self, forKey: .label) + description = try c.decodeIfPresent(String.self, forKey: .description) + options = try c.decode([ProviderOptionChoice].self, forKey: .options) + currentValue = try c.decodeIfPresent(String.self, forKey: .currentValue) + promptInjectedValues = try c.decodeIfPresent([String].self, forKey: .promptInjectedValues) + } +} + +public struct BooleanProviderOptionDescriptor: Codable, Sendable { + public let type: String = "boolean" + public var id: String + public var label: String + public var description: String? + public var currentValue: Bool? + + private enum CodingKeys: String, CodingKey { case type, id, label, description, currentValue } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + label = try c.decode(String.self, forKey: .label) + description = try c.decodeIfPresent(String.self, forKey: .description) + currentValue = try c.decodeIfPresent(Bool.self, forKey: .currentValue) + } +} + +/// `ProviderOptionDescriptor` union, discriminated by `type`. +public enum ProviderOptionDescriptor: Codable, Sendable { + case select(SelectProviderOptionDescriptor) + case boolean(BooleanProviderOptionDescriptor) + + private enum CodingKeys: String, CodingKey { case type } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(String.self, forKey: .type) { + case "select": + self = .select(try SelectProviderOptionDescriptor(from: decoder)) + case "boolean": + self = .boolean(try BooleanProviderOptionDescriptor(from: decoder)) + case let other: + throw DecodingError.dataCorruptedError( + forKey: .type, in: c, debugDescription: "Unknown ProviderOptionDescriptor type: \(other)") + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .select(let d): try d.encode(to: encoder) + case .boolean(let d): try d.encode(to: encoder) + } + } +} + +public struct ModelCapabilities: Codable, Sendable { + public var optionDescriptors: [ProviderOptionDescriptor]? +} + +// MARK: - Providers (server.ts) + +public enum ServerProviderState: String, Codable, Sendable { + case ready, warning, error, disabled +} + +public enum ServerProviderAuthStatus: String, Codable, Sendable { + case authenticated, unauthenticated, unknown +} + +public struct ServerProviderAuth: Codable, Sendable { + public var status: ServerProviderAuthStatus + public var type: String? + public var label: String? + public var email: String? +} + +public struct ServerProviderModel: Codable, Sendable { + public var slug: String + public var name: String + public var shortName: String? + public var subProvider: String? + public var isCustom: Bool + public var capabilities: ModelCapabilities? +} + +public struct ServerProviderSlashCommandInput: Codable, Sendable { + public var hint: String +} + +public struct ServerProviderSlashCommand: Codable, Sendable { + public var name: String + public var description: String? + public var input: ServerProviderSlashCommandInput? +} + +public struct ServerProviderSkill: Codable, Sendable { + public var name: String + public var description: String? + public var path: String + public var scope: String? + public var enabled: Bool + public var displayName: String? + public var shortDescription: String? +} + +/// See server.ts doc comment: an absent `availability` means `"available"` +/// (legacy producers never set it) — use `ServerProvider.isAvailable` +/// instead of reading this field directly. +public enum ServerProviderAvailability: String, Codable, Sendable { + case available, unavailable +} + +public struct ServerProviderContinuation: Codable, Sendable { + public var groupKey: String +} + +public enum ServerProviderVersionAdvisoryStatus: String, Codable, Sendable { + case unknown, current, behindLatest = "behind_latest" +} + +public struct ServerProviderVersionAdvisory: Codable, Sendable { + public var status: ServerProviderVersionAdvisoryStatus + public var currentVersion: String? + public var latestVersion: String? + public var updateCommand: String? + public var canUpdate: Bool + public var checkedAt: String? + public var message: String? + + private enum CodingKeys: String, CodingKey { + case status, currentVersion, latestVersion, updateCommand, canUpdate, checkedAt, message + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + status = try c.decode(ServerProviderVersionAdvisoryStatus.self, forKey: .status) + currentVersion = try c.decode(String?.self, forKey: .currentVersion, default: nil) + latestVersion = try c.decode(String?.self, forKey: .latestVersion, default: nil) + updateCommand = try c.decode(String?.self, forKey: .updateCommand, default: nil) + canUpdate = try c.decode(Bool.self, forKey: .canUpdate, default: false) + checkedAt = try c.decode(String?.self, forKey: .checkedAt, default: nil) + message = try c.decode(String?.self, forKey: .message, default: nil) + } +} + +public enum ServerProviderUpdateStatus: String, Codable, Sendable { + case idle, queued, running, succeeded, failed, unchanged +} + +public struct ServerProviderUpdateState: Codable, Sendable { + public var status: ServerProviderUpdateStatus + public var startedAt: String? + public var finishedAt: String? + public var message: String? + public var output: String? +} + +/// `ServerProvider` — one configured provider instance snapshot. +public struct ServerProvider: Codable, Sendable { + public var instanceId: String + public var driver: String + public var displayName: String? + public var accentColor: String? + public var badgeLabel: String? + public var continuation: ServerProviderContinuation? + public var showInteractionModeToggle: Bool? + public var requiresNewThreadForModelChange: Bool? + public var enabled: Bool + public var installed: Bool + public var version: String? + public var status: ServerProviderState + public var auth: ServerProviderAuth + public var checkedAt: String + public var message: String? + public var availability: ServerProviderAvailability? + public var unavailableReason: String? + public var models: [ServerProviderModel] + public var slashCommands: [ServerProviderSlashCommand] + public var skills: [ServerProviderSkill] + public var versionAdvisory: ServerProviderVersionAdvisory? + public var updateState: ServerProviderUpdateState? + + /// Absent `availability` means available (server.ts `isProviderAvailable`). + public var isAvailable: Bool { availability != .unavailable } + + private enum CodingKeys: String, CodingKey { + case instanceId, driver, displayName, accentColor, badgeLabel, continuation, + showInteractionModeToggle, requiresNewThreadForModelChange, enabled, installed, + version, status, auth, checkedAt, message, availability, unavailableReason, models, + slashCommands, skills, versionAdvisory, updateState + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + instanceId = try c.decode(String.self, forKey: .instanceId) + driver = try c.decode(String.self, forKey: .driver) + displayName = try c.decodeIfPresent(String.self, forKey: .displayName) + accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) + badgeLabel = try c.decodeIfPresent(String.self, forKey: .badgeLabel) + continuation = try c.decodeIfPresent(ServerProviderContinuation.self, forKey: .continuation) + showInteractionModeToggle = try c.decodeIfPresent(Bool.self, forKey: .showInteractionModeToggle) + requiresNewThreadForModelChange = try c.decodeIfPresent( + Bool.self, forKey: .requiresNewThreadForModelChange) + enabled = try c.decode(Bool.self, forKey: .enabled) + installed = try c.decode(Bool.self, forKey: .installed) + version = try c.decode(String?.self, forKey: .version, default: nil) + status = try c.decode(ServerProviderState.self, forKey: .status) + auth = try c.decode(ServerProviderAuth.self, forKey: .auth) + checkedAt = try c.decode(String.self, forKey: .checkedAt) + message = try c.decodeIfPresent(String.self, forKey: .message) + availability = try c.decodeIfPresent(ServerProviderAvailability.self, forKey: .availability) + unavailableReason = try c.decodeIfPresent(String.self, forKey: .unavailableReason) + models = try c.decode([ServerProviderModel].self, forKey: .models) + slashCommands = try c.decode( + [ServerProviderSlashCommand].self, forKey: .slashCommands, default: []) + skills = try c.decode([ServerProviderSkill].self, forKey: .skills, default: []) + versionAdvisory = try c.decodeIfPresent( + ServerProviderVersionAdvisory.self, forKey: .versionAdvisory) + updateState = try c.decodeIfPresent(ServerProviderUpdateState.self, forKey: .updateState) + } +} + +public struct ServerObservability: Codable, Sendable { + public var logsDirectoryPath: String + public var localTracingEnabled: Bool + public var otlpTracesUrl: String? + public var otlpTracesEnabled: Bool + public var otlpMetricsUrl: String? + public var otlpMetricsEnabled: Bool +} + +// MARK: - Settings (settings.ts). Per-driver settings blobs and the +// instance-config map are intentionally opaque (`JSONValue`) — no v1 UI +// edits them (SettingsScene.swift ships only a read-only provider list and +// connection info); the fields still decode/round-trip losslessly. + +public enum ThreadEnvMode: String, Codable, Sendable { + case local, worktree +} + +public struct ObservabilitySettings: Codable, Sendable { + public var otlpTracesUrl: String + public var otlpMetricsUrl: String + + private enum CodingKeys: String, CodingKey { case otlpTracesUrl, otlpMetricsUrl } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + otlpTracesUrl = try c.decode(String.self, forKey: .otlpTracesUrl, default: "") + otlpMetricsUrl = try c.decode(String.self, forKey: .otlpMetricsUrl, default: "") + } +} + +// Decode-only: the client never sends a whole `ServerSettings` back (a +// future `server.updateSettings` would use the separate `ServerSettingsPatch` +// shape, out of v1 scope). `automaticGitFetchIntervalMs` is deliberately +// renamed from the wire's `automaticGitFetchInterval` for clarity, which +// would break Encodable synthesis (no matching CodingKeys case) — kept +// Decodable-only so that's moot. +public struct ServerSettings: Decodable, Sendable { + public var enableAssistantStreaming: Bool + public var enableProviderUpdateChecks: Bool + /// `Schema.DurationFromMillis` — milliseconds (§5.1 numeric-time exception). + public var automaticGitFetchIntervalMs: Double + public var defaultThreadEnvMode: ThreadEnvMode + public var newWorktreesStartFromOrigin: Bool + public var addProjectBaseDirectory: String + public var textGenerationModelSelection: ModelSelection + /// Per-driver legacy settings blobs (`codex`, `claudeAgent`, `cursor`, + /// `grok`, `opencode`) — opaque, not edited by v1 UI. + public var providers: JSONValue + /// `Record` — opaque. + public var providerInstances: JSONValue + public var observability: ObservabilitySettings + + private enum CodingKeys: String, CodingKey { + case enableAssistantStreaming, enableProviderUpdateChecks, automaticGitFetchInterval, + defaultThreadEnvMode, newWorktreesStartFromOrigin, addProjectBaseDirectory, + textGenerationModelSelection, providers, providerInstances, observability + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + enableAssistantStreaming = try c.decode( + Bool.self, forKey: .enableAssistantStreaming, default: false) + enableProviderUpdateChecks = try c.decode( + Bool.self, forKey: .enableProviderUpdateChecks, default: true) + automaticGitFetchIntervalMs = try c.decode( + Double.self, forKey: .automaticGitFetchInterval, default: 30_000) + defaultThreadEnvMode = try c.decode( + ThreadEnvMode.self, forKey: .defaultThreadEnvMode, default: .local) + newWorktreesStartFromOrigin = try c.decode( + Bool.self, forKey: .newWorktreesStartFromOrigin, default: false) + addProjectBaseDirectory = try c.decode( + String.self, forKey: .addProjectBaseDirectory, default: "") + textGenerationModelSelection = try c.decode( + ModelSelection.self, forKey: .textGenerationModelSelection) + providers = try c.decode(JSONValue.self, forKey: .providers, default: .object([:])) + providerInstances = try c.decode( + JSONValue.self, forKey: .providerInstances, default: .object([:])) + observability = try c.decode( + ObservabilitySettings.self, forKey: .observability, + default: ObservabilitySettings(rawTracesUrl: "", rawMetricsUrl: "")) + } +} + +extension ObservabilitySettings { + fileprivate init(rawTracesUrl: String, rawMetricsUrl: String) { + self.otlpTracesUrl = rawTracesUrl + self.otlpMetricsUrl = rawMetricsUrl + } +} + +// MARK: - ServerConfig (initial sync object, §1.3/§3.2) + +public struct ServerConfig: Decodable, Sendable { + public var environment: ExecutionEnvironmentDescriptor + public var auth: ServerAuthDescriptor + public var cwd: String + public var keybindingsConfigPath: String + /// `ResolvedKeybindingsConfig` — opaque; the keybindings editor is out of + /// v1 scope (ARCHITECTURE.md). + public var keybindings: JSONValue + public var issues: [ServerConfigIssue] + public var providers: [ServerProvider] + public var availableEditors: [EditorId] + public var observability: ServerObservability + public var settings: ServerSettings +} + +// MARK: - subscribeServerConfig stream (server.ts §3.11) + +public struct ServerConfigKeybindingsUpdatedPayload: Decodable, Sendable { + public var keybindings: JSONValue + public var issues: [ServerConfigIssue] +} + +public struct ServerConfigProviderStatusesPayload: Decodable, Sendable { + public var providers: [ServerProvider] +} + +public struct ServerConfigSettingsUpdatedPayload: Decodable, Sendable { + public var settings: ServerSettings +} + +/// `ServerConfigStreamEvent` — union discriminated by `type` (plus a fixed +/// `version: 1`). +public enum ServerConfigStreamEvent: Decodable, Sendable { + case snapshot(ServerConfig) + case keybindingsUpdated(ServerConfigKeybindingsUpdatedPayload) + case providerStatuses(ServerConfigProviderStatusesPayload) + case settingsUpdated(ServerConfigSettingsUpdatedPayload) + /// Any `type` this codec doesn't recognize (forward compatibility, §6 + /// risk 1) — this is one of the explicitly-unstable streams, so an + /// unknown variant must not kill the subscription (matching + /// `OrchestrationEvent`'s `.other` fallback). + case other(type: String, payload: JSONValue?) + + private enum CodingKeys: String, CodingKey { case type, config, payload } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let type = try c.decode(String.self, forKey: .type) + switch type { + case "snapshot": + self = .snapshot(try c.decode(ServerConfig.self, forKey: .config)) + case "keybindingsUpdated": + self = .keybindingsUpdated( + try c.decode(ServerConfigKeybindingsUpdatedPayload.self, forKey: .payload)) + case "providerStatuses": + self = .providerStatuses( + try c.decode(ServerConfigProviderStatusesPayload.self, forKey: .payload)) + case "settingsUpdated": + self = .settingsUpdated( + try c.decode(ServerConfigSettingsUpdatedPayload.self, forKey: .payload)) + default: + let payload = try c.decodeIfPresent(JSONValue.self, forKey: .payload) + self = .other(type: type, payload: payload) + } + } +} + +// MARK: - subscribeServerLifecycle stream (server.ts; used only for the +// welcome/ready readiness signal — see wire-protocol.md §1.3) + +public struct ServerLifecycleWelcomePayload: Decodable, Sendable { + public var environment: ExecutionEnvironmentDescriptor + public var cwd: String + public var projectName: String + public var bootstrapProjectId: String? + public var bootstrapThreadId: String? +} + +public struct ServerLifecycleReadyPayload: Decodable, Sendable { + public var at: String + public var environment: ExecutionEnvironmentDescriptor +} + +public enum ServerLifecycleStreamEvent: Decodable, Sendable { + case welcome(sequence: Int, payload: ServerLifecycleWelcomePayload) + case ready(sequence: Int, payload: ServerLifecycleReadyPayload) + /// Any `type` this codec doesn't recognize (forward compatibility, §6 + /// risk 1) — see `ServerConfigStreamEvent.other`. + case other(type: String, sequence: Int, payload: JSONValue?) + + private enum CodingKeys: String, CodingKey { case type, sequence, payload } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let sequence = try c.decode(Int.self, forKey: .sequence) + let type = try c.decode(String.self, forKey: .type) + switch type { + case "welcome": + self = .welcome( + sequence: sequence, payload: try c.decode(ServerLifecycleWelcomePayload.self, forKey: .payload)) + case "ready": + self = .ready( + sequence: sequence, payload: try c.decode(ServerLifecycleReadyPayload.self, forKey: .payload)) + default: + let payload = try c.decodeIfPresent(JSONValue.self, forKey: .payload) + self = .other(type: type, sequence: sequence, payload: payload) + } + } +} diff --git a/apps/mac/Sources/T3Kit/T3Client.swift b/apps/mac/Sources/T3Kit/T3Client.swift new file mode 100644 index 00000000000..0080bb45d0f --- /dev/null +++ b/apps/mac/Sources/T3Kit/T3Client.swift @@ -0,0 +1,351 @@ +// T3Client — typed facade over the RPC connection layer, exposing the v1 +// method subset (orchestration + server-config, §3.1/§3.2/§3.11 of +// docs/wire-protocol.md) as async calls / AsyncThrowingStreams instead of +// raw `(tag, JSONValue)` pairs. This is the "one layer up" from +// RpcConnection.swift that the architect plan describes: RpcConnection +// deals in JSONValue, this file does the JSON <-> typed-model conversion +// using WireCoding + the models in OrchestrationModels.swift/ServerModels.swift. +// +// ASSUMED DEPENDENCY CONTRACT (flag for reconciliation — RpcConnection.swift +// is a different slice of this plan and its exact public API was not +// available when this file was written): `T3RpcTransport` below states the +// minimal shape this client needs. Once RpcConnection.swift lands, either +// conform it directly (`extension RpcConnection: T3RpcTransport {}`, if its +// method names already match) or add a one-screen adapter. The essential +// invariants this file relies on: +// - `request` performs one non-streaming RPC: send `Request{tag,payload}`, +// await the terminal `Exit`, decode `.success.value` or throw +// `T3Error.rpc(RpcFailure)` from `.failure.cause` (§2.2). +// - `stream` performs one streaming RPC: send `Request{tag,payload}`, +// yield each `Chunk.values` element (sending the mandatory per-chunk +// `Ack` internally, §2.3 — non-negotiable, or the stream stalls after +// the first chunk), and finish the stream on the terminal `Exit` +// (throwing on `Exit.Failure`). Cancelling the returned +// `AsyncThrowingStream` (Task cancellation) must translate to an +// `Interrupt{requestId}` frame internally — this layer never sees +// RpcConnection's internal request ids, so it cannot send `Interrupt` +// itself. + +import Foundation + +/// Minimal transport contract `T3Client` needs from the connection actor. +/// +/// `stream` is `async throws` (not a plain synchronous-returning function) +/// because `RpcConnection.stream` itself throws if the initial `Request` +/// frame fails to send (§2.4) — that failure must propagate to the caller +/// rather than being swallowed or only surfacing once someone iterates the +/// stream. +public protocol T3RpcTransport: Actor { + func request(tag: String, payload: JSONValue) async throws -> JSONValue + func stream(tag: String, payload: JSONValue) async throws -> AsyncThrowingStream +} + +/// `{}` — the payload schema for every no-argument RPC in the v1 subset +/// (`server.getConfig`, `subscribeServerConfig`, `subscribeServerLifecycle`, +/// `orchestration.getArchivedShellSnapshot`, `orchestration.subscribeShell`). +public struct EmptyPayload: Encodable, Sendable { + public init() {} +} + +/// `orchestration.subscribeThread` input (`OrchestrationSubscribeThreadInput`). +public struct OrchestrationSubscribeThreadInput: Encodable, Sendable { + public var threadId: String + public init(threadId: String) { self.threadId = threadId } +} + +/// Client-side id/timestamp generation helpers. Every dispatched command +/// carries a client-minted `commandId` and (for most variants) `createdAt`; +/// the server treats both as opaque strings, so any unique value is valid, +/// but ids must be unique per §6 risk 12 (trimmed, non-empty). +public enum T3Ids { + public static func newCommandId() -> String { UUID().uuidString } + public static func newMessageId() -> String { UUID().uuidString } +} + +public enum T3Clock { + /// ISO-8601 UTC with fractional seconds, matching `IsoDateTime`'s wire + /// convention (`DateTime.formatIso`, §5.1). + public static func nowISO8601() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } +} + +/// Typed RPC facade for the v1 method subset. One instance per live +/// connection; construct after the transport is connected and authenticated +/// (AuthClient/RpcConnection's job, not this layer's). +public actor T3Client { + private let transport: any T3RpcTransport + + public init(transport: any T3RpcTransport) { + self.transport = transport + } + + // MARK: - Generic call helpers + + func call( + _ tag: String, _ payload: Payload + ) async throws -> Success { + let payloadJSON = try Self.encodeToJSON(payload) + let resultJSON = try await transport.request(tag: tag, payload: payloadJSON) + do { + return try resultJSON.decode(as: Success.self, using: WireCoding.decoder) + } catch { + throw T3Error.decoding("Failed decoding \(Success.self) for \(tag): \(error)") + } + } + + func streamCall( + _ tag: String, _ payload: Payload + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let payloadJSON = try Self.encodeToJSON(payload) + let rawStream = try await self.transport.stream(tag: tag, payload: payloadJSON) + for try await raw in rawStream { + do { + continuation.yield(try raw.decode(as: Item.self, using: WireCoding.decoder)) + } catch { + continuation.finish( + throwing: T3Error.decoding("Failed decoding \(Item.self) for \(tag): \(error)")) + return + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + static func encodeToJSON(_ value: some Encodable) throws -> JSONValue { + let data = try WireCoding.encoder.encode(value) + return try WireCoding.decoder.decode(JSONValue.self, from: data) + } + + // MARK: - server.* (§3.2, §3.11) + + public func getConfig() async throws -> ServerConfig { + try await call("server.getConfig", EmptyPayload()) + } + + public func subscribeServerConfig() -> AsyncThrowingStream { + streamCall("subscribeServerConfig", EmptyPayload()) + } + + public func subscribeServerLifecycle() -> AsyncThrowingStream { + streamCall("subscribeServerLifecycle", EmptyPayload()) + } + + // MARK: - orchestration.* (§3.1) + + @discardableResult + public func dispatch(_ command: ClientOrchestrationCommand) async throws -> DispatchResult { + try await call("orchestration.dispatchCommand", command) + } + + public func getTurnDiff( + threadId: String, fromTurnCount: Int, toTurnCount: Int, ignoreWhitespace: Bool? = nil + ) async throws -> ThreadTurnDiff { + try await call( + "orchestration.getTurnDiff", + OrchestrationGetTurnDiffInput( + fromTurnCount: fromTurnCount, toTurnCount: toTurnCount, threadId: threadId, + ignoreWhitespace: ignoreWhitespace)) + } + + public func getFullThreadDiff( + threadId: String, toTurnCount: Int, ignoreWhitespace: Bool? = nil + ) async throws -> ThreadTurnDiff { + try await call( + "orchestration.getFullThreadDiff", + OrchestrationGetFullThreadDiffInput( + threadId: threadId, toTurnCount: toTurnCount, ignoreWhitespace: ignoreWhitespace)) + } + + public func replayEvents(fromSequenceExclusive: Int) async throws -> [OrchestrationEvent] { + try await call( + "orchestration.replayEvents", + OrchestrationReplayEventsInput(fromSequenceExclusive: fromSequenceExclusive)) + } + + public func getArchivedShellSnapshot() async throws -> OrchestrationShellSnapshot { + try await call("orchestration.getArchivedShellSnapshot", EmptyPayload()) + } + + /// Snapshot-then-live-tail (§4.2): first item is `.snapshot`, then + /// `.event(...)` deltas for every project/thread. Re-issue after every + /// reconnect (§4.3) — there is no resume token. + public func subscribeShell() -> AsyncThrowingStream { + streamCall("orchestration.subscribeShell", EmptyPayload()) + } + + /// Snapshot-then-live-tail for one thread's detail (messages, activities, + /// proposed plans, checkpoints, session). + public func subscribeThread(threadId: String) -> AsyncThrowingStream { + streamCall("orchestration.subscribeThread", OrchestrationSubscribeThreadInput(threadId: threadId)) + } + + // MARK: - Command convenience wrappers + // + // Thin sugar over `dispatch(_:)` that fills in `commandId`/`createdAt`. + // Kept at the wire-parameter level (not BackendService's UI-level + // signatures) since resolving UI ids (e.g. a `Checkpoint.id` -> + // `turnCount`) is the app-layer adapter's job, not this client's. + + @discardableResult + public func createProject( + projectId: String, title: String, workspaceRoot: String, + createWorkspaceRootIfMissing: Bool? = nil, defaultModelSelection: ModelSelection? = nil + ) async throws -> DispatchResult { + try await dispatch( + .projectCreate( + ProjectCreateCommand( + commandId: T3Ids.newCommandId(), projectId: projectId, title: title, + workspaceRoot: workspaceRoot, + createWorkspaceRootIfMissing: createWorkspaceRootIfMissing, + defaultModelSelection: defaultModelSelection, createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func deleteProject(projectId: String, force: Bool? = nil) async throws -> DispatchResult { + try await dispatch( + .projectDelete( + ProjectDeleteCommand(commandId: T3Ids.newCommandId(), projectId: projectId, force: force))) + } + + @discardableResult + public func createThread( + threadId: String, projectId: String, title: String, modelSelection: ModelSelection, + runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode = .wireDefault, + branch: String? = nil, worktreePath: String? = nil + ) async throws -> DispatchResult { + try await dispatch( + .threadCreate( + ThreadCreateCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, projectId: projectId, + title: title, modelSelection: modelSelection, runtimeMode: runtimeMode, + interactionMode: interactionMode, branch: branch, worktreePath: worktreePath, + createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func deleteThread(threadId: String) async throws -> DispatchResult { + try await dispatch(.threadDelete(ThreadDeleteCommand(commandId: T3Ids.newCommandId(), threadId: threadId))) + } + + @discardableResult + public func archiveThread(threadId: String) async throws -> DispatchResult { + try await dispatch(.threadArchive(ThreadArchiveCommand(commandId: T3Ids.newCommandId(), threadId: threadId))) + } + + @discardableResult + public func unarchiveThread(threadId: String) async throws -> DispatchResult { + try await dispatch( + .threadUnarchive(ThreadUnarchiveCommand(commandId: T3Ids.newCommandId(), threadId: threadId))) + } + + @discardableResult + public func setRuntimeMode(threadId: String, runtimeMode: RuntimeMode) async throws -> DispatchResult { + try await dispatch( + .threadRuntimeModeSet( + ThreadRuntimeModeSetCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, runtimeMode: runtimeMode, + createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func setInteractionMode( + threadId: String, interactionMode: ProviderInteractionMode + ) async throws -> DispatchResult { + try await dispatch( + .threadInteractionModeSet( + ThreadInteractionModeSetCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, + interactionMode: interactionMode, createdAt: T3Clock.nowISO8601()))) + } + + /// Partial thread-meta update; `nil` fields are omitted (left untouched). + @discardableResult + public func updateThreadMeta( + threadId: String, title: String? = nil, modelSelection: ModelSelection? = nil, + branch: String?? = nil, worktreePath: String?? = nil + ) async throws -> DispatchResult { + try await dispatch( + .threadMetaUpdate( + ThreadMetaUpdateCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, title: title, + modelSelection: modelSelection, branch: branch, worktreePath: worktreePath))) + } + + /// Sends a user message and starts a turn (composer send action). + @discardableResult + public func startTurn( + threadId: String, text: String, attachments: [UploadChatAttachment] = [], + modelSelection: ModelSelection? = nil, titleSeed: String? = nil, + runtimeMode: RuntimeMode = .wireDefault, interactionMode: ProviderInteractionMode = .wireDefault, + sourceProposedPlan: SourceProposedPlanReference? = nil + ) async throws -> DispatchResult { + let message = ChatMessageInput( + messageId: T3Ids.newMessageId(), text: text, attachments: attachments) + return try await dispatch( + .threadTurnStart( + ThreadTurnStartCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, message: message, + modelSelection: modelSelection, titleSeed: titleSeed, runtimeMode: runtimeMode, + interactionMode: interactionMode, sourceProposedPlan: sourceProposedPlan, + createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func interruptTurn(threadId: String, turnId: String? = nil) async throws -> DispatchResult { + try await dispatch( + .threadTurnInterrupt( + ThreadTurnInterruptCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, turnId: turnId, + createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func respondToApproval( + threadId: String, requestId: String, decision: ProviderApprovalDecision + ) async throws -> DispatchResult { + try await dispatch( + .threadApprovalRespond( + ThreadApprovalRespondCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, requestId: requestId, + decision: decision, createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func respondToUserInput( + threadId: String, requestId: String, answers: ProviderUserInputAnswers + ) async throws -> DispatchResult { + try await dispatch( + .threadUserInputRespond( + ThreadUserInputRespondCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, requestId: requestId, + answers: answers, createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func revertCheckpoint(threadId: String, turnCount: Int) async throws -> DispatchResult { + try await dispatch( + .threadCheckpointRevert( + ThreadCheckpointRevertCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, turnCount: turnCount, + createdAt: T3Clock.nowISO8601()))) + } + + @discardableResult + public func stopSession(threadId: String) async throws -> DispatchResult { + try await dispatch( + .threadSessionStop( + ThreadSessionStopCommand( + commandId: T3Ids.newCommandId(), threadId: threadId, createdAt: T3Clock.nowISO8601()))) + } +} diff --git a/apps/mac/Sources/T3Kit/T3Error.swift b/apps/mac/Sources/T3Kit/T3Error.swift new file mode 100644 index 00000000000..4bb879ccca1 --- /dev/null +++ b/apps/mac/Sources/T3Kit/T3Error.swift @@ -0,0 +1,99 @@ +// T3Error.swift +// Error taxonomy surfaced to callers: transport failures, decode failures, +// ack/ping-timeout deadness, and typed in-band RPC failures decoded from +// Exit.Failure causes (§2.2, §5.6). RpcFailure is the per-request error the +// app must handle without treating it as connection-fatal (§risk9). + +import Foundation + +/// Decoded from `Exit.Failure.cause` (§2.2): an array of cause nodes, each +/// discriminated by `_tag` as `Fail` | `Die` | `Interrupt`. +public struct RpcFailure: Error, Decodable, Sendable { + public enum Node: Sendable, Decodable { + /// `{ "_tag": "Fail", "error": { "_tag": "", …fields } }`. + /// `tag` is the *error's* `_tag` literal (§5.9: match on this, never + /// the exported class name); `error` is the full error object. + case fail(tag: String, error: JSONValue) + /// `{ "_tag": "Die", "defect": }` — opaque, best-effort (§5.6). + case die(defect: JSONValue) + /// `{ "_tag": "Interrupt", "fiberId": 12 }` — `fiberId` may be absent. + case interrupt(fiberId: Int?) + + private enum CodingKeys: String, CodingKey { + case _tag = "_tag" + case error, defect, fiberId + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let tag = try container.decode(String.self, forKey: ._tag) + switch tag { + case "Fail": + let error = try container.decode(JSONValue.self, forKey: .error) + let errorTag = error["_tag"]?.stringValue ?? "" + self = .fail(tag: errorTag, error: error) + case "Die": + let defect = try container.decode(JSONValue.self, forKey: .defect) + self = .die(defect: defect) + case "Interrupt": + let fiberId = try container.decodeIfPresent(Int.self, forKey: .fiberId) + self = .interrupt(fiberId: fiberId) + default: + // Forward-compatible fallback: an unrecognized cause-node tag + // is treated as an opaque defect rather than a hard decode + // failure (§6 risk 1: the envelope is an unstable Effect API). + let raw = try JSONValue(from: decoder) + self = .die(defect: raw) + } + } + } + + public let causes: [Node] + + public init(causes: [Node]) { + self.causes = causes + } + + public init(from decoder: any Decoder) throws { + causes = try [Node](from: decoder) + } + + /// The first `Fail` node's error `_tag` literal (§5.9). Match on this, not + /// the Swift/TS class name. + public var primaryErrorTag: String? { + for cause in causes { + if case let .fail(tag, _) = cause { return tag } + } + return nil + } + + public var isAuthorizationError: Bool { + primaryErrorTag == "EnvironmentAuthorizationError" + } + + public var requiredScope: String? { + for cause in causes { + if case let .fail(_, error) = cause { + return error["requiredScope"]?.stringValue + } + } + return nil + } +} + +public enum T3Error: Error, Sendable { + case notConnected + case connectionClosed(reason: String?) + /// ~10s without a `Pong` after the 5s ping cadence (§1.4). + case pingTimeout + /// URLSession/WebSocket transport-layer failure. + case transport(String) + /// Frame/model decode failure (malformed JSON, unexpected shape). + case decoding(String) + /// A typed `Exit.Failure` cause, decoded per-request (§risk9: not fatal + /// to the connection). + case rpc(RpcFailure) + /// HTTP bootstrap/ticket failure (§1.2). + case auth(String) + case unexpectedFrame(String) +} diff --git a/apps/mac/Sources/T3Kit/T3Kit.swift b/apps/mac/Sources/T3Kit/T3Kit.swift new file mode 100644 index 00000000000..33289dfc51c --- /dev/null +++ b/apps/mac/Sources/T3Kit/T3Kit.swift @@ -0,0 +1,6 @@ +// T3Kit — Swift client for the t3 server WebSocket protocol. +// Protocol layer implemented against packages/contracts; see ARCHITECTURE.md. + +public enum T3Kit { + public static let defaultPort = 3773 +} diff --git a/apps/mac/Sources/T3Kit/T3KitConfig.swift b/apps/mac/Sources/T3Kit/T3KitConfig.swift new file mode 100644 index 00000000000..987289cfd2e --- /dev/null +++ b/apps/mac/Sources/T3Kit/T3KitConfig.swift @@ -0,0 +1,45 @@ +// T3KitConfig — app-facing configuration for connecting T3Kit to the t3 +// server sidecar that SidecarKit spawns (ARCHITECTURE.md "Sidecar contract": +// loopback host, desktop-managed-local auth; wire-protocol.md §1.1 default +// port). +// +// The embedding app (SergeCodeMac) constructs one of these once SidecarKit's +// readiness poll succeeds and the `desktopBootstrapToken` from +// `DesktopBackendBootstrap` is known, then derives `authConfig` to build an +// `AuthClient` (AuthClient.swift). + +import Foundation + +/// Everything T3Kit needs to know to reach one running t3 server sidecar. +public struct T3KitConfig: Sendable { + /// Loopback host the sidecar bound to, e.g. `"127.0.0.1"`. + public let host: String + /// Port the sidecar's HTTP/WebSocket server is listening on (default + /// `T3Kit.defaultPort` = 3773, wire-protocol.md §1.1 `DEFAULT_PORT`). + public let port: Int + /// One-shot bootstrap credential handed to the sidecar over stdin + /// (`DesktopBackendBootstrap.desktopBootstrapToken`). + public let desktopBootstrapToken: String + + public init(host: String = "127.0.0.1", port: Int = T3Kit.defaultPort, desktopBootstrapToken: String) { + self.host = host + self.port = port + self.desktopBootstrapToken = desktopBootstrapToken + } + + /// `http://:` — base for the local auth HTTP API (§1.2). + public var httpBaseURL: URL { + URL(string: "http://\(host):\(port)")! + } + + /// `ws://:` — base for the RPC socket; `AuthClient` forces + /// the path to `/ws` on every connect (§1.1). + public var wsBaseURL: URL { + URL(string: "ws://\(host):\(port)")! + } + + /// Derives the `AuthConfig` consumed by `AuthClient`. + public var authConfig: AuthConfig { + AuthConfig(httpBaseURL: httpBaseURL, wsBaseURL: wsBaseURL, desktopBootstrapToken: desktopBootstrapToken) + } +} diff --git a/apps/mac/Sources/T3Kit/VcsRpc.swift b/apps/mac/Sources/T3Kit/VcsRpc.swift new file mode 100644 index 00000000000..6bf3ca85725 --- /dev/null +++ b/apps/mac/Sources/T3Kit/VcsRpc.swift @@ -0,0 +1,279 @@ +// vcs.* / git.* / subscribeVcsStatus RPC family (packages/contracts/src/ +// git.ts + vcs.ts, wired in rpc.ts). Branch operations, working-tree status +// (snapshot + live stream), and the stacked git action pipeline +// (commit/push/PR with streamed progress). + +import Foundation + +// MARK: - Status + +public struct VcsWorkingTreeFile: Decodable, Sendable, Hashable { + public var path: String + public var insertions: Int + public var deletions: Int +} + +public struct VcsWorkingTree: Decodable, Sendable { + public var files: [VcsWorkingTreeFile] + public var insertions: Int + public var deletions: Int +} + +public struct VcsStatusChangeRequest: Decodable, Sendable { + public var number: Int + public var title: String + public var url: String + public var baseRef: String + public var headRef: String + /// "open" | "closed" | "merged" + public var state: String +} + +/// `VcsStatusLocalResult` — repo-local status (no network). +public struct VcsStatusLocal: Decodable, Sendable { + public var isRepo: Bool + public var hasPrimaryRemote: Bool + public var isDefaultRef: Bool + public var refName: String? + public var hasWorkingTreeChanges: Bool + public var workingTree: VcsWorkingTree +} + +/// `VcsStatusRemoteResult` — upstream/PR status (may be stale-cached). +public struct VcsStatusRemote: Decodable, Sendable { + public var hasUpstream: Bool + public var aheadCount: Int + public var behindCount: Int + public var aheadOfDefaultCount: Int? + public var pr: VcsStatusChangeRequest? +} + +/// `vcs.refreshStatus` result: local + remote flattened into one struct. +public struct VcsStatusResult: Decodable, Sendable { + public var isRepo: Bool + public var hasPrimaryRemote: Bool + public var isDefaultRef: Bool + public var refName: String? + public var hasWorkingTreeChanges: Bool + public var workingTree: VcsWorkingTree + public var hasUpstream: Bool + public var aheadCount: Int + public var behindCount: Int + public var aheadOfDefaultCount: Int? + public var pr: VcsStatusChangeRequest? +} + +/// `subscribeVcsStatus` stream item — TaggedStruct union, discriminated by +/// `_tag` (not `type`). +public enum VcsStatusStreamEvent: Decodable, Sendable { + case snapshot(local: VcsStatusLocal, remote: VcsStatusRemote?) + case localUpdated(VcsStatusLocal) + case remoteUpdated(VcsStatusRemote?) + + private enum CodingKeys: String, CodingKey { + case tag = "_tag" + case local, remote + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(String.self, forKey: .tag) { + case "snapshot": + self = .snapshot( + local: try c.decode(VcsStatusLocal.self, forKey: .local), + remote: try c.decodeIfPresent(VcsStatusRemote.self, forKey: .remote)) + case "localUpdated": + self = .localUpdated(try c.decode(VcsStatusLocal.self, forKey: .local)) + case "remoteUpdated": + self = .remoteUpdated(try c.decodeIfPresent(VcsStatusRemote.self, forKey: .remote)) + case let other: + throw DecodingError.dataCorruptedError( + forKey: .tag, in: c, debugDescription: "Unknown VcsStatusStreamEvent tag: \(other)") + } + } +} + +// MARK: - Refs + +public struct VcsRef: Decodable, Sendable, Hashable { + public var name: String + public var isRemote: Bool? + public var remoteName: String? + public var current: Bool + public var isDefault: Bool + public var worktreePath: String? +} + +public struct VcsListRefsResult: Decodable, Sendable { + public var refs: [VcsRef] + public var isRepo: Bool + public var hasPrimaryRemote: Bool + public var nextCursor: Int? + public var totalCount: Int +} + +// MARK: - Inputs + +struct VcsCwdInput: Encodable, Sendable { + var cwd: String +} + +struct VcsListRefsInput: Encodable, Sendable { + var cwd: String + var query: String? + var refKind: String? + var limit: Int? +} + +struct VcsCreateRefInput: Encodable, Sendable { + var cwd: String + var refName: String + var switchRef: Bool? +} + +struct VcsSwitchRefInput: Encodable, Sendable { + var cwd: String + var refName: String +} + +public struct VcsCreateRefResult: Decodable, Sendable { + public var refName: String +} + +public struct VcsSwitchRefResult: Decodable, Sendable { + public var refName: String? +} + +public struct VcsPullResult: Decodable, Sendable { + /// "pulled" | "skipped_up_to_date" + public var status: String + public var refName: String + public var upstreamRef: String? +} + +// MARK: - Stacked git actions + +/// `GitStackedAction` literals. +public enum GitStackedAction: String, Encodable, Sendable { + case commit + case push + case createPR = "create_pr" + case commitPush = "commit_push" + case commitPushPR = "commit_push_pr" +} + +struct GitRunStackedActionInput: Encodable, Sendable { + var actionId: String + var cwd: String + var action: GitStackedAction + var commitMessage: String? + var featureBranch: Bool? +} + +public struct GitActionToast: Decodable, Sendable { + public var title: String + public var description: String? + /// `cta.kind == "open_pr"` carries `url`; decoded loosely. + public var cta: JSONValue? + + public var prURL: String? { + guard let cta = cta?.objectValue, cta["kind"]?.stringValue == "open_pr" else { return nil } + return cta["url"]?.stringValue + } +} + +public struct GitRunStackedActionResult: Decodable, Sendable { + public var toast: GitActionToast +} + +/// `git.runStackedAction` streamed progress, discriminated by `kind`. +public enum GitActionProgressEvent: Decodable, Sendable { + case started(phases: [String]) + case phaseStarted(phase: String, label: String) + case hookStarted(hookName: String) + case hookOutput(text: String) + case hookFinished(hookName: String, exitCode: Int?) + case finished(GitRunStackedActionResult) + case failed(phase: String?, message: String) + + private enum CodingKeys: String, CodingKey { + case kind, phases, phase, label, hookName, text, exitCode, result, message + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + switch try c.decode(String.self, forKey: .kind) { + case "action_started": + self = .started(phases: try c.decodeIfPresent([String].self, forKey: .phases) ?? []) + case "phase_started": + self = .phaseStarted( + phase: try c.decode(String.self, forKey: .phase), + label: try c.decode(String.self, forKey: .label)) + case "hook_started": + self = .hookStarted(hookName: try c.decode(String.self, forKey: .hookName)) + case "hook_output": + self = .hookOutput(text: try c.decode(String.self, forKey: .text)) + case "hook_finished": + self = .hookFinished( + hookName: try c.decode(String.self, forKey: .hookName), + exitCode: try c.decodeIfPresent(Int.self, forKey: .exitCode)) + case "action_finished": + self = .finished(try c.decode(GitRunStackedActionResult.self, forKey: .result)) + case "action_failed": + self = .failed( + phase: try c.decodeIfPresent(String.self, forKey: .phase), + message: try c.decode(String.self, forKey: .message)) + case let other: + throw DecodingError.dataCorruptedError( + forKey: .kind, in: c, debugDescription: "Unknown GitActionProgressEvent kind: \(other)") + } + } +} + +// MARK: - Client methods + +extension T3Client { + /// Live working-tree/branch/PR status for one repo. Snapshot first, + /// then local/remote deltas. + public func subscribeVcsStatus(cwd: String) -> AsyncThrowingStream { + streamCall("subscribeVcsStatus", VcsCwdInput(cwd: cwd)) + } + + public func refreshVcsStatus(cwd: String) async throws -> VcsStatusResult { + try await call("vcs.refreshStatus", VcsCwdInput(cwd: cwd)) + } + + public func listRefs( + cwd: String, query: String? = nil, refKind: String? = nil, limit: Int? = nil + ) async throws -> VcsListRefsResult { + try await call( + "vcs.listRefs", VcsListRefsInput(cwd: cwd, query: query, refKind: refKind, limit: limit)) + } + + public func createRef(cwd: String, refName: String, switchRef: Bool = true) async throws + -> VcsCreateRefResult + { + try await call( + "vcs.createRef", VcsCreateRefInput(cwd: cwd, refName: refName, switchRef: switchRef)) + } + + public func switchRef(cwd: String, refName: String) async throws -> VcsSwitchRefResult { + try await call("vcs.switchRef", VcsSwitchRefInput(cwd: cwd, refName: refName)) + } + + public func pull(cwd: String) async throws -> VcsPullResult { + try await call("vcs.pull", VcsCwdInput(cwd: cwd)) + } + + /// Streamed commit/push/PR pipeline. + public func runStackedAction( + cwd: String, action: GitStackedAction, commitMessage: String? = nil, + featureBranch: Bool? = nil + ) -> AsyncThrowingStream { + streamCall( + "git.runStackedAction", + GitRunStackedActionInput( + actionId: UUID().uuidString, cwd: cwd, action: action, + commitMessage: commitMessage, featureBranch: featureBranch)) + } +} diff --git a/apps/mac/Sources/T3Kit/WireCoding.swift b/apps/mac/Sources/T3Kit/WireCoding.swift new file mode 100644 index 00000000000..be1914f39ac --- /dev/null +++ b/apps/mac/Sources/T3Kit/WireCoding.swift @@ -0,0 +1,81 @@ +// WireCoding.swift +// Shared codec configuration plus the reusable optionality/default helpers +// that model files rely on: `Schema.Option` (§5.4) and `withDecodingDefault` +// (§5.3) fields. + +import Foundation + +public enum WireCoding { + /// No key strategy mutation, no pretty printing — compact one-object (or + /// one-array) JSON per WS text frame (§2.1). + public static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + return encoder + }() + + /// Default decoder. Dates stay `String` at the wire layer (§5.1); Option + /// (`OptionBox`) and default-injecting fields are handled per-type. + public static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + return decoder + }() + + /// Encode one value as the UTF-8 text payload of a single WebSocket text + /// frame (`JSON.stringify` equivalent, §2.1). `JSONEncoder` always emits + /// valid UTF-8, so this only throws on the encode step itself. + public static func encodeFrameString(_ value: T) throws -> String { + let data = try encoder.encode(value) + return String(decoding: data, as: UTF8.self) + } +} + +/// Decodes/encodes `Schema.Option` = `{_tag:"None"}` | `{_tag:"Some",value}` +/// (§5.4) — a tagged object, distinct from an absent key or a bare `null`. +public struct OptionBox: Codable, Sendable { + public let value: Wrapped? + + public init(_ value: Wrapped?) { + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case _tag = "_tag" + case value + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let tag = try container.decode(String.self, forKey: ._tag) + switch tag { + case "Some": + value = try container.decode(Wrapped.self, forKey: .value) + case "None": + value = nil + default: + throw DecodingError.dataCorruptedError( + forKey: ._tag, + in: container, + debugDescription: "Unknown Option _tag: \(tag)" + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let value { + try container.encode("Some", forKey: ._tag) + try container.encode(value, forKey: .value) + } else { + try container.encode("None", forKey: ._tag) + } + } +} + +extension KeyedDecodingContainer { + /// `withDecodingDefault` support (§5.3): inject a default when the key is + /// absent, e.g. `runtimeMode` (dflt `"full-access"`), `interactionMode` + /// (dflt `"default"`), `archivedAt` (dflt `nil`), `proposedPlans` (dflt `[]`). + public func decode(_ type: T.Type, forKey key: Key, default defaultValue: T) throws -> T { + try decodeIfPresent(type, forKey: key) ?? defaultValue + } +} diff --git a/apps/mac/Sources/T3Kit/WireEnvelope.swift b/apps/mac/Sources/T3Kit/WireEnvelope.swift new file mode 100644 index 00000000000..83fe2be9171 --- /dev/null +++ b/apps/mac/Sources/T3Kit/WireEnvelope.swift @@ -0,0 +1,168 @@ +// WireEnvelope.swift +// The Effect-RPC framing layer (§2): outbound C→S and inbound S→C envelope +// Codables, plus batch-array/single frame decoding (§2.1). Payload/value/error +// stay JSONValue here so this layer is method-agnostic; the method layer +// decodes those into typed models via JSONValue.decode(as:using:). + +import Foundation + +/// `FromClientEncoded` (§2.2). Each case encodes to one JSON object +/// discriminated by `_tag` — exactly one WS text frame's worth of JSON. +public enum ClientFrame: Encodable, Sendable { + /// `headers` is an array of `[key, value]` string pairs, not a JSON + /// object (§2.2); normally `[]`. `payload` must already be schema-encoded. + case request(id: String, tag: String, payload: JSONValue, headers: [[String]], traceId: String? = nil) + /// Acknowledge one streamed `Chunk`, enabling the next (§2.3, mandatory). + case ack(requestId: String) + /// Cancel an in-flight request / unsubscribe from a stream (§2.4). + case interrupt(requestId: String) + /// Liveness heartbeat sent every 5s (§1.4). + case ping + /// Client is done sending; rarely needed (§2.2). + case eof + + private enum CodingKeys: String, CodingKey { + case _tag = "_tag" + case id, tag, payload, headers, traceId, requestId + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .request(id, tag, payload, headers, traceId): + try container.encode("Request", forKey: ._tag) + try container.encode(id, forKey: .id) + try container.encode(tag, forKey: .tag) + try container.encode(payload, forKey: .payload) + try container.encode(headers, forKey: .headers) + try container.encodeIfPresent(traceId, forKey: .traceId) + case let .ack(requestId): + try container.encode("Ack", forKey: ._tag) + try container.encode(requestId, forKey: .requestId) + case let .interrupt(requestId): + try container.encode("Interrupt", forKey: ._tag) + try container.encode(requestId, forKey: .requestId) + case .ping: + try container.encode("Ping", forKey: ._tag) + case .eof: + try container.encode("Eof", forKey: ._tag) + } + } +} + +/// Terminal result of a request (§2.2): `Exit.Success` carries the encoded +/// success value; `Exit.Failure` carries a `Cause` array decoded into +/// `RpcFailure`. +public enum ExitResult: Decodable, Sendable { + case success(value: JSONValue) + case failure(RpcFailure) + + private enum CodingKeys: String, CodingKey { + case _tag = "_tag" + case value, cause + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let tag = try container.decode(String.self, forKey: ._tag) + switch tag { + case "Success": + // Void-success RPCs (§3: "(none — void)") omit `value` entirely. + let value = try container.decodeIfPresent(JSONValue.self, forKey: .value) ?? .null + self = .success(value: value) + case "Failure": + let failure = try container.decode(RpcFailure.self, forKey: .cause) + self = .failure(failure) + default: + throw DecodingError.dataCorruptedError( + forKey: ._tag, + in: container, + debugDescription: "Unknown Exit _tag: \(tag)" + ) + } + } +} + +/// `FromServerEncoded` (§2.2), discriminated by `_tag`. +public enum ServerFrame: Decodable, Sendable { + /// One batch of stream values for a streaming RPC. `values` is non-empty + /// per spec; the caller MUST send an `Ack` for `requestId` after handling + /// it (§2.3 — mandatory, or the stream stalls). + case chunk(requestId: String, values: [JSONValue]) + case exit(requestId: String, exit: ExitResult) + /// Connection-level defect, not tied to one request; kills all in-flight + /// requests (§2.2). + case defect(defect: JSONValue) + /// Liveness reply to a client `Ping` (§1.4). + case pong + /// Server signals the client connection ended; safe to ignore (§2.2). + /// Note: this is a decoded-`FromServer`-only tag in the reference client + /// (`RpcMessage.ts:206-210`) and is never actually seen on the wire — the + /// transport union instead sends `ClientProtocolError` + /// (`RpcMessage.ts:218-223`). Kept for forward compatibility; harmless if + /// it never arrives. + case clientEnd(clientId: Int?) + /// Connection-level protocol error reported by the server transport + /// (bad/duplicate/oversized frame, serialization error affecting this + /// client). Like `Defect`, this kills every in-flight request/stream on + /// this connection — the reference client does the same + /// (`RpcClient.ts:780-786`). + case clientProtocolError(error: JSONValue) + /// Any `_tag` this codec doesn't recognize (forward compatibility, §6 risk 1). + case unknown(tag: String) + + private enum CodingKeys: String, CodingKey { + case _tag = "_tag" + case requestId, values, exit, defect, clientId, error + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let tag = try container.decode(String.self, forKey: ._tag) + switch tag { + case "Chunk": + let requestId = try container.decode(String.self, forKey: .requestId) + let values = try container.decode([JSONValue].self, forKey: .values) + self = .chunk(requestId: requestId, values: values) + case "Exit": + let requestId = try container.decode(String.self, forKey: .requestId) + let exit = try container.decode(ExitResult.self, forKey: .exit) + self = .exit(requestId: requestId, exit: exit) + case "Defect": + let defect = try container.decode(JSONValue.self, forKey: .defect) + self = .defect(defect: defect) + case "Pong": + self = .pong + case "ClientEnd": + let clientId = try container.decodeIfPresent(Int.self, forKey: .clientId) + self = .clientEnd(clientId: clientId) + case "ClientProtocolError": + let error = try container.decode(JSONValue.self, forKey: .error) + self = .clientProtocolError(error: error) + default: + self = .unknown(tag: tag) + } + } +} + +/// Decode one WS text frame that may be a single JSON object or a JSON array +/// batch (§2.1): `JSON.parse(frameText)` — array means a batch of messages, +/// otherwise a single message. +public enum FrameBatch { + public static func decode(_ text: String) throws -> [ServerFrame] { + guard let data = text.data(using: .utf8) else { + throw T3Error.decoding("Frame text is not valid UTF-8") + } + let isBatch = text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("[") + do { + if isBatch { + return try WireCoding.decoder.decode([ServerFrame].self, from: data) + } else { + let frame = try WireCoding.decoder.decode(ServerFrame.self, from: data) + return [frame] + } + } catch let error as DecodingError { + throw T3Error.decoding(String(describing: error)) + } + } +} diff --git a/apps/mac/Support/Info.plist b/apps/mac/Support/Info.plist new file mode 100644 index 00000000000..5ea97b8d7b3 --- /dev/null +++ b/apps/mac/Support/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleName + SergeCode + CFBundleDisplayName + SergeCode + CFBundleIdentifier + com.sergeserb.sergecode + CFBundleVersion + 0.0.1 + CFBundleShortVersionString + 0.0.1 + CFBundleExecutable + SergeCodeMac + CFBundlePackageType + APPL + LSMinimumSystemVersion + 26.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + LSApplicationCategoryType + public.app-category.developer-tools + + diff --git a/apps/mac/Tests/SidecarKitTests/BackoffScheduleTests.swift b/apps/mac/Tests/SidecarKitTests/BackoffScheduleTests.swift new file mode 100644 index 00000000000..f88ad8a263a --- /dev/null +++ b/apps/mac/Tests/SidecarKitTests/BackoffScheduleTests.swift @@ -0,0 +1,33 @@ +import Testing + +@testable import SidecarKit + +@Suite("Restart backoff schedule") +struct BackoffScheduleTests { + // Mirrors DesktopBackendManager.ts's calculateRestartDelay: + // Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY) + // with INITIAL_RESTART_DELAY = 500ms and MAX_RESTART_DELAY = 10s. + @Test( + "doubles from 500ms, capping at 10s", + arguments: [ + (0, 0.5), + (1, 1.0), + (2, 2.0), + (3, 4.0), + (4, 8.0), + (5, 10.0), + (6, 10.0), + (20, 10.0), + ] + ) + func schedule(attempt: Int, expectedSeconds: Double) { + let delay = ServerProcess.backoffDelay(forAttempt: attempt) + #expect(abs(delay - expectedSeconds) < 0.0001) + } + + @Test("never returns a negative delay for a negative attempt") + func clampsNegativeAttempt() { + let delay = ServerProcess.backoffDelay(forAttempt: -1) + #expect(delay == 0.5) + } +} diff --git a/apps/mac/Tests/SidecarKitTests/BootstrapEnvelopeTests.swift b/apps/mac/Tests/SidecarKitTests/BootstrapEnvelopeTests.swift new file mode 100644 index 00000000000..8ac5611d448 --- /dev/null +++ b/apps/mac/Tests/SidecarKitTests/BootstrapEnvelopeTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +@testable import SidecarKit + +@Suite("BootstrapEnvelope") +struct BootstrapEnvelopeTests { + // Mirrors packages/contracts/src/desktopBootstrap.ts `DesktopBackendBootstrap`, + // as it would be serialized by the TS side's + // `Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))`. + static let fixtureJSON = """ + {"mode":"desktop","noBrowser":true,"port":3773,"t3Home":"/Users/test/Library/Application Support/SergeCode","host":"127.0.0.1","desktopBootstrapToken":"abc123token","tailscaleServeEnabled":false,"tailscaleServePort":443} + """ + + @Test("decodes the TS fixture field-for-field") + func decodesFixture() throws { + let envelope = try JSONDecoder().decode( + BootstrapEnvelope.self, from: Data(Self.fixtureJSON.utf8)) + + #expect(envelope.mode == "desktop") + #expect(envelope.noBrowser == true) + #expect(envelope.port == 3773) + #expect(envelope.t3Home == "/Users/test/Library/Application Support/SergeCode") + #expect(envelope.host == "127.0.0.1") + #expect(envelope.desktopBootstrapToken == "abc123token") + #expect(envelope.tailscaleServeEnabled == false) + #expect(envelope.tailscaleServePort == 443) + #expect(envelope.otlpTracesUrl == nil) + #expect(envelope.otlpMetricsUrl == nil) + } + + @Test("round trips through encode then decode") + func roundTrips() throws { + let original = BootstrapEnvelope( + port: 4000, + t3Home: "/tmp/base", + host: "127.0.0.1", + desktopBootstrapToken: "token-value", + noBrowser: true, + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + otlpTracesUrl: "http://localhost:4318/traces", + otlpMetricsUrl: nil + ) + + let line = try original.encodeLine() + let decoded = try JSONDecoder().decode(BootstrapEnvelope.self, from: Data(line.utf8)) + #expect(decoded == original) + } + + @Test("omits optional fields when nil, matching TS Schema.optional") + func omitsNilOptionals() throws { + let envelope = BootstrapEnvelope( + port: 3773, + host: "127.0.0.1", + desktopBootstrapToken: "tok" + ) + + let line = try envelope.encodeLine() + let object = try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] + + #expect(object?["t3Home"] == nil) + #expect(object?["otlpTracesUrl"] == nil) + #expect(object?["otlpMetricsUrl"] == nil) + #expect(object?["mode"] as? String == "desktop") + #expect(object?["port"] as? Int == 3773) + } + + @Test("generates a 32+ byte, base64url-safe token") + func generatesSecureToken() { + let token = BootstrapTokenGenerator.generate() + + #expect(!token.contains("+")) + #expect(!token.contains("/")) + #expect(!token.contains("=")) + // 32 raw bytes base64-encoded (no padding) is 43 characters. + #expect(token.count >= 43) + + let other = BootstrapTokenGenerator.generate() + #expect(token != other) + } +} diff --git a/apps/mac/Tests/SidecarKitTests/FreePortPickerTests.swift b/apps/mac/Tests/SidecarKitTests/FreePortPickerTests.swift new file mode 100644 index 00000000000..2a960e5144a --- /dev/null +++ b/apps/mac/Tests/SidecarKitTests/FreePortPickerTests.swift @@ -0,0 +1,30 @@ +import Darwin +import Testing + +@testable import SidecarKit + +@Suite("Free port picker") +struct FreePortPickerTests { + @Test("returns a port that can actually be bound") + func returnsBindablePort() throws { + let port = try FreePortPicker.pick() + #expect(port > 0) + #expect(port < 65536) + + let fd = socket(AF_INET, SOCK_STREAM, 0) + #expect(fd >= 0) + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = UInt16(port).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + + let bindResult = withUnsafePointer(to: &addr) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + #expect(bindResult == 0) + } +} diff --git a/apps/mac/Tests/SidecarKitTests/NodeVersionPredicateTests.swift b/apps/mac/Tests/SidecarKitTests/NodeVersionPredicateTests.swift new file mode 100644 index 00000000000..a51241df58a --- /dev/null +++ b/apps/mac/Tests/SidecarKitTests/NodeVersionPredicateTests.swift @@ -0,0 +1,45 @@ +import Testing + +@testable import SidecarKit + +@Suite("Node version predicate") +struct NodeVersionPredicateTests { + // engines.node from apps/server/package.json: "^22.16 || ^23.11 || >=24.10" + @Test( + "satisfies the server's engines range", + arguments: [ + ("v21.9.0", false), + ("v20.0.0", false), + ("v22.15.9", false), + ("v22.16.0", true), + ("v22.20.3", true), + ("v23.10.9", false), + ("v23.11.0", true), + ("v23.99.0", true), + ("v24.9.9", false), + ("v24.10.0", true), + ("v24.10.1", true), + ("v25.0.0", true), + ("v30.0.0", true), + ] + ) + func versionSatisfies(raw: String, expected: Bool) { + #expect(NodeRuntimeLocator.versionSatisfies(raw) == expected) + } + + @Test("parses semantic version strings, dropping the leading v and any metadata") + func parsesVersionString() { + #expect(SemanticVersion(parsing: "v22.16.0") == SemanticVersion(major: 22, minor: 16, patch: 0)) + #expect(SemanticVersion(parsing: "24.10") == SemanticVersion(major: 24, minor: 10, patch: 0)) + #expect( + SemanticVersion(parsing: "v23.11.2-rc.1") + == SemanticVersion(major: 23, minor: 11, patch: 2)) + } + + @Test("rejects malformed version strings") + func rejectsMalformed() { + #expect(SemanticVersion(parsing: "not-a-version") == nil) + #expect(SemanticVersion(parsing: "") == nil) + #expect(SemanticVersion(parsing: "v22") == nil) + } +} diff --git a/apps/mac/Tests/SidecarKitTests/SidecarConfigTests.swift b/apps/mac/Tests/SidecarKitTests/SidecarConfigTests.swift new file mode 100644 index 00000000000..d8ff4952829 --- /dev/null +++ b/apps/mac/Tests/SidecarKitTests/SidecarConfigTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing + +@testable import SidecarKit + +@Suite("SidecarConfig") +struct SidecarConfigTests { + @Test("dev default entry path resolves to apps/server/dist/bin.mjs under the repo root") + func devDefaultEntryPath() { + // Simulate this source file's real on-disk location so the test + // doesn't depend on where SidecarKitTests happens to be checked out. + let fakeSourceFile = "/Users/dev/SergeCode/apps/mac/Sources/SidecarKit/SidecarConfig.swift" + let entryPath = SidecarEntryPathResolver.devDefaultEntryPath(sourceFile: fakeSourceFile) + #expect(entryPath == "/Users/dev/SergeCode/apps/server/dist/bin.mjs") + } + + @Test("default base dir lives under Application Support/SergeCode") + func defaultBaseDir() { + let baseDir = SidecarConfig.defaultBaseDir() + #expect(baseDir.hasSuffix("/Application Support/SergeCode")) + } + + @Test("an explicit port is honored without probing for a free one") + func explicitPortIsHonored() throws { + let config = try SidecarConfig(nodePath: "/usr/local/bin/node", port: 54321, baseDir: "/tmp/sergecode-test") + #expect(config.port == 54321) + #expect(config.logDirectory == "/tmp/sergecode-test/logs/sidecar") + } +} diff --git a/apps/mac/Tests/SidecarKitTests/SidecarKitTests.swift b/apps/mac/Tests/SidecarKitTests/SidecarKitTests.swift new file mode 100644 index 00000000000..19785580355 --- /dev/null +++ b/apps/mac/Tests/SidecarKitTests/SidecarKitTests.swift @@ -0,0 +1,2 @@ +import Testing +@testable import SidecarKit diff --git a/apps/mac/Tests/T3KitTests/ActivityPayloadTests.swift b/apps/mac/Tests/T3KitTests/ActivityPayloadTests.swift new file mode 100644 index 00000000000..503561d8cfe --- /dev/null +++ b/apps/mac/Tests/T3KitTests/ActivityPayloadTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing + +@testable import T3Kit + +// Fixtures mirror the server's ProviderRuntimeIngestion.ts projections and +// packages/contracts/src/providerRuntime.ts shapes. + +@Suite("Typed activity payloads") +struct ActivityPayloadTests { + private func activity(kind: String, payloadJSON: String) throws -> OrchestrationThreadActivity { + let json = """ + { + "id": "act-1", + "tone": "info", + "kind": "\(kind)", + "summary": "s", + "payload": \(payloadJSON), + "createdAt": "2026-07-04T12:00:00.000Z" + } + """ + return try WireCoding.decoder.decode( + OrchestrationThreadActivity.self, from: Data(json.utf8)) + } + + @Test("user-input.requested decodes questions with options and multiSelect default") + func userInputRequested() throws { + let activity = try activity( + kind: ActivityKind.userInputRequested, + payloadJSON: """ + { + "requestId": "req-9", + "questions": [ + { + "id": "q1", + "header": "Sort", + "question": "Which order?", + "options": [ + {"label": "A", "description": "first"}, + {"label": "B"} + ] + }, + { + "id": "q2", + "header": "Multi", + "question": "Pick many", + "options": [], + "multiSelect": true + } + ] + } + """) + let payload = try #require(activity.decodePayload(UserInputRequestedActivityPayload.self)) + #expect(payload.requestId == "req-9") + #expect(payload.questions.count == 2) + #expect(payload.questions[0].multiSelect == false) + #expect(payload.questions[0].options.count == 2) + #expect(payload.questions[0].options[1].description == nil) + #expect(payload.questions[1].multiSelect == true) + } + + @Test("user-input.resolved decodes with and without requestId") + func userInputResolved() throws { + let with = try activity( + kind: ActivityKind.userInputResolved, + payloadJSON: #"{"requestId": "req-9", "answers": {"q1": "A"}}"#) + #expect(with.decodePayload(UserInputResolvedActivityPayload.self)?.requestId == "req-9") + + let without = try activity( + kind: ActivityKind.userInputResolved, payloadJSON: #"{"answers": {}}"#) + let payload = try #require(without.decodePayload(UserInputResolvedActivityPayload.self)) + #expect(payload.requestId == nil) + } + + @Test("turn.plan.updated decodes steps, tolerating unknown statuses") + func turnPlanUpdated() throws { + let activity = try activity( + kind: ActivityKind.turnPlanUpdated, + payloadJSON: """ + { + "plan": [ + {"step": "one", "status": "completed"}, + {"step": "two", "status": "inProgress"}, + {"step": "three"}, + {"step": "four", "status": "some-future-status"} + ], + "explanation": "why" + } + """) + let payload = try #require(activity.decodePayload(TurnPlanUpdatedActivityPayload.self)) + #expect(payload.plan.count == 4) + #expect(payload.plan[0].status == .completed) + #expect(payload.plan[1].status == .inProgress) + #expect(payload.plan[2].status == nil) + #expect(payload.plan[3].status == nil) + #expect(payload.explanation == "why") + } + + @Test("context-window.updated decodes the meter fields") + func contextWindowUpdated() throws { + let activity = try activity( + kind: ActivityKind.contextWindowUpdated, + payloadJSON: """ + { + "usedTokens": 72000, + "maxTokens": 200000, + "inputTokens": 60000, + "outputTokens": 12000, + "compactsAutomatically": true, + "toolUses": 14 + } + """) + let payload = try #require(activity.decodePayload(ContextWindowUpdatedActivityPayload.self)) + #expect(payload.usedTokens == 72000) + #expect(payload.maxTokens == 200000) + #expect(payload.compactsAutomatically == true) + } + + @Test("context-window.updated without maxTokens still decodes") + func contextWindowNoMax() throws { + let activity = try activity( + kind: ActivityKind.contextWindowUpdated, payloadJSON: #"{"usedTokens": 10}"#) + let payload = try #require(activity.decodePayload(ContextWindowUpdatedActivityPayload.self)) + #expect(payload.usedTokens == 10) + #expect(payload.maxTokens == nil) + } + + @Test("mismatched payload degrades to nil instead of throwing") + func mismatchedPayload() throws { + let activity = try activity( + kind: ActivityKind.contextWindowUpdated, payloadJSON: #"{"unexpected": true}"#) + #expect(activity.decodePayload(ContextWindowUpdatedActivityPayload.self) == nil) + } + + @Test("mode-set command wrappers encode wire tags and modes") + func modeSetCommands() throws { + let runtime = ClientOrchestrationCommand.threadRuntimeModeSet( + ThreadRuntimeModeSetCommand( + commandId: "c1", threadId: "t1", runtimeMode: .approvalRequired, + createdAt: "2026-07-04T12:00:00.000Z")) + let runtimeJSON = try WireCoding.encoder.encode(runtime) + let runtimeObject = + try JSONSerialization.jsonObject(with: runtimeJSON) as? [String: Any] ?? [:] + #expect(runtimeObject["type"] as? String == "thread.runtime-mode.set") + #expect(runtimeObject["runtimeMode"] as? String == "approval-required") + + let interaction = ClientOrchestrationCommand.threadInteractionModeSet( + ThreadInteractionModeSetCommand( + commandId: "c2", threadId: "t1", interactionMode: .plan, + createdAt: "2026-07-04T12:00:00.000Z")) + let interactionJSON = try WireCoding.encoder.encode(interaction) + let interactionObject = + try JSONSerialization.jsonObject(with: interactionJSON) as? [String: Any] ?? [:] + #expect(interactionObject["type"] as? String == "thread.interaction-mode.set") + #expect(interactionObject["interactionMode"] as? String == "plan") + } + + @Test("turn.start encodes sourceProposedPlan when present, omits when nil") + func turnStartSourcePlan() throws { + let command = ClientOrchestrationCommand.threadTurnStart( + ThreadTurnStartCommand( + commandId: "c3", threadId: "t1", + message: ChatMessageInput(messageId: "m1", text: "go"), + sourceProposedPlan: SourceProposedPlanReference(threadId: "t1", planId: "p1"), + createdAt: "2026-07-04T12:00:00.000Z")) + let json = try WireCoding.encoder.encode(command) + let object = try JSONSerialization.jsonObject(with: json) as? [String: Any] ?? [:] + let plan = object["sourceProposedPlan"] as? [String: Any] + #expect(plan?["planId"] as? String == "p1") + + let bare = ClientOrchestrationCommand.threadTurnStart( + ThreadTurnStartCommand( + commandId: "c4", threadId: "t1", + message: ChatMessageInput(messageId: "m2", text: "go"), + createdAt: "2026-07-04T12:00:00.000Z")) + let bareJSON = try WireCoding.encoder.encode(bare) + let bareObject = try JSONSerialization.jsonObject(with: bareJSON) as? [String: Any] ?? [:] + #expect(bareObject["sourceProposedPlan"] == nil) + } +} diff --git a/apps/mac/Tests/T3KitTests/FixtureFrames.swift b/apps/mac/Tests/T3KitTests/FixtureFrames.swift new file mode 100644 index 00000000000..ba595f9b8c7 --- /dev/null +++ b/apps/mac/Tests/T3KitTests/FixtureFrames.swift @@ -0,0 +1,161 @@ +// FixtureFrames.swift +// Plain string constants — NOT a bundled resource — reproducing the canonical +// wire frames from docs/wire-protocol.md Appendix A, plus a handful of +// additional frames needed to exercise edges (Die/Interrupt causes, batches, +// no-value encodings, non-`_tag` discriminators) that the appendix only +// describes in prose. Every constant below cites the doc section it mirrors. + +enum FixtureFrames { + + // MARK: - §Appendix A — canonical example frames + + /// C→S invoke `server.getConfig` (id 0). + static let requestGetConfig = #""" + {"_tag":"Request","id":"0","tag":"server.getConfig","payload":{},"headers":[]} + """# + + /// S→C success exit for the above. + static let exitGetConfigSuccess = #""" + {"_tag":"Exit","requestId":"0","exit":{"_tag":"Success","value":{"cwd":"/tmp"}}} + """# + + /// C→S subscribe to a thread (streaming; id 1). + static let requestSubscribeThread = #""" + {"_tag":"Request","id":"1","tag":"orchestration.subscribeThread","payload":{"threadId":"th_123"},"headers":[]} + """# + + /// S→C first chunk = snapshot. + static let chunkSubscribeThreadSnapshot = #""" + {"_tag":"Chunk","requestId":"1","values":[{"kind":"snapshot","snapshot":{"snapshotSequence":100,"thread":{"id":"th_123"}}}]} + """# + + /// C→S must ack the snapshot chunk to receive more. + static let ackSubscribeThreadSnapshot = #""" + {"_tag":"Ack","requestId":"1"} + """# + + /// S→C live event chunk. + static let chunkSubscribeThreadEvent = #""" + {"_tag":"Chunk","requestId":"1","values":[{"kind":"event","event":{"sequence":101,"type":"thread.message-sent","aggregateKind":"thread","aggregateId":"th_123","occurredAt":"2026-07-04T12:00:00.000Z","eventId":"ev_9","commandId":null,"causationEventId":null,"correlationId":null,"metadata":{},"payload":{}}}]} + """# + + /// C→S ack for the live event chunk. + static let ackSubscribeThreadEvent = #""" + {"_tag":"Ack","requestId":"1"} + """# + + /// Heartbeat: client ping. + static let ping = #""" + {"_tag":"Ping"} + """# + + /// Heartbeat: server pong. + static let pong = #""" + {"_tag":"Pong"} + """# + + /// C→S start a turn via `dispatchCommand` (id 2). + static let requestDispatchTurnStart = #""" + {"_tag":"Request","id":"2","tag":"orchestration.dispatchCommand","headers":[],"payload":{"type":"thread.turn.start","commandId":"cmd_1","threadId":"th_123","message":{"messageId":"m_1","role":"user","text":"hi","attachments":[]},"runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-07-04T12:00:01.000Z"}} + """# + + /// S→C success exit for the dispatched command. + static let exitDispatchTurnStartSuccess = #""" + {"_tag":"Exit","requestId":"2","exit":{"_tag":"Success","value":{"sequence":102}}} + """# + + /// C→S cancel the subscription. + static let interruptSubscribeThread = #""" + {"_tag":"Interrupt","requestId":"1"} + """# + + /// C→S "done sending" (rarely used). + static let eof = #""" + {"_tag":"Eof"} + """# + + /// S→C failure exit — missing scope (§1.2, §5.6). + static let exitAuthorizationFailure = #""" + {"_tag":"Exit","requestId":"3","exit":{"_tag":"Failure","cause":[{"_tag":"Fail","error":{"_tag":"EnvironmentAuthorizationError","message":"The authenticated token is missing required scope: orchestration:operate","requiredScope":"orchestration:operate"}}]}} + """# + + // MARK: - Additional Exit.Failure cause shapes (§2.2, §5.6) + + /// Unexpected server crash: `Die` cause with an opaque defect. + static let exitDieFailure = #""" + {"_tag":"Exit","requestId":"4","exit":{"_tag":"Failure","cause":[{"_tag":"Die","defect":{"name":"TypeError","message":"boom"}}]}} + """# + + /// Interrupted fiber, `fiberId` present. + static let exitInterruptFailureWithFiberId = #""" + {"_tag":"Exit","requestId":"5","exit":{"_tag":"Failure","cause":[{"_tag":"Interrupt","fiberId":12}]}} + """# + + /// Interrupted fiber, `fiberId` absent (§2.2: "may be undefined/absent"). + static let exitInterruptFailureWithoutFiberId = #""" + {"_tag":"Exit","requestId":"6","exit":{"_tag":"Failure","cause":[{"_tag":"Interrupt"}]}} + """# + + // MARK: - Connection-level frames (§2.2) + + static let defect = #""" + {"_tag":"Defect","defect":{"name":"Error","message":"connection lost"}} + """# + + static let clientEndWithId = #""" + {"_tag":"ClientEnd","clientId":5} + """# + + static let clientEndWithoutId = #""" + {"_tag":"ClientEnd"} + """# + + static let unknownServerTag = #""" + {"_tag":"SomeFutureMessage","foo":"bar"} + """# + + // MARK: - §2.1 batching — one WS frame may be a JSON array of messages + + static let batchOfTwoServerFrames = #""" + [{"_tag":"Pong"},{"_tag":"Chunk","requestId":"1","values":[{"kind":"snapshot","snapshot":{}}]}] + """# + + // MARK: - §5.3/§5.4 no-value encoding matrix + + /// `Schema.optional` — key entirely absent. + static let optionalKeyAbsent = #""" + {"present":"x"} + """# + + /// `Schema.NullOr` — key present, value explicit JSON null. + static let nullOrKeyPresentNull = #""" + {"present":"x","branch":null} + """# + + /// `Schema.Option` — tagged object, the "Some" case. + static let optionSome = #""" + {"_tag":"Some","value":"origin/main"} + """# + + /// `Schema.Option` — tagged object, the "None" case. + static let optionNone = #""" + {"_tag":"None"} + """# + + // MARK: - §5.5 discriminator variants (`_tag` vs `type` vs `kind`) + + /// Envelope-style union discriminated by `_tag` (protocol frames, tagged errors). + static let discriminatorByTag = #""" + {"_tag":"EnvironmentAuthorizationError","message":"nope","requiredScope":"access:read"} + """# + + /// OrchestrationEvent-style union discriminated by `type` (orchestration.ts:1001+). + static let discriminatorByType = #""" + {"sequence":7,"type":"thread.archived","aggregateKind":"thread","aggregateId":"th_1"} + """# + + /// Orchestration stream-item-style union discriminated by `kind` (orchestration.ts:421-452, :1115-1124). + static let discriminatorByKind = #""" + {"kind":"thread-upserted","sequence":9,"threadId":"th_1"} + """# +} diff --git a/apps/mac/Tests/T3KitTests/JSONValueTests.swift b/apps/mac/Tests/T3KitTests/JSONValueTests.swift new file mode 100644 index 00000000000..7c7154956b8 --- /dev/null +++ b/apps/mac/Tests/T3KitTests/JSONValueTests.swift @@ -0,0 +1,93 @@ +// JSONValueTests.swift +// Round-trip + accessor coverage for the free-form JSON value used for +// opaque/untyped fields (§5.4, §5.6, §11 of docs/wire-protocol.md). + +import Testing +@testable import T3Kit +import Foundation + +@Suite("JSONValue") +struct JSONValueTests { + + @Test("decodes every primitive JSON shape") + func decodesPrimitives() throws { + let text = #""" + { + "n": null, + "b": true, + "i": 42, + "d": 3.5, + "s": "hi", + "a": [1, "two", null], + "o": {"k": "v"} + } + """# + let value = try WireCoding.decoder.decode(JSONValue.self, from: Data(text.utf8)) + #expect(value["n"] == .null) + #expect(value["b"] == .bool(true)) + #expect(value["i"]?.intValue == 42) + #expect(value["d"] == .double(3.5)) + #expect(value["s"]?.stringValue == "hi") + #expect(value["a"]?.arrayValue?.count == 3) + #expect(value["a"]?.arrayValue?[1] == .string("two")) + #expect(value["o"]?.objectValue?["k"] == .string("v")) + } + + @Test("encode(to:) round-trips through decode for every case") + func encodeDecodeRoundTrip() throws { + let original = JSONValue.object([ + "null": .null, + "bool": .bool(false), + "int": .int(-7), + "double": .double(1.25), + "string": .string("café"), + "array": .array([.int(1), .int(2), .int(3)]), + ]) + let data = try WireCoding.encoder.encode(original) + let decoded = try WireCoding.decoder.decode(JSONValue.self, from: data) + #expect(decoded == original) + } + + @Test("subscript returns nil for non-object values and missing keys") + func subscriptOnNonObject() { + let array = JSONValue.array([.int(1)]) + #expect(array["missing"] == nil) + + let object = JSONValue.object(["present": .int(1)]) + #expect(object["absent"] == nil) + #expect(object["present"] == .int(1)) + } + + @Test("typed accessors return nil for mismatched cases") + func typedAccessorsMismatch() { + let string = JSONValue.string("x") + #expect(string.stringValue == "x") + #expect(string.intValue == nil) + #expect(string.arrayValue == nil) + #expect(string.objectValue == nil) + + let int = JSONValue.int(5) + #expect(int.intValue == 5) + #expect(int.stringValue == nil) + } + + @Test("decode(as:using:) re-decodes a subtree into a typed model") + func decodeSubtreeIntoTypedModel() throws { + struct Snapshot: Decodable, Equatable { + let snapshotSequence: Int + } + let value = JSONValue.object([ + "kind": .string("snapshot"), + "snapshot": .object(["snapshotSequence": .int(100)]), + ]) + let snapshot = try value["snapshot"]!.decode(as: Snapshot.self, using: WireCoding.decoder) + #expect(snapshot == Snapshot(snapshotSequence: 100)) + } + + @Test("object values compare equal regardless of key insertion order") + func objectEqualityIgnoresKeyOrder() { + let a = JSONValue.object(["x": .int(1), "y": .int(2)]) + let b = JSONValue.object(["y": .int(2), "x": .int(1)]) + #expect(a == b) + } +} diff --git a/apps/mac/Tests/T3KitTests/LiveIntegrationTests.swift b/apps/mac/Tests/T3KitTests/LiveIntegrationTests.swift new file mode 100644 index 00000000000..d5060e36b20 --- /dev/null +++ b/apps/mac/Tests/T3KitTests/LiveIntegrationTests.swift @@ -0,0 +1,491 @@ +// LiveIntegrationTests.swift +// Live, protocol-level end-to-end check of the full stack: a real `node +// dist/bin.mjs` sidecar process, the real desktop-managed-local HTTP auth +// handshake, a real WebSocket RPC connection, real subscriptions, and a real +// dispatchCommand write path (project + thread creation) persisted to SQLite. +// +// Gated behind `SERGECODE_LIVE_E2E=1` (opt-in): it spawns a real child +// process and hits real HTTP/WS endpoints, which is too slow/heavy for the +// default `swift test` run. +// +// Why this file doesn't `import SidecarKit`: this test target (T3KitTests) +// only depends on T3Kit in Package.swift, and apps/mac/CLAUDE.md hard-rules +// against editing Package.swift ("target layout is fixed") without +// coordinating. Rather than widen that target's dependencies, the handful of +// SidecarKit responsibilities this test needs — bootstrap envelope encoding, +// spawning the node child, stdin handoff, readiness polling, graceful +// shutdown, free-port selection — are reproduced here in miniature, +// deliberately mirroring SidecarKit.ServerProcess / BootstrapEnvelope / +// NodeRuntimeLocator / SidecarConfig (which already have their own unit +// coverage in Tests/SidecarKitTests). This file's job is to validate the +// *wire protocol* end-to-end (T3Kit's AuthClient/RpcConnection/T3Client +// against a live server), not to re-test SidecarKit's supervisor logic. + +import Foundation +import Testing + +@testable import T3Kit + +#if canImport(Darwin) + import Darwin +#endif + +// MARK: - Errors + +private enum LiveE2EError: Error, CustomStringConvertible { + case setupFailed(String) + case timedOut(String) + case streamEnded(String) + + var description: String { + switch self { + case .setupFailed(let message): return "setup failed: \(message)" + case .timedOut(let context): return "timed out waiting for: \(context)" + case .streamEnded(let context): return "stream ended before: \(context)" + } + } +} + +// MARK: - Miniature sidecar launcher (see file header for why this is +// duplicated here instead of importing SidecarKit) + +/// `DesktopBackendBootstrap` mirror (packages/contracts/src/desktopBootstrap.ts), +/// matching SidecarKit.BootstrapEnvelope's wire shape. +private struct TestBootstrapEnvelope: Encodable { + var mode = "desktop" + var noBrowser = true + var port: Int + var t3Home: String? + var host: String + var desktopBootstrapToken: String + var tailscaleServeEnabled = false + var tailscaleServePort = 443 + var otlpTracesUrl: String? + var otlpMetricsUrl: String? +} + +private func makeBootstrapToken() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + bytes.withUnsafeMutableBytes { buffer in + arc4random_buf(buffer.baseAddress, buffer.count) + } + return Data(bytes).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +/// Binds to port 0 on `host` to let the kernel assign a free loopback port, +/// reads it back, then releases the socket (mirrors SidecarKit.FreePortPicker; +/// same inherent TOCTOU tradeoff, acceptable for a local test run). +private func pickFreePort(host: String = "127.0.0.1") throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw LiveE2EError.setupFailed("socket() failed") } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr.s_addr = inet_addr(host) + + let bindResult = withUnsafePointer(to: &addr) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { throw LiveE2EError.setupFailed("bind() failed") } + + var boundAddr = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let getsocknameResult = withUnsafeMutablePointer(to: &boundAddr) { pointer -> Int32 in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + getsockname(fd, sockaddrPointer, &length) + } + } + guard getsocknameResult == 0 else { throw LiveE2EError.setupFailed("getsockname() failed") } + + return Int(UInt16(bigEndian: boundAddr.sin_port)) +} + +/// Default server entry: `apps/server/dist/bin.mjs`, derived from this test +/// file's own location (`.../apps/mac/Tests/T3KitTests/LiveIntegrationTests.swift` +/// -> repo root -> `apps/server/dist/bin.mjs`) rather than a hardcoded +/// machine/user-specific absolute path, so the fallback is portable across +/// clones of this repo. `$SERGECODE_SERVER_ENTRY` still overrides it. +private func defaultServerEntryPath() -> String { + let thisFile = URL(fileURLWithPath: #filePath) + // .../apps/mac/Tests/T3KitTests/LiveIntegrationTests.swift -> apps/mac + let appsMacDir = + thisFile + .deletingLastPathComponent() // T3KitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // apps/mac + return + appsMacDir + .appendingPathComponent("../server/dist/bin.mjs") + .standardizedFileURL + .path +} + +/// Scratch root for the e2e test's throwaway home dir + git repo. Defaults to +/// a directory under the system temp dir (not a hardcoded session-specific +/// path) so the test is portable across machines/sessions/CI; +/// `$SERGECODE_LIVE_E2E_SCRATCH` overrides it. +private func defaultScratchRoot() -> String { + if let override = ProcessInfo.processInfo.environment["SERGECODE_LIVE_E2E_SCRATCH"], + !override.isEmpty + { + return override + } + return NSTemporaryDirectory() + "sergecode-live-e2e" +} + +/// Priority order mirrors SidecarKit.NodeRuntimeLocator: `$SERGECODE_NODE` +/// override, login-shell PATH probe, then common install paths. +private func locateNode() -> String? { + let environment = ProcessInfo.processInfo.environment + var candidates: [String] = [] + if let override = environment["SERGECODE_NODE"], !override.isEmpty { + candidates.append(override) + } + if let shellNode = probeLoginShellNodePath() { + candidates.append(shellNode) + } + let home = environment["HOME"] ?? NSHomeDirectory() + candidates.append(contentsOf: [ + "\(home)/.local/bin/node", + "/opt/homebrew/bin/node", + "/usr/local/bin/node", + ]) + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } +} + +private func probeLoginShellNodePath() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-ilc", "command -v node"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed +} + +/// Spawns `node --mode desktop --bootstrap-fd 0 ...`, writes the +/// bootstrap envelope as one JSON line to stdin, then closes stdin — mirroring +/// SidecarKit.ServerProcess.launch(). Stdout/stderr go to `logDir` for +/// post-mortem debugging. +private func spawnServer( + nodePath: String, entryPath: String, port: Int, host: String, baseDir: String, + bootstrapToken: String, logDir: String +) throws -> Process { + let envelope = TestBootstrapEnvelope( + port: port, t3Home: baseDir, host: host, desktopBootstrapToken: bootstrapToken) + let envelopeData = try JSONEncoder().encode(envelope) + guard let envelopeJSON = String(data: envelopeData, encoding: .utf8) else { + throw LiveE2EError.setupFailed("failed to encode bootstrap envelope as UTF-8") + } + + try FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true) + let stdoutPath = (logDir as NSString).appendingPathComponent("stdout.log") + let stderrPath = (logDir as NSString).appendingPathComponent("stderr.log") + FileManager.default.createFile(atPath: stdoutPath, contents: nil) + FileManager.default.createFile(atPath: stderrPath, contents: nil) + guard let stdout = FileHandle(forWritingAtPath: stdoutPath), + let stderr = FileHandle(forWritingAtPath: stderrPath) + else { + throw LiveE2EError.setupFailed("could not open sidecar log files under \(logDir)") + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: nodePath) + process.arguments = [ + entryPath, + "--mode", "desktop", + "--bootstrap-fd", "0", + "--port", String(port), + "--host", host, + "--base-dir", baseDir, + "--no-browser", + ] + let stdinPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + // Best-effort: if the child already exited, this write throws and is + // swallowed — readiness polling below will simply time out and the test + // fails with the sidecar's own stdout/stderr as context. + try? stdinPipe.fileHandleForWriting.write(contentsOf: Data((envelopeJSON + "\n").utf8)) + stdinPipe.fileHandleForWriting.closeFile() + + return process +} + +/// Polls `GET /.well-known/t3/environment` every 100ms up to `timeout` +/// seconds, mirroring SidecarKit.ServerProcess.pollReadiness. +private func waitForReadiness(host: String, port: Int, timeout: TimeInterval = 60) async -> Bool { + guard let url = URL(string: "http://\(host):\(port)/.well-known/t3/environment") else { + return false + } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + var request = URLRequest(url: url) + request.timeoutInterval = 1 + if let (_, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) + { + return true + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + return false +} + +/// SIGTERM, 2s grace, then SIGKILL — mirrors SidecarKit.ServerProcess.stop. +private func terminateProcess(_ process: Process) async { + guard process.isRunning else { return } + process.terminate() + let deadline = Date().addingTimeInterval(2) + while process.isRunning && Date() < deadline { + try? await Task.sleep(nanoseconds: 50_000_000) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } +} + +private func readLogTail(logDir: String, maxBytes: Int = 4000) -> String { + var combined = "" + for name in ["stdout.log", "stderr.log"] { + let path = (logDir as NSString).appendingPathComponent(name) + guard let data = FileManager.default.contents(atPath: path) else { continue } + let tail = data.suffix(maxBytes) + let text = String(data: tail, encoding: .utf8) ?? "" + combined += "--- \(name) (last \(tail.count) bytes) ---\n\(text)\n" + } + return combined +} + +// MARK: - git helper (throwaway repo for the created project) + +@discardableResult +private func runGit(_ arguments: [String], cwd: String) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let out = String(data: outData, encoding: .utf8) ?? "" + let err = String(data: errData, encoding: .utf8) ?? "" + throw LiveE2EError.setupFailed("git \(arguments.joined(separator: " ")) failed: \(out)\(err)") + } + return String(data: outData, encoding: .utf8) ?? "" +} + +// MARK: - Stream-with-timeout helper +// +// AsyncThrowingStream supports only a single logical consumer, so a long-lived +// subscription (e.g. subscribeShell) is wrapped in this reference-typed +// cursor and awaited multiple times (once per expected event) rather than +// re-iterated from scratch. `@unchecked Sendable` is safe here because the +// test only ever has one in-flight call to `next()` at a time (the racing +// timeout task never touches the iterator). +private final class StreamCursor: @unchecked Sendable { + private var iterator: AsyncThrowingStream.AsyncIterator + init(_ stream: AsyncThrowingStream) { + iterator = stream.makeAsyncIterator() + } + func next() async throws -> T? { + try await iterator.next() + } +} + +/// Awaits the next stream element matching `predicate`, racing a timeout. +private func waitForNext( + _ cursor: StreamCursor, context: String, timeout: Duration = .seconds(10), + where predicate: @escaping @Sendable (T) -> Bool +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + while true { + guard let item = try await cursor.next() else { + throw LiveE2EError.streamEnded(context) + } + if predicate(item) { return item } + } + } + group.addTask { + try await Task.sleep(for: timeout) + throw LiveE2EError.timedOut(context) + } + defer { group.cancelAll() } + guard let result = try await group.next() else { + throw LiveE2EError.timedOut(context) + } + return result + } +} + +// MARK: - The test + +@Test(.enabled(if: ProcessInfo.processInfo.environment["SERGECODE_LIVE_E2E"] == "1")) +func liveEndToEndProtocolFlow() async throws { + let scratchRoot = defaultScratchRoot() + let baseDir = scratchRoot + "/e2e-home" + let repoDir = scratchRoot + "/e2e-repo" + let fileManager = FileManager.default + + try? fileManager.removeItem(atPath: baseDir) + try? fileManager.removeItem(atPath: repoDir) + try fileManager.createDirectory(atPath: baseDir, withIntermediateDirectories: true) + try fileManager.createDirectory(atPath: repoDir, withIntermediateDirectories: true) + + // A throwaway git repo for the project the test creates. + try runGit(["init"], cwd: repoDir) + try runGit(["config", "user.email", "e2e@example.test"], cwd: repoDir) + try runGit(["config", "user.name", "E2E Test"], cwd: repoDir) + try "hello\n".write(toFile: repoDir + "/README.md", atomically: true, encoding: .utf8) + try runGit(["add", "-A"], cwd: repoDir) + try runGit(["commit", "-m", "initial commit"], cwd: repoDir) + + guard let nodePath = locateNode() else { + Issue.record("Could not locate a node binary (checked $SERGECODE_NODE, login-shell PATH, common install paths).") + return + } + let entryPath = + ProcessInfo.processInfo.environment["SERGECODE_SERVER_ENTRY"] + ?? defaultServerEntryPath() + guard fileManager.isExecutableFile(atPath: entryPath) else { + Issue.record("Server entry point not found/executable at \(entryPath).") + return + } + + let host = "127.0.0.1" + let port = try pickFreePort(host: host) + let bootstrapToken = makeBootstrapToken() + let logDir = baseDir + "/logs/sidecar" + + let process = try spawnServer( + nodePath: nodePath, entryPath: entryPath, port: port, host: host, baseDir: baseDir, + bootstrapToken: bootstrapToken, logDir: logDir) + + let ready = await waitForReadiness(host: host, port: port) + guard ready else { + await terminateProcess(process) + Issue.record( + "Sidecar did not become ready within 60s.\n\(readLogTail(logDir: logDir))") + return + } + + var connection: RpcConnection? + do { + let kitConfig = T3KitConfig(host: host, port: port, desktopBootstrapToken: bootstrapToken) + let authClient = AuthClient(config: kitConfig.authConfig) + + // Full auth chain: bootstrap token -> access token -> wsTicket -> socket URL. + let socketURL = try await authClient.makeSocketURL() + #expect(socketURL.absoluteString.contains("wsTicket=")) + + let conn = RpcConnection(url: socketURL) + connection = conn + try await conn.connect() + let client = T3Client(transport: conn) + + // server.getConfig round-trip. + let config = try await client.getConfig() + #expect(config.auth.policy == .desktopManagedLocal) + #expect(config.environment.platform.os == .darwin) + + // Subscribe to the project/thread shell before writing, so the + // snapshot-then-live-tail events for our writes are observed. + let shellStream = await client.subscribeShell() + let shellCursor = StreamCursor(shellStream) + let firstItem = try await waitForNext(shellCursor, context: "initial shell snapshot") { _ in + true + } + guard case .snapshot = firstItem else { + throw LiveE2EError.setupFailed( + "expected the first subscribeShell item to be a snapshot, got \(firstItem)") + } + + // dispatchCommand: project.create, pointed at the throwaway git repo. + let projectId = "e2e-project-\(UUID().uuidString.prefix(8))" + _ = try await client.createProject( + projectId: projectId, title: "E2E Project", workspaceRoot: repoDir, + createWorkspaceRootIfMissing: false) + + let projectEvent = try await waitForNext( + shellCursor, context: "project-upserted event for \(projectId)" + ) { item in + if case .event(.projectUpserted(_, let project)) = item, project.id == projectId { + return true + } + return false + } + if case .event(.projectUpserted(_, let project)) = projectEvent { + #expect(project.workspaceRoot == repoDir) + } + + // dispatchCommand: thread.create with provider claude. Prefer a real + // configured claude-driver instance/model from getConfig; fall back to + // a plausible instanceId/model if this machine has none configured + // (dispatchCommand does not appear to validate ModelSelection against + // the live provider table — only its own schema shape). + let claudeProvider = config.providers.first { $0.driver.lowercased().contains("claude") } + let claudeModel = claudeProvider?.models.first?.slug + let modelSelection = ModelSelection( + instanceId: claudeProvider?.instanceId ?? "claude-code", + model: (claudeModel?.isEmpty == false) ? claudeModel! : "claude-sonnet-4-5") + + let threadId = "e2e-thread-\(UUID().uuidString.prefix(8))" + _ = try await client.createThread( + threadId: threadId, projectId: projectId, title: "E2E Thread", + modelSelection: modelSelection, runtimeMode: .fullAccess) + + let threadEvent = try await waitForNext( + shellCursor, context: "thread-upserted event for \(threadId)" + ) { item in + if case .event(.threadUpserted(_, let thread)) = item, thread.id == threadId { + return true + } + return false + } + if case .event(.threadUpserted(_, let thread)) = threadEvent { + #expect(thread.projectId == projectId) + #expect(thread.modelSelection.instanceId == modelSelection.instanceId) + } + + // Clean shutdown: closing the socket tears down all of this + // connection's server-side subscriptions (wire-protocol.md §2.4), so + // there is no separate Interrupt needed for the shell subscription. + await conn.disconnect(reason: "test complete") + connection = nil + } catch { + if let connection { + await connection.disconnect(reason: "test failed") + } + await terminateProcess(process) + throw error + } + + await terminateProcess(process) +} diff --git a/apps/mac/Tests/T3KitTests/T3ErrorTests.swift b/apps/mac/Tests/T3KitTests/T3ErrorTests.swift new file mode 100644 index 00000000000..e9dce5f22f6 --- /dev/null +++ b/apps/mac/Tests/T3KitTests/T3ErrorTests.swift @@ -0,0 +1,115 @@ +// T3ErrorTests.swift +// Error taxonomy (§1.2, §2.2, §5.6, §5.9, §risks 2/3/9): RpcFailure derived +// from Exit.Failure causes, and the T3Error cases callers switch on — +// including the ~10s-silence liveness case from §1.4. + +import Testing +@testable import T3Kit +import Foundation + +@Suite("RpcFailure") +struct RpcFailureTests { + + private func rpcFailure(from fixture: String) throws -> RpcFailure { + let frames = try FrameBatch.decode(fixture) + guard case let .exit(_, .failure(rpcFailure)) = frames[0] else { + throw TestFailure("expected .exit(.failure) in fixture") + } + return rpcFailure + } + + private struct TestFailure: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } + } + + @Test("primaryErrorTag reads the first Fail node's literal _tag (§5.9: not the class name)") + func primaryErrorTagIsTheWireLiteral() throws { + let failure = try rpcFailure(from: FixtureFrames.exitAuthorizationFailure) + #expect(failure.primaryErrorTag == "EnvironmentAuthorizationError") + } + + @Test("isAuthorizationError is true only for EnvironmentAuthorizationError") + func isAuthorizationErrorFlag() throws { + let authFailure = try rpcFailure(from: FixtureFrames.exitAuthorizationFailure) + #expect(authFailure.isAuthorizationError == true) + + let dieFailure = try rpcFailure(from: FixtureFrames.exitDieFailure) + #expect(dieFailure.isAuthorizationError == false) + } + + @Test("requiredScope surfaces the scope literal from an authorization failure") + func requiredScopeFromAuthorizationFailure() throws { + let failure = try rpcFailure(from: FixtureFrames.exitAuthorizationFailure) + #expect(failure.requiredScope == "orchestration:operate") + } + + @Test("requiredScope is nil when the cause isn't an authorization error") + func requiredScopeNilForNonAuthorizationFailure() throws { + let failure = try rpcFailure(from: FixtureFrames.exitDieFailure) + #expect(failure.requiredScope == nil) + } + + @Test("primaryErrorTag is nil when the cause chain has no Fail node") + func primaryErrorTagNilWithOnlyDieCause() throws { + let failure = try rpcFailure(from: FixtureFrames.exitDieFailure) + #expect(failure.primaryErrorTag == nil) + } +} + +@Suite("T3Error") +struct T3ErrorTests { + + @Test("all documented cases are constructible and pattern-matchable") + func allCasesConstructAndMatch() throws { + let cases: [T3Error] = [ + .notConnected, + .connectionClosed(reason: "server closed"), + .connectionClosed(reason: nil), + .pingTimeout, + .transport("socket error"), + .decoding("bad frame"), + .auth("ticket expired"), + .unexpectedFrame("saw an array where an object was expected"), + ] + #expect(cases.count == 8) + + for error in cases { + switch error { + case .notConnected, .pingTimeout: + continue + case .connectionClosed, .transport, .decoding, .auth, .unexpectedFrame, .rpc: + continue + } + } + } + + @Test("§1.4: ~10s of silence after an unanswered Ping is modeled as .pingTimeout") + func pingTimeoutModelsHeartbeatDeadline() { + // This documents the wire-level liveness contract this error models: + // client pings every 5s (FixtureFrames.ping); if the matching Pong + // (FixtureFrames.pong) doesn't arrive by the next tick, the + // connection is dead. Actor-level timer scheduling for this belongs + // to RpcConnection and is out of scope for this fixture-only suite. + let error = T3Error.pingTimeout + guard case .pingTimeout = error else { + Issue.record("expected .pingTimeout") + return + } + } + + @Test("wraps a decoded RpcFailure without losing its fields") + func rpcCaseWrapsRpcFailure() throws { + let frames = try FrameBatch.decode(FixtureFrames.exitAuthorizationFailure) + guard case let .exit(_, .failure(rpcFailure)) = frames[0] else { + Issue.record("expected .exit(.failure)") + return + } + let error = T3Error.rpc(rpcFailure) + guard case let .rpc(wrapped) = error else { + Issue.record("expected .rpc") + return + } + #expect(wrapped.isAuthorizationError == true) + } +} diff --git a/apps/mac/Tests/T3KitTests/T3KitTests.swift b/apps/mac/Tests/T3KitTests/T3KitTests.swift new file mode 100644 index 00000000000..659dc2ca5e2 --- /dev/null +++ b/apps/mac/Tests/T3KitTests/T3KitTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import T3Kit + +@Test func defaultPortMatchesServer() { + #expect(T3Kit.defaultPort == 3773) +} diff --git a/apps/mac/Tests/T3KitTests/WireCodingTests.swift b/apps/mac/Tests/T3KitTests/WireCodingTests.swift new file mode 100644 index 00000000000..689f8e3c747 --- /dev/null +++ b/apps/mac/Tests/T3KitTests/WireCodingTests.swift @@ -0,0 +1,124 @@ +// WireCodingTests.swift +// Shared codec configuration (§5.1, §5.7) and the reusable optionality/default +// helpers (§5.3, §5.4) that every wire model depends on. + +import Testing +@testable import T3Kit +import Foundation + +@Suite("WireCoding") +struct WireCodingTests { + + @Test("encodeFrameString produces one compact JSON object, no pretty-printing") + func encodeFrameStringIsCompact() throws { + let text = try WireCoding.encodeFrameString(["_tag": "Ping"]) + #expect(!text.contains("\n")) + let roundTripped = try WireCoding.decoder.decode(JSONValue.self, from: Data(text.utf8)) + #expect(roundTripped == .object(["_tag": .string("Ping")])) + } + + // MARK: - OptionBox (`Schema.Option` = {_tag:"None"} | {_tag:"Some",value}) + + @Test("OptionBox decodes the Some case") + func optionBoxDecodesSome() throws { + let box = try WireCoding.decoder.decode( + OptionBox.self, + from: Data(FixtureFrames.optionSome.utf8) + ) + #expect(box.value == "origin/main") + } + + @Test("OptionBox decodes the None case") + func optionBoxDecodesNone() throws { + let box = try WireCoding.decoder.decode( + OptionBox.self, + from: Data(FixtureFrames.optionNone.utf8) + ) + #expect(box.value == nil) + } + + @Test("OptionBox encodes Some/None back to the tagged wire shape") + func optionBoxEncodeRoundTrip() throws { + let some = OptionBox("origin/main") + let someData = try WireCoding.encoder.encode(some) + let someJSON = try WireCoding.decoder.decode(JSONValue.self, from: someData) + #expect(someJSON["_tag"]?.stringValue == "Some") + #expect(someJSON["value"]?.stringValue == "origin/main") + + let none = OptionBox(nil) + let noneData = try WireCoding.encoder.encode(none) + let noneJSON = try WireCoding.decoder.decode(JSONValue.self, from: noneData) + #expect(noneJSON["_tag"]?.stringValue == "None") + #expect(noneJSON["value"] == nil) + } + + // MARK: - `decode(_:forKey:default:)` (`withDecodingDefault`, §5.3) + + private struct DefaultedThread: Decodable, Equatable { + let runtimeMode: String + let proposedPlans: [String] + + init(runtimeMode: String, proposedPlans: [String]) { + self.runtimeMode = runtimeMode + self.proposedPlans = proposedPlans + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.runtimeMode = try container.decode( + String.self, forKey: .runtimeMode, default: "full-access" + ) + self.proposedPlans = try container.decode( + [String].self, forKey: .proposedPlans, default: [] + ) + } + + private enum CodingKeys: String, CodingKey { + case runtimeMode, proposedPlans + } + } + + @Test("decode(forKey:default:) supplies the default when the key is absent") + func decodeWithDefaultOnAbsentKey() throws { + let decoded = try WireCoding.decoder.decode( + DefaultedThread.self, from: Data(#"{}"#.utf8) + ) + #expect(decoded == DefaultedThread(runtimeMode: "full-access", proposedPlans: [])) + } + + @Test("decode(forKey:default:) uses the provided value when the key is present") + func decodeWithDefaultOnPresentKey() throws { + let decoded = try WireCoding.decoder.decode( + DefaultedThread.self, + from: Data(#"{"runtimeMode":"approval-required","proposedPlans":["p1"]}"#.utf8) + ) + #expect(decoded == DefaultedThread(runtimeMode: "approval-required", proposedPlans: ["p1"])) + } + + // MARK: - §5.3/§5.4 no-value encoding matrix (absent key vs null vs Option) + + @Test("absent key, explicit null, and Option-None are three distinct wire shapes") + func noValueEncodingMatrix() throws { + // Schema.optional: the key is simply not present in the object. + let absentKeyObject = try WireCoding.decoder.decode( + JSONValue.self, from: Data(FixtureFrames.optionalKeyAbsent.utf8) + ) + #expect(absentKeyObject.objectValue?["branch"] == nil) + + // Schema.NullOr: the key is present with a literal JSON null. + let nullOrObject = try WireCoding.decoder.decode( + JSONValue.self, from: Data(FixtureFrames.nullOrKeyPresentNull.utf8) + ) + #expect(nullOrObject.objectValue?["branch"] == .some(.null)) + + // Schema.Option: neither absent nor null — a tagged {_tag,value?} object. + let optionNoneBox = try WireCoding.decoder.decode( + OptionBox.self, from: Data(FixtureFrames.optionNone.utf8) + ) + #expect(optionNoneBox.value == nil) + let optionSomeBox = try WireCoding.decoder.decode( + OptionBox.self, from: Data(FixtureFrames.optionSome.utf8) + ) + #expect(optionSomeBox.value == "origin/main") + } +} diff --git a/apps/mac/Tests/T3KitTests/WireEnvelopeTests.swift b/apps/mac/Tests/T3KitTests/WireEnvelopeTests.swift new file mode 100644 index 00000000000..ec5287850bc --- /dev/null +++ b/apps/mac/Tests/T3KitTests/WireEnvelopeTests.swift @@ -0,0 +1,314 @@ +// WireEnvelopeTests.swift +// Effect-RPC framing layer (§2 of docs/wire-protocol.md): encode/decode +// round-trips against the spec's Appendix A fixtures, mandatory per-Chunk Ack +// emission/ordering (§2.3), Exit.Failure cause parsing (§2.2/§5.6), frame +// batching (§2.1), and non-uniform union discriminators (§5.5). + +import Testing +@testable import T3Kit +import Foundation + +// MARK: - Helpers + +/// Structural JSON equality that ignores key order and whitespace, so tests +/// don't depend on JSONEncoder's key-ordering behavior. +private func assertSameJSON( + _ actual: String, _ expected: String, + sourceLocation: SourceLocation = #_sourceLocation +) throws { + let actualValue = try WireCoding.decoder.decode(JSONValue.self, from: Data(actual.utf8)) + let expectedValue = try WireCoding.decoder.decode(JSONValue.self, from: Data(expected.utf8)) + #expect(actualValue == expectedValue, sourceLocation: sourceLocation) +} + +// MARK: - ClientFrame encode round-trips (§2.2 FromClientEncoded) + +@Suite("ClientFrame encoding") +struct ClientFrameEncodingTests { + + @Test("Request with no traceId omits the key entirely") + func requestWithoutTraceId() throws { + let frame = ClientFrame.request( + id: "0", tag: "server.getConfig", payload: .object([:]), + headers: [], traceId: nil + ) + let text = try WireCoding.encodeFrameString(frame) + try assertSameJSON(text, FixtureFrames.requestGetConfig) + #expect(!text.contains("traceId")) + } + + @Test("Request carries payload and non-empty headers as [key,value] pairs") + func requestWithHeadersAndPayload() throws { + let frame = ClientFrame.request( + id: "1", tag: "orchestration.subscribeThread", + payload: .object(["threadId": .string("th_123")]), + headers: [], traceId: nil + ) + let text = try WireCoding.encodeFrameString(frame) + try assertSameJSON(text, FixtureFrames.requestSubscribeThread) + } + + @Test("Request with traceId includes it") + func requestWithTraceId() throws { + let frame = ClientFrame.request( + id: "9", tag: "server.getConfig", payload: .object([:]), + headers: [], traceId: "trace-abc" + ) + let text = try WireCoding.encodeFrameString(frame) + let value = try WireCoding.decoder.decode(JSONValue.self, from: Data(text.utf8)) + #expect(value["traceId"]?.stringValue == "trace-abc") + } + + @Test("Ack encodes {_tag, requestId}") + func ack() throws { + let text = try WireCoding.encodeFrameString(ClientFrame.ack(requestId: "1")) + try assertSameJSON(text, FixtureFrames.ackSubscribeThreadSnapshot) + } + + @Test("Interrupt encodes {_tag, requestId}") + func interrupt() throws { + let text = try WireCoding.encodeFrameString(ClientFrame.interrupt(requestId: "1")) + try assertSameJSON(text, FixtureFrames.interruptSubscribeThread) + } + + @Test("Ping encodes to the bare {_tag:\"Ping\"} heartbeat frame") + func ping() throws { + let text = try WireCoding.encodeFrameString(ClientFrame.ping) + try assertSameJSON(text, FixtureFrames.ping) + } + + @Test("Eof encodes to the bare {_tag:\"Eof\"} frame") + func eof() throws { + let text = try WireCoding.encodeFrameString(ClientFrame.eof) + try assertSameJSON(text, FixtureFrames.eof) + } +} + +// MARK: - ServerFrame / ExitResult decode (§2.2 FromServerEncoded, §5.6) + +@Suite("ServerFrame decoding") +struct ServerFrameDecodingTests { + + @Test("Chunk carries a non-empty values array keyed by requestId") + func chunk() throws { + let frames = try FrameBatch.decode(FixtureFrames.chunkSubscribeThreadSnapshot) + #expect(frames.count == 1) + guard case let .chunk(requestId, values) = frames[0] else { + Issue.record("expected .chunk, got \(frames[0])") + return + } + #expect(requestId == "1") + #expect(values.count == 1) + #expect(values[0]["kind"]?.stringValue == "snapshot") + } + + @Test("Exit.Success decodes the encoded success value") + func exitSuccess() throws { + let frames = try FrameBatch.decode(FixtureFrames.exitGetConfigSuccess) + guard case let .exit(requestId, .success(value)) = frames[0] else { + Issue.record("expected .exit(.success), got \(frames[0])") + return + } + #expect(requestId == "0") + #expect(value["cwd"]?.stringValue == "/tmp") + } + + @Test("Exit.Failure with a Fail cause exposes the typed error tag and fields") + func exitFailureFail() throws { + let frames = try FrameBatch.decode(FixtureFrames.exitAuthorizationFailure) + guard case let .exit(requestId, .failure(rpcFailure)) = frames[0] else { + Issue.record("expected .exit(.failure), got \(frames[0])") + return + } + #expect(requestId == "3") + #expect(rpcFailure.primaryErrorTag == "EnvironmentAuthorizationError") + #expect(rpcFailure.isAuthorizationError == true) + #expect(rpcFailure.requiredScope == "orchestration:operate") + } + + @Test("Exit.Failure with a Die cause carries the opaque defect JSON") + func exitFailureDie() throws { + let frames = try FrameBatch.decode(FixtureFrames.exitDieFailure) + guard case let .exit(_, .failure(rpcFailure)) = frames[0] else { + Issue.record("expected .exit(.failure), got \(frames[0])") + return + } + guard case let .die(defect) = rpcFailure.causes.first else { + Issue.record("expected a .die cause, got \(rpcFailure.causes)") + return + } + #expect(defect["name"]?.stringValue == "TypeError") + #expect(rpcFailure.isAuthorizationError == false) + } + + @Test("Exit.Failure with an Interrupt cause parses a present fiberId") + func exitFailureInterruptWithFiberId() throws { + let frames = try FrameBatch.decode(FixtureFrames.exitInterruptFailureWithFiberId) + guard case let .exit(_, .failure(rpcFailure)) = frames[0] else { + Issue.record("expected .exit(.failure), got \(frames[0])") + return + } + guard case let .interrupt(fiberId) = rpcFailure.causes.first else { + Issue.record("expected a .interrupt cause, got \(rpcFailure.causes)") + return + } + #expect(fiberId == 12) + } + + @Test("Exit.Failure with an Interrupt cause tolerates an absent fiberId") + func exitFailureInterruptWithoutFiberId() throws { + let frames = try FrameBatch.decode(FixtureFrames.exitInterruptFailureWithoutFiberId) + guard case let .exit(_, .failure(rpcFailure)) = frames[0] else { + Issue.record("expected .exit(.failure), got \(frames[0])") + return + } + guard case let .interrupt(fiberId) = rpcFailure.causes.first else { + Issue.record("expected a .interrupt cause, got \(rpcFailure.causes)") + return + } + #expect(fiberId == nil) + } + + @Test("Defect is connection-level, not tied to a requestId") + func defect() throws { + let frames = try FrameBatch.decode(FixtureFrames.defect) + guard case let .defect(defectValue) = frames[0] else { + Issue.record("expected .defect, got \(frames[0])") + return + } + #expect(defectValue["message"]?.stringValue == "connection lost") + } + + @Test("Pong decodes to the bare liveness reply") + func pong() throws { + let frames = try FrameBatch.decode(FixtureFrames.pong) + guard case .pong = frames[0] else { + Issue.record("expected .pong, got \(frames[0])") + return + } + } + + @Test("ClientEnd tolerates both a present and an absent clientId") + func clientEnd() throws { + let withId = try FrameBatch.decode(FixtureFrames.clientEndWithId) + guard case let .clientEnd(id) = withId[0] else { + Issue.record("expected .clientEnd, got \(withId[0])") + return + } + #expect(id == 5) + + let withoutId = try FrameBatch.decode(FixtureFrames.clientEndWithoutId) + guard case let .clientEnd(id2) = withoutId[0] else { + Issue.record("expected .clientEnd, got \(withoutId[0])") + return + } + #expect(id2 == nil) + } + + @Test("An unrecognized _tag decodes to .unknown instead of throwing") + func unknownTag() throws { + let frames = try FrameBatch.decode(FixtureFrames.unknownServerTag) + guard case let .unknown(tag) = frames[0] else { + Issue.record("expected .unknown, got \(frames[0])") + return + } + #expect(tag == "SomeFutureMessage") + } +} + +// MARK: - §2.1 batching: one WS frame may be a JSON array of messages + +@Suite("FrameBatch") +struct FrameBatchTests { + + @Test("a single JSON object decodes to exactly one frame") + func singleObjectFrame() throws { + let frames = try FrameBatch.decode(FixtureFrames.pong) + #expect(frames.count == 1) + } + + @Test("a JSON array decodes to one frame per element, in order") + func arrayBatch() throws { + let frames = try FrameBatch.decode(FixtureFrames.batchOfTwoServerFrames) + #expect(frames.count == 2) + guard case .pong = frames[0] else { + Issue.record("expected frames[0] == .pong, got \(frames[0])") + return + } + guard case let .chunk(requestId, _) = frames[1] else { + Issue.record("expected frames[1] == .chunk, got \(frames[1])") + return + } + #expect(requestId == "1") + } +} + +// MARK: - §2.3 mandatory per-Chunk Ack, ordering & requestId correlation + +@Suite("Ack emission ordering") +struct AckEmissionOrderingTests { + + @Test("each Chunk's requestId maps to exactly one correlated Ack frame") + func ackFollowsChunkWithSameRequestId() throws { + let snapshotFrames = try FrameBatch.decode(FixtureFrames.chunkSubscribeThreadSnapshot) + guard case let .chunk(snapshotRequestId, _) = snapshotFrames[0] else { + Issue.record("expected .chunk") + return + } + let snapshotAck = try WireCoding.encodeFrameString( + ClientFrame.ack(requestId: snapshotRequestId) + ) + try assertSameJSON(snapshotAck, FixtureFrames.ackSubscribeThreadSnapshot) + } + + @Test("acks are emitted in the same order the chunks arrived (snapshot, then live event)") + func acksPreserveChunkArrivalOrder() throws { + let incoming = try FrameBatch.decode(FixtureFrames.chunkSubscribeThreadSnapshot) + + FrameBatch.decode(FixtureFrames.chunkSubscribeThreadEvent) + + var emittedAcks: [String] = [] + for frame in incoming { + guard case let .chunk(requestId, _) = frame else { continue } + emittedAcks.append(try WireCoding.encodeFrameString(ClientFrame.ack(requestId: requestId))) + } + + #expect(emittedAcks.count == 2) + try assertSameJSON(emittedAcks[0], FixtureFrames.ackSubscribeThreadSnapshot) + try assertSameJSON(emittedAcks[1], FixtureFrames.ackSubscribeThreadEvent) + } +} + +// MARK: - §5.5 discriminator variants (`_tag` vs `type` vs `kind`) + +@Suite("Discriminator variants") +struct DiscriminatorVariantTests { + + @Test("tagged errors/envelopes are discriminated by `_tag`") + func byTag() throws { + let value = try WireCoding.decoder.decode( + JSONValue.self, from: Data(FixtureFrames.discriminatorByTag.utf8) + ) + #expect(value["_tag"]?.stringValue == "EnvironmentAuthorizationError") + #expect(value["type"] == nil) + #expect(value["kind"] == nil) + } + + @Test("OrchestrationEvent-style unions are discriminated by `type`, not `_tag`") + func byType() throws { + let value = try WireCoding.decoder.decode( + JSONValue.self, from: Data(FixtureFrames.discriminatorByType.utf8) + ) + #expect(value["type"]?.stringValue == "thread.archived") + #expect(value["_tag"] == nil) + } + + @Test("orchestration stream items are discriminated by `kind`, not `_tag`") + func byKind() throws { + let value = try WireCoding.decoder.decode( + JSONValue.self, from: Data(FixtureFrames.discriminatorByKind.utf8) + ) + #expect(value["kind"]?.stringValue == "thread-upserted") + #expect(value["_tag"] == nil) + #expect(value["type"] == nil) + } +} diff --git a/apps/mac/docs/wire-protocol.md b/apps/mac/docs/wire-protocol.md new file mode 100644 index 00000000000..c22004c7966 --- /dev/null +++ b/apps/mac/docs/wire-protocol.md @@ -0,0 +1,991 @@ +# t3code WebSocket Wire Protocol Specification + +> Target audience: an independent **native Swift macOS client** that speaks the +> t3code server's WebSocket RPC protocol directly, without the Effect-TS +> runtime. +> +> Every claim below cites `file:line` in this monorepo (or in the vendored +> `effect@4.0.0-beta.78` source under `node_modules/.pnpm/effect@4.0.0-beta.78…/node_modules/effect/src`, +> abbreviated **`effect/src`** below). +> +> **Version pin:** the transport is Effect's *unstable* RPC +> (`effect/unstable/rpc`) at `effect@4.0.0-beta.78` +> (`node_modules/.pnpm/effect@4.0.0-beta.78_patch_hash=…`). The envelope shapes in +> §2 are an **unstable** API and can change between Effect betas. See §6. + +--- + +## 1. Connection + +### 1.1 Endpoint + +- **Path:** `GET /ws`, upgraded to WebSocket. + Registered server-side at `apps/server/src/ws.ts:1796-1798` + (`HttpRouter.add("GET", "/ws", …)`). +- **Scheme/host/port:** the same HTTP server that serves the web app and the + `/api/*` REST endpoints. Default listen port is **3773** + (`apps/server/src/config.ts:17`, `DEFAULT_PORT = 3773`). Local desktop + connections therefore use `ws://127.0.0.1:3773/ws`; remote/relay/SSH + connections use `wss:///ws`. +- **Client URL construction:** `packages/client-runtime/src/connection/resolver.ts:49-55` + takes the target's `wsBaseUrl` and forces the path to `/ws` when empty + (`primarySocketUrl`). The final URL string is stored on + `PreparedConnection.socketUrl` (`packages/client-runtime/src/connection/model.ts:116-123`). +- The socket is opened with `Socket.layerWebSocket(connection.socketUrl, { openTimeout: "15 seconds" })` + (`packages/client-runtime/src/rpc/session.ts:94-96`, `:23`). **No WebSocket + subprotocol is negotiated** — `Socket.layerWebSocket` is called without a + `protocols` argument (`effect/src/unstable/socket/Socket.ts:586,596,610`), so + the browser/client sends no `Sec-WebSocket-Protocol` header. + +### 1.2 Authentication / handshake + +Authentication happens **at the HTTP upgrade**, before any RPC frame. The server +resolves the session in `EnvironmentAuth.authenticateWebSocketUpgrade` +(`apps/server/src/auth/EnvironmentAuth.ts:936-956`). Two mechanisms, tried in +this order: + +1. **`wsTicket` query parameter (primary, browser-compatible).** + `apps/server/src/auth/EnvironmentAuth.ts:501` defines the query key literal + `wsTicket`; `:940-952` reads `url.searchParams.get("wsTicket")` and verifies + it via `sessions.verifyWebSocketToken`. The final socket URL therefore looks + like: + + ``` + wss://environment.example.test/ws?wsTicket= + ``` + + (see fixtures `packages/client-runtime/src/connection/resolver.test.ts:124,138,236,284,341`). + + The ticket is a **short-lived, single-use-ish token** obtained *over HTTP + before* opening the socket: + - `POST /api/auth/websocket-ticket` (`packages/contracts/src/environmentHttp.ts:373-377`), + authenticated with `Authorization: Bearer ` (or DPoP), returns + `AuthWebSocketTicketResult = { ticket: string, expiresAt: }` + (`packages/contracts/src/auth.ts:196-200`; server side + `EnvironmentAuth.issueWebSocketTicket` `apps/server/src/auth/EnvironmentAuth.ts:918-929`). + - The Swift client should: acquire an access token → `POST` the ticket + endpoint → append `?wsTicket=` → open the socket. Re-issue a fresh + ticket on every (re)connect. + +2. **HTTP header / cookie fallback.** If no `wsTicket` is present, the server + falls back to `authenticateRequest` (`apps/server/src/auth/EnvironmentAuth.ts:955`), + which accepts (in precedence order, `:594-597`): + - session **cookie** (`request.cookies[sessions.cookieName]`), + - `Authorization: Bearer ` (`:499`, `:538-545`), + - `Authorization: DPoP ` (`:500`, `:547-554`). + + A native Swift client using `URLSessionWebSocketTask` **can** set request + headers, so `Authorization: Bearer …` on the upgrade request is a valid + alternative to the query ticket. (Browsers cannot set WS headers, which is why + the ticket exists.) + +On success the server binds the authenticated session to the socket, marks the +session connected for its lifetime (`apps/server/src/ws.ts:1842-1846`), and +serves RPCs. On failure the upgrade returns an HTTP error response +(`apps/server/src/ws.ts:1848-1852`; `EnvironmentAuthInvalidError` → 401-class). + +**Per-RPC authorization (scopes).** Even after the socket authenticates, every +RPC method is gated by a required scope, enforced inside each handler +(`apps/server/src/ws.ts:277-346`, `RPC_REQUIRED_SCOPE`). A call whose session +lacks the scope fails with `EnvironmentAuthorizationError` +(`{ _tag:"EnvironmentAuthorizationError", message:string, requiredScope:string }`, +`apps/server/src/ws.ts:437-441`; schema `packages/contracts/src/auth.ts:286`) — it +does **not** close the socket. `requiredScope` is one of the literal wire values +`"orchestration:read" | "orchestration:operate" | "terminal:operate" | +"review:write" | "access:read" | "access:write" | "relay:read" | "relay:write"`. +The method→scope assignment map is `apps/server/src/ws.ts:277-346`. + +### 1.3 There is no initial application handshake frame + +After the socket opens, neither side sends a mandatory protocol "hello". The +client's first frame is simply the first RPC it chooses to issue. The reference +client immediately calls `server.getConfig` for initial sync +(`packages/client-runtime/src/rpc/session.ts:116-126`), and treats the connection +as `ready` once the socket `onConnect` hook fires **and** that first +`server.getConfig` resolves (`:131-136`). A Swift client may issue any RPC first. + +### 1.4 Heartbeat / ping (application-level, inside WebSocket frames) + +The RPC layer runs its **own** liveness ping *as JSON frames* — this is separate +from (and in addition to) any RFC 6455 control-frame ping/pong the WebSocket +library does. + +- The client sends `{"_tag":"Ping"}` every **5 seconds** + (`effect/src/unstable/rpc/RpcClient.ts:1043,1161-1183`; `makePinger` uses + `Effect.delay("5 seconds")`). +- The server replies with `{"_tag":"Pong"}` + (`effect/src/unstable/rpc/RpcServer.ts:759-760`, `constPong`). +- The client tracks whether the previous ping was ponged; if a ping goes + unanswered by the next 5 s tick, the pinger opens a timeout latch that races the + socket read loop and fails it as a "ping timeout" `SocketError` + (`RpcClient.ts:1093-1106,1161-1183`). Net effect: **~5–10 s** without a `Pong` + is treated as a dead connection. + +A Swift client should: (a) send `{"_tag":"Ping"}` every 5 s and expect +`{"_tag":"Pong"}`; and (b) reply to any inbound `{"_tag":"Ping"}` with +`{"_tag":"Pong"}` (the server only ever *answers* pings, but implementing the +responder is cheap and future-proof). + +### 1.5 Reconnect behavior + +The socket-level protocol is created with `retryTransientErrors: false` and +`retryPolicy: Schedule.recurs(0)` (`packages/client-runtime/src/rpc/session.ts:99-102`), +i.e. the Effect RPC socket does **not** auto-reconnect. Reconnection is driven a +layer up by the connection *supervisor* +(`packages/client-runtime/src/connection/supervisor.ts`), which tears down the +session and establishes a brand-new socket (new ticket, new `server.getConfig`, +new stream subscriptions). See §4.3. + +--- + +## 2. Framing (the exact on-the-wire envelope) + +### 2.1 Serialization = plain JSON, one message per WebSocket frame + +- The server provides `RpcSerialization.layerJson` + (`apps/server/src/ws.ts:1816`); the client provides the same + (`packages/client-runtime/src/rpc/session.ts:108`). +- `json` serialization has **`includesFraming: false`** + (`effect/src/unstable/rpc/RpcSerialization.ts:84-97`). It does **not** use + NDJSON, JSON-RPC, or MessagePack. The framing is provided by the **WebSocket + message boundaries themselves**: + - **Encode:** `JSON.stringify(message)` → the string payload of one WS text + frame (`RpcSerialization.ts:94`). + - **Decode:** `JSON.parse(frameText)`; if the parsed value is an array it is + treated as a batch of messages, otherwise as a single message + (`RpcSerialization.ts:90-93`). +- Server write path confirms one encoded message per frame for the unframed + (JSON) case: `effect/src/unstable/rpc/RpcServer.ts:1051-1057` (`write: + !includesFraming ? (response) => offer(parser.encode(response))`), where each + `offer` is one WS frame. + +**Swift takeaway:** every inbound WS text frame is a complete JSON value. Parse +it; if it's a JSON **array**, iterate it as multiple messages; otherwise handle +it as one message object. When sending, put exactly one JSON object per WS text +frame (`JSON.stringify` equivalent). Frames are UTF-8 **text** frames. + +### 2.2 Message vocabulary + +All envelopes are defined in `effect/src/unstable/rpc/RpcMessage.ts`. Each object +is discriminated by a `_tag` string. Below, "encoded" = the on-the-wire form. + +#### Client → Server (`FromClientEncoded`, `RpcMessage.ts:60`) + +**`Request`** — invoke an RPC (`RpcMessage.ts:87-96`): + +```jsonc +{ + "_tag": "Request", + "id": "0", // stringified, monotonically increasing integer + "tag": "server.getConfig", // RPC method name (see §3) + "payload": { /* encoded payload */ }, + "headers": [], // array of [key, value] string pairs; usually [] + "traceId": "…", // optional (OpenTelemetry); omit if not tracing + "spanId": "…", // optional + "sampled": true // optional +} +``` + +- `id` generation: a process-global counter starting at `0`, stringified + (`RpcClient.ts:233` `requestIdCounter = BigInt(0)`, `:288`, and `:710` + `id: String(message.id)`). A Swift client just needs a **unique string per + in-flight request**; a monotonically increasing integer-as-string is simplest + and matches the server's `Number(id)` expectations nowhere-critical (the server + treats ids as opaque strings for JSON serialization). +- `headers` is an **array of `[string, string]` tuples**, not a JSON object + (`RpcClient.ts:712` `Object.entries(message.headers)`). Empty `[]` is normal. +- `payload` is the RPC's payload **already schema-encoded** (dates as strings, + etc. — see §5). + +**`Ack`** — acknowledge one streamed chunk, enabling the next +(`RpcMessage.ts:146-149`): + +```json +{ "_tag": "Ack", "requestId": "7" } +``` + +**`Interrupt`** — cancel an in-flight request / stream (`RpcMessage.ts:157-160`): + +```json +{ "_tag": "Interrupt", "requestId": "7" } +``` + +**`Ping`** — liveness (`RpcMessage.ts:180-182`): `{ "_tag": "Ping" }` + +**`Eof`** — client is done sending (`RpcMessage.ts:169-171`): `{ "_tag": "Eof" }` +(rarely needed for a socket client; the reference client never sends it, +`RpcClient.ts:734-736`). + +#### Server → Client (`FromServerEncoded`, `RpcMessage.ts:218`) + +**`Chunk`** — one batch of stream values for a streaming RPC +(`RpcMessage.ts:256-260`): + +```jsonc +{ + "_tag": "Chunk", + "requestId": "7", + "values": [ /* one or more encoded success-chunk values */ ] +} +``` + +`values` is a **non-empty array**; the server may batch several stream emissions +into one `Chunk`. After processing a `Chunk`, the client **must** send an `Ack` +for that `requestId` (see §2.3). + +**`Exit`** — terminal result of a request (`RpcMessage.ts:283-313`). Success: + +```json +{ "_tag": "Exit", "requestId": "0", + "exit": { "_tag": "Success", "value": { /* encoded success */ } } } +``` + +Failure (a `Cause` = array of failure nodes): + +```jsonc +{ "_tag": "Exit", "requestId": "0", + "exit": { + "_tag": "Failure", + "cause": [ + { "_tag": "Fail", "error": { "_tag": "SomeTaggedError", /* fields */ } } + // …or { "_tag": "Die", "defect": } + // …or { "_tag": "Interrupt", "fiberId": 12 } // fiberId may be undefined/absent + ] + } } +``` + +- Expected/typed RPC errors (the RPC's declared `error` schema, §3) arrive as + `{"_tag":"Fail","error":{…}}`. The `error` object is itself a tagged struct with + its own `_tag` (e.g. `EnvironmentAuthorizationError`, `GitCommandError`). +- Unexpected server-side crashes arrive as `{"_tag":"Die","defect":…}` where + `defect` is a serialized `Schema.Defect` (see §5.6). +- A streaming RPC ends with an `Exit`: `Success` (with `value` typically the + stream's completion value — for pure streams the terminal value is the stream + end) terminates the stream normally; `Failure` terminates it with the error. + For a **streaming** RPC the data arrives as `Chunk`s and the stream is closed by + the terminal `Exit` (`RpcServer.ts:565-586`; client side `RpcClient.ts:743-764`). + +**`Defect`** — connection-level defect not tied to one request +(`RpcMessage.ts:348-351`): `{ "_tag": "Defect", "defect": }`. Clears/kills +all in-flight requests (`RpcClient.ts:614-616`). + +**`Pong`** — liveness reply (`RpcMessage.ts:418-420`): `{ "_tag": "Pong" }`. + +**`ClientEnd`** — server signals the client connection ended +(`RpcMessage.ts:407-410`); the reference client ignores it +(`RpcClient.ts:617-619`). (Note `ClientEnd` carries a numeric `clientId` in the +decoded form; over the socket it is not normally emitted to a single-client +browser session.) + +### 2.3 Streaming, acknowledgement & backpressure (critical) + +Streaming RPCs use **mandatory per-chunk acknowledgement backpressure**: + +- The server, after writing a `Chunk`, **blocks** on a latch until it receives an + `Ack` for that `requestId` before sending the next chunk + (`effect/src/unstable/rpc/RpcServer.ts:428-448`, `latch.closeUnsafe()` then + `latch.await`; `:222-224` opens the latch on `Ack`). +- Acks are **enabled** for this server: `RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true })` + (`apps/server/src/ws.ts:1811-1813`) does not set `disableClientAcks`, and the + default is `supportsAck = true` (`RpcServer.ts:146`). +- The socket client protocol reports `supportsAck: true` + (`RpcClient.ts:1152`) and auto-sends an `Ack` after buffering each `Chunk` + (`RpcClient.ts:588-596`). + +**Swift takeaway (do not skip):** after handling every `Chunk`, send +`{"_tag":"Ack","requestId":""}`. If you don't, the stream **stalls after +the first chunk** (or after the server's internal buffer fills). + +### 2.4 Cancellation / interruption + +To cancel an in-flight request or unsubscribe from a stream, send +`{"_tag":"Interrupt","requestId":""}` (`RpcClient.ts:725-732`). The server +interrupts the corresponding fiber (`RpcServer.ts:226-235`). Closing the +WebSocket also tears down all of that connection's server-side subscriptions. + +--- + +## 3. RPC catalog + +The RPC group is `WsRpcGroup` (`packages/contracts/src/rpc.ts:684-753`), built +from `Rpc.make(, { payload, success, error, stream? })` declarations. +Method-name string constants live in `WS_METHODS` +(`packages/contracts/src/rpc.ts:147-235`) and `ORCHESTRATION_WS_METHODS` +(`packages/contracts/src/orchestration.ts:25-33`). The **`tag`** field of a +`Request` (§2.2) is exactly the string value listed here (e.g. `"projects.readFile"`, +`"orchestration.dispatchCommand"`, `"subscribeVcsStatus"`). + +Conventions for the tables: +- **stream** = `true` means responses arrive as `Chunk`s (§2.3), terminated by an + `Exit`; `false` means a single `Exit`. +- Every method's declared `error` is a `Schema.Union` that **always** includes + `EnvironmentAuthorizationError` (`apps/server/src/ws.ts:437-441`), plus the + domain errors noted. Omitted below for brevity except where domain-specific. +- Payload/success are named schemas; the file where each is defined is cited so + the Swift `Codable` types can be generated 1:1. + +### 3.0 Server-side handler wiring (for reference) + +All handlers are registered by `makeWsRpcLayer` via `WsRpcGroup.of({ … })` +(`apps/server/src/ws.ts:944-1789`) and served over the socket by +`RpcServer.toHttpEffectWebsocket(WsRpcGroup, …)` (`apps/server/src/ws.ts:1811`). + +### 3.1 Orchestration — threads, turns, projects, checkpoints (the core) + +Defined in `packages/contracts/src/rpc.ts:593-647` and +`packages/contracts/src/orchestration.ts`. + +| tag | payload | success | stream | notes | +|---|---|---|---|---| +| `orchestration.dispatchCommand` | `ClientOrchestrationCommand` (union, `orchestration.ts:681-699`) | `DispatchResult` = `{ sequence: int }` (`orchestration.ts:1186-1189`) | no | The single write path for projects/threads/turns. Error `OrchestrationDispatchCommandError` (`orchestration.ts:1260`). | +| `orchestration.getTurnDiff` | `{ fromTurnCount:int, toTurnCount:int, threadId:string, ignoreWhitespace?:bool }` (`orchestration.ts:1191-1198`) | `ThreadTurnDiff` = `{ fromTurnCount:int, toTurnCount:int, threadId:string, diff:string }` (`orchestration.ts:1144-1150`) | no | Error `OrchestrationGetTurnDiffError`. | +| `orchestration.getFullThreadDiff` | `{ threadId:string, toTurnCount:int, ignoreWhitespace?:bool }` (`orchestration.ts:1203-1208`) | `ThreadTurnDiff` | no | | +| `orchestration.replayEvents` | `{ fromSequenceExclusive:int }` (`orchestration.ts:1213-1216`) | `OrchestrationEvent[]` (`orchestration.ts:1218`) | no | Catch-up: fetch all events after a sequence. | +| `orchestration.getArchivedShellSnapshot` | `{}` | `OrchestrationShellSnapshot` (`orchestration.ts:413-419`) | no | | +| `orchestration.subscribeShell` | `{}` | `OrchestrationShellStreamItem` (`orchestration.ts:445-452`) | **yes** | First item `{kind:"snapshot", snapshot}`, then live `project-*`/`thread-*` deltas. | +| `orchestration.subscribeThread` | `{ threadId:string }` (`orchestration.ts:454-457`) | `OrchestrationThreadStreamItem` (`orchestration.ts:1115-1124`) | **yes** | First item `{kind:"snapshot", snapshot:{snapshotSequence,thread}}`, then `{kind:"event", event}`. | + +#### 3.1.1 `dispatchCommand` — the command union (client → server writes) + +`ClientOrchestrationCommand` (`orchestration.ts:681-699`) is a **union +discriminated by the `type` string field**. Client-dispatchable members: + +| `type` | key fields (all also carry `commandId:string`) | def | +|---|---|---| +| `project.create` | `projectId, title, workspaceRoot, createWorkspaceRootIfMissing?, defaultModelSelection?:ModelSelection\|null, createdAt` | `orchestration.ts:465-474` | +| `project.meta.update` | `projectId, title?, workspaceRoot?, defaultModelSelection?, scripts?` | `:476-484` | +| `project.delete` | `projectId, force?` | `:486-491` | +| `thread.create` | `threadId, projectId, title, modelSelection, runtimeMode, interactionMode(dflt "default"), branch:string\|null, worktreePath:string\|null, createdAt` | `:493-507` | +| `thread.delete` | `threadId` | `:509-513` | +| `thread.archive` | `threadId` | `:515-519` | +| `thread.unarchive` | `threadId` | `:521-525` | +| `thread.meta.update` | `threadId, title?, modelSelection?, branch?, worktreePath?` | `:527-535` | +| `thread.runtime-mode.set` | `threadId, runtimeMode, createdAt` | `:537-543` | +| `thread.interaction-mode.set` | `threadId, interactionMode, createdAt` | `:545-551` | +| `thread.turn.start` | `threadId, message:{messageId,role:"user",text,attachments:UploadChatAttachment[]}, modelSelection?, titleSeed?, runtimeMode(dflt "full-access"), interactionMode(dflt "default"), bootstrap?, sourceProposedPlan?, createdAt` | client variant `ClientThreadTurnStartCommand` `:600-617` | +| `thread.turn.interrupt` | `threadId, turnId?, createdAt` | `:619-625` | +| `thread.approval.respond` | `threadId, requestId, decision:ProviderApprovalDecision, createdAt` | `:627-634` | +| `thread.user-input.respond` | `threadId, requestId, answers:ProviderUserInputAnswers, createdAt` | `:636-643` | +| `thread.checkpoint.revert` | `threadId, turnCount:int, createdAt` | `:645-651` | +| `thread.session.stop` | `threadId, createdAt` | `:653-658` | + +`ClientThreadTurnStartCommand` (`:600-617`) uses `attachments: UploadChatAttachment[]` +(client upload form); the internal `ThreadTurnStartCommand` (`:579-598`) uses +resolved `ChatAttachment[]`. `bootstrap` (`ThreadTurnStartBootstrap`, `:571-575`) +optionally atomically **creates the thread** (`createThread`, `:553-562`), +**prepares a git worktree** (`prepareWorktree`, `:564-569`), and/or **runs the +project setup script** (`runSetupScript`) as part of the first turn — handled +server-side at `apps/server/src/ws.ts:678-882`. + +Supporting enums/objects: +- `ModelSelection` (`orchestration.ts:81-115`): wire object `{ instanceId:string, + model:string, options?:ProviderOptionSelections }`. Has a **decode transform** + that promotes a legacy `{ provider, model }` shape to `{ instanceId, model }` + (`:85-102`) — a fresh Swift client should always emit `instanceId`. +- `RuntimeMode` (`:117-122`): `"approval-required" | "auto-accept-edits" | "full-access"` (default `"full-access"`). +- `ProviderInteractionMode` (`:124-125`): `"default" | "plan"`. +- `ProviderApprovalDecision`, `ProviderUserInputAnswers`, `ProviderApprovalPolicy` + (`:35-41`), `ProviderSandboxMode` (`:42-47`) — see `orchestration.ts` / `model.ts`. + +#### 3.1.2 Thread/shell projection payloads (read models) + +- `OrchestrationThread` (full thread detail, `orchestration.ts:344-368`): + `id, projectId, title, modelSelection, runtimeMode, interactionMode, branch|null, + worktreePath|null, latestTurn|null, createdAt, updatedAt, archivedAt|null, + deletedAt|null, messages:OrchestrationMessage[], proposedPlans[], activities: + OrchestrationThreadActivity[], checkpoints:OrchestrationCheckpointSummary[], + session:OrchestrationSession|null`. +- `OrchestrationThreadShell` (list-row summary, `:390-411`): like the above minus + messages/checkpoints, plus `latestUserMessageAt|null, hasPendingApprovals:bool, + hasPendingUserInput:bool, hasActionableProposedPlan:bool`. +- `OrchestrationProjectShell` (`:378-388`), `OrchestrationShellSnapshot` + (`:413-419`: `{ snapshotSequence:int, projects[], threads[], updatedAt }`). +- `OrchestrationSession` (`:271-281`), `OrchestrationLatestTurn` (`:333-342`), + `OrchestrationThreadActivity` (`:313-323`: `{ id, tone, kind, summary, + payload:unknown, turnId|null, sequence?, createdAt }`), + `OrchestrationCheckpointSummary`/`File` (`:283-303`). + +#### 3.1.3 The event envelope (`OrchestrationEvent`) + +`OrchestrationEvent` (`orchestration.ts:1001-1113`) is a union **discriminated by +`type`** (23 members, listed in `OrchestrationEventType` `:783-807`). Every member +shares `EventBaseFields` (`:989-999`): + +```jsonc +{ + "sequence": 42, // NonNegativeInt — global ordering key + "eventId": "…", + "aggregateKind": "thread", // "project" | "thread" + "aggregateId": "", + "occurredAt": "2026-07-04T12:00:00.000Z", // ISO string + "commandId": "…" | null, + "causationEventId": "…" | null, + "correlationId": "…" | null, + "metadata": { "providerTurnId"?, "providerItemId"?, "adapterKey"?, "requestId"?, "ingestedAt"? }, + "type": "thread.message-sent", + "payload": { /* type-specific, e.g. ThreadMessageSentPayload :893-903 */ } +} +``` + +Event `type` values and payload defs: `project.created`(`:813`), +`project.meta-updated`(`:824`), `project.deleted`(`:834`), `thread.created`(`:839`), +`thread.deleted`(`:854`), `thread.archived`(`:859`), `thread.unarchived`(`:865`), +`thread.meta-updated`(`:870`), `thread.runtime-mode-set`(`:879`), +`thread.interaction-mode-set`(`:885`), `thread.message-sent`(`:893`), +`thread.turn-start-requested`(`:905`), `thread.turn-interrupt-requested`(`:918`), +`thread.approval-response-requested`(`:924`), `thread.user-input-response-requested`(`:931`), +`thread.checkpoint-revert-requested`(`:938`), `thread.reverted`(`:944`), +`thread.session-stop-requested`(`:949`), `thread.session-set`(`:954`), +`thread.proposed-plan-upserted`(`:959`), `thread.turn-diff-completed`(`:964`), +`thread.activity-appended`(`:975`). + +Note `subscribeThread` only forwards a **subset** of live events +(`message-sent, proposed-plan-upserted, activity-appended, turn-diff-completed, +reverted, session-set` — `apps/server/src/ws.ts:253-273,1146-1157`); the rest are +reflected through `subscribeShell`/snapshot updates. + +### 3.2 Server meta / config / settings + +`packages/contracts/src/rpc.ts:237-318`. Errors include `KeybindingsConfigError`, +`ServerSettingsError`, `ServerProviderUpdateError`, plus `EnvironmentAuthorizationError`. + +| tag | payload | success | stream | +|---|---|---|---| +| `server.getConfig` | `{}` | `ServerConfig` (`server.ts:409-421`) | no | +| `server.refreshProviders` | `{ instanceId?:string }` | `ServerProviderUpdatedPayload` (`server.ts`) | no | +| `server.updateProvider` | `ServerProviderUpdateInput` | `ServerProviderUpdatedPayload` | no | +| `server.upsertKeybinding` | `ServerUpsertKeybindingInput` (`server.ts:429-435`) | `ServerUpsertKeybindingResult` = `{keybindings, issues}` (`:440-444`) | no | +| `server.removeKeybinding` | `ServerRemoveKeybindingInput` (`:437-438`) | `ServerRemoveKeybindingResult` | no | +| `server.getSettings` | `{}` | `ServerSettings` (`settings.ts`) | no | +| `server.updateSettings` | `{ patch: ServerSettingsPatch }` | `ServerSettings` | no | +| `server.discoverSourceControl` | `{}` | `SourceControlDiscoveryResult` (`sourceControl.ts`) | no | +| `server.getTraceDiagnostics` | `{}` | `ServerTraceDiagnosticsResult` | no | +| `server.getProcessDiagnostics` | `{}` | `ServerProcessDiagnosticsResult` | no | +| `server.getProcessResourceHistory` | `ServerProcessResourceHistoryInput` | `ServerProcessResourceHistoryResult` | no | +| `server.signalProcess` | `ServerSignalProcessInput` | `ServerSignalProcessResult` | no | + +`ServerConfig` (`server.ts:409-421`) — the initial-sync object — is: +`{ environment:ExecutionEnvironmentDescriptor, auth:ServerAuthDescriptor, cwd, +keybindingsConfigPath, keybindings:ResolvedKeybindingsConfig, +issues:ServerConfigIssues, providers:ServerProviders, +availableEditors:EditorId[], observability:ServerObservability, +settings:ServerSettings }`. Each `ServerProvider` (`server.ts:156`) carries +`{ instanceId, driver, displayName?, enabled, installed, version:string|null, +status:"ready"|"warning"|"error"|"disabled", auth:{status,…}, checkedAt:ISO, +models:[], slashCommands:[], skills:[], … }`. `ServerConfigIssue` +(`server.ts:35`) is a union discriminated by **`kind`** +(`"keybindings.malformed-config" | "keybindings.invalid-entry"`). + +`ServerSettings` (`settings.ts:366`) — every field has a decoding default so the +key may be absent on request but is always present on response: +`{ enableAssistantStreaming:bool, enableProviderUpdateChecks:bool, +automaticGitFetchInterval:number(ms), defaultThreadEnvMode:"local"|"worktree", +newWorktreesStartFromOrigin:bool, addProjectBaseDirectory:string, +textGenerationModelSelection:ModelSelection, +providers:{codex,claudeAgent,cursor,grok,opencode}, providerInstances:record, +observability:{otlpTracesUrl,otlpMetricsUrl} }`. `ServerSettingsPatch` +(`settings.ts:504`) is the same tree with **every field `optionalKey`** (send only +what changes). `ServerProviderUpdateInput` (`server.ts:554`) = +`{ provider:string, instanceId? }`. + +`ServerLifecycleStreamEvent` (`server.ts:543`) is a union discriminated by +**`type`** with `version:1` + `sequence:number`: `welcome` +(`payload:{environment,cwd,projectName,bootstrapProjectId?,bootstrapThreadId?}`) and +`ready` (`payload:{at:ISO,environment}`). + +### 3.3 Cloud / relay + +`rpc.ts:320-331`. + +| tag | payload | success | stream | +|---|---|---|---| +| `cloud.getRelayClientStatus` | `{}` | `RelayClientStatusSchema` (`relayClient.ts:3`) | no | +| `cloud.installRelayClient` | `{}` | `RelayClientInstallProgressEventSchema` (`relayClient.ts:34`) | **yes** (progress) | + +`RelayClientStatusSchema` is a union **discriminated by `status`** +(`available{executablePath,source,version}` | `missing{version}` | +`unsupported{platform,arch,version}`); the progress stream is a union by **`type`** +(`progress{stage}` | `complete{status}`). + +### 3.4 Source control (repository provisioning) + +`rpc.ts:333-355`. Domain error `SourceControlRepositoryError`. + +| tag | payload | success | stream | +|---|---|---|---| +| `sourceControl.lookupRepository` | `SourceControlRepositoryLookupInput` | `SourceControlRepositoryInfo` | no | +| `sourceControl.cloneRepository` | `SourceControlCloneRepositoryInput` | `SourceControlCloneRepositoryResult` | no | +| `sourceControl.publishRepository` | `SourceControlPublishRepositoryInput` | `SourceControlPublishRepositoryResult` | no | + +### 3.5 Projects (workspace files & registry) + +`rpc.ts:357-379`. All defined in `packages/contracts/src/project.ts`. Domain +errors `Project{Search,List,Read,Write}EntriesError`. + +| tag | payload | success | stream | +|---|---|---|---| +| `projects.searchEntries` | `ProjectSearchEntriesInput` (`{cwd, query, limit,…}`) | `ProjectSearchEntriesResult` | no | +| `projects.listEntries` | `ProjectListEntriesInput` | `ProjectListEntriesResult` | no | +| `projects.readFile` | `ProjectReadFileInput` (`{cwd, relativePath,…}`) | `ProjectReadFileResult` | no | +| `projects.writeFile` | `ProjectWriteFileInput` | `ProjectWriteFileResult` | no | + +Wire shapes (`project.ts`): `ProjectListEntriesInput` = `{ cwd }` (`:29`), +`ProjectListEntriesResult` = `{ entries:[{path,kind:"file"|"directory"}], truncated:bool }` +(`:34`). `ProjectReadFileInput` = `{ cwd, relativePath }` (`:119`), +`ProjectReadFileResult` = `{ relativePath, contents:string, byteLength:number, truncated:bool }` +(`:125`). `ProjectSearchEntriesInput` = `{ cwd, query, limit }` (`:8`). +`ProjectWriteFileInput` = `{ cwd, relativePath, contents }` (`:190`), +`ProjectWriteFileResult` = `{ relativePath }` (`:197`). The `*Error` types +(`_tag`-tagged) carry `message` (required) plus optional structured fields +(`failure` literal, `resolvedPath`, `operation`, …) — `:64,97,165,202`. + +> Note: `WS_METHODS` also declares `projects.list/add/remove` (`rpc.ts:149-151`), +> but these are **not** added to `WsRpcGroup` and have no `Rpc.make`/handler — +> project CRUD is done through `orchestration.dispatchCommand` +> (`project.create` / `project.meta.update` / `project.delete`). Do not call them. + +### 3.6 Shell / filesystem / assets + +`rpc.ts:381-396`. + +| tag | payload | success | stream | def | +|---|---|---|---|---| +| `shell.openInEditor` | `LaunchEditorInput` (`editor.ts`) | *(none — void)* | no | `rpc.ts:381` | +| `filesystem.browse` | `FilesystemBrowseInput` (`filesystem.ts`) | `FilesystemBrowseResult` | no | `:386` | +| `assets.createUrl` | `AssetCreateUrlInput` (`assets.ts`) | `AssetCreateUrlResult` | no | `:392` | + +`assets.createUrl` issues an HTTP URL for binary content (images, favicons) served +under the asset route (`apps/server/src/http.ts:176-220`, `ASSET_ROUTE_PREFIX`). +**Binary data is not inlined in RPC frames** — fetch it over HTTP from the issued +URL. Handler: `apps/server/src/ws.ts:1407-1452`. Wire shapes: +`AssetCreateUrlInput` = `{ resource }` where `resource` is a `_tag`-union +(`workspace-file{threadId,path}` | `attachment{attachmentId}` | +`project-favicon{cwd}`, `assets.ts:21`); `AssetCreateUrlResult` = +`{ relativeUrl:string, expiresAt:number (epoch, not ISO) }` (`assets.ts:26`). +`FilesystemBrowseInput` = `{ partialPath:string, cwd? }` (`filesystem.ts:6`); +`FilesystemBrowseResult` = `{ parentPath:string, entries:[{name,fullPath}] }` +(`filesystem.ts:18`). `LaunchEditorInput` = `{ cwd, editor: }` +(`editor.ts:47`). + +### 3.7 VCS / Git + +`rpc.ts:398-468`. Defined in `packages/contracts/src/git.ts`, +`packages/contracts/src/vcs.ts`. Errors: `GitCommandError`, +`GitManagerServiceError`, `VcsError`. + +| tag | payload | success | stream | +|---|---|---|---| +| `subscribeVcsStatus` | `VcsStatusInput` | `VcsStatusStreamEvent` | **yes** | +| `vcs.pull` | `VcsPullInput` | `VcsPullResult` | no | +| `vcs.refreshStatus` | `VcsStatusInput` | `VcsStatusResult` | no | +| `vcs.listRefs` | `VcsListRefsInput` | `VcsListRefsResult` | no | +| `vcs.createWorktree` | `VcsCreateWorktreeInput` | `VcsCreateWorktreeResult` | no | +| `vcs.removeWorktree` | `VcsRemoveWorktreeInput` | *(void)* | no | +| `vcs.createRef` | `VcsCreateRefInput` | `VcsCreateRefResult` | no | +| `vcs.switchRef` | `VcsSwitchRefInput` | `VcsSwitchRefResult` | no | +| `vcs.init` | `VcsInitInput` | *(void)* | no | +| `git.runStackedAction` | `GitRunStackedActionInput` | `GitActionProgressEvent` | **yes** (progress) | +| `git.resolvePullRequest` | `GitPullRequestRefInput` | `GitResolvePullRequestResult` | no | +| `git.preparePullRequestThread` | `GitPreparePullRequestThreadInput` | `GitPreparePullRequestThreadResult` | no | + +Key wire shapes (all in `git.ts`): +- `VcsStatusInput` = `{ cwd:string }` (`git.ts:102`). +- `VcsStatusStreamEvent` (`git.ts:241`) is a **`_tag`-tagged** union + (`snapshot`/`localUpdated`/`remoteUpdated`) carrying `local`/`remote` + `VcsStatusResult`-like payloads. `VcsStatusResult` (`git.ts:235`): `{ isRepo:bool, + hasPrimaryRemote:bool, isDefaultRef:bool, refName:string|null, + hasWorkingTreeChanges:bool, workingTree:{files:[{path,insertions,deletions}], + insertions,deletions}, hasUpstream:bool, aheadCount, behindCount, pr:{…}|null }`. +- `GitActionProgressEvent` (`git.ts:437`) — the `git.runStackedAction` stream — is a + union **discriminated by `kind`**: `action_started | phase_started | hook_started | + hook_output | hook_finished | action_finished | action_failed`, all sharing + `actionId, cwd, action`. +- `GitCommandError` (`git.ts:323`, `_tag:"GitCommandError"`), + `GitManagerServiceError` (`git.ts:380`, `_tag`-union), `VcsError` + (`vcs.ts:269`, `_tag`-union of 9 process/repo error variants). + +### 3.8 Review + +`rpc.ts:475-479`. `review.getDiffPreview` — payload `ReviewDiffPreviewInput`, +success `ReviewDiffPreviewResult`, error `ReviewDiffPreviewError` (`review.ts`), +not streamed. (Ephemeral live diff preview; not the persisted review model.) + +### 3.9 Terminal + +`rpc.ts:481-518, 649-661`. Defined in `packages/contracts/src/terminal.ts`. Error +`TerminalError`. + +| tag | payload | success | stream | +|---|---|---|---| +| `terminal.open` | `TerminalOpenInput` | `TerminalSessionSnapshot` | no | +| `terminal.attach` | `TerminalAttachInput` | `TerminalAttachStreamEvent` | **yes** | +| `terminal.write` | `TerminalWriteInput` | *(void)* | no | +| `terminal.resize` | `TerminalResizeInput` | *(void)* | no | +| `terminal.clear` | `TerminalClearInput` | *(void)* | no | +| `terminal.restart` | `TerminalRestartInput` | `TerminalSessionSnapshot` | no | +| `terminal.close` | `TerminalCloseInput` | *(void)* | no | +| `subscribeTerminalEvents` | `{}` | `TerminalEvent` | **yes** | +| `subscribeTerminalMetadata` | `{}` | `TerminalMetadataStreamEvent` | **yes** | + +Key wire shapes (all in `terminal.ts`): `TerminalSessionSnapshot` (`:96`) = +`{ threadId, terminalId, cwd, worktreePath:string|null, +status:"starting"|"running"|"exited"|"error", pid:number|null, history:string, +exitCode:number|null, exitSignal:number|null, label, updatedAt, sequence? }`. +`TerminalEvent` (`:206`) is a union **discriminated by `type`** (`started | output | +exited | closed | error | cleared | restarted | activity`), each sharing +`threadId, terminalId, sequence?`; `output` carries `{ data:string }` (UTF-8). +`TerminalWriteInput` (`:60`) = `{ threadId, terminalId, data:string(1..65536) }`. +`TerminalMetadataStreamEvent` (`:145`) union by `type`: `snapshot | upsert | remove`. +`TerminalError` (`:344`) is a `_tag`-union of 8 variants. + +### 3.10 Preview & preview-automation + +`rpc.ts:520-591`. Defined in `packages/contracts/src/preview.ts`, +`previewAutomation.ts`. Errors `PreviewError`, `PreviewAutomationError`. + +| tag | payload | success | stream | +|---|---|---|---| +| `preview.open` | `PreviewOpenInput` | `PreviewSessionSnapshot` | no | +| `preview.navigate` | `PreviewNavigateInput` | `PreviewSessionSnapshot` | no | +| `preview.resize` | `PreviewResizeInput` | `PreviewSessionSnapshot` | no | +| `preview.refresh` | `PreviewRefreshInput` | *(void)* | no | +| `preview.close` | `PreviewCloseInput` | *(void)* | no | +| `preview.list` | `PreviewListInput` | `PreviewListResult` | no | +| `preview.reportStatus` | `PreviewReportStatusInput` | *(void)* | no | +| `previewAutomation.connect` | `PreviewAutomationHost` | `PreviewAutomationStreamEvent` | **yes** | +| `previewAutomation.respond` | `PreviewAutomationResponse` | *(void)* | no | +| `previewAutomation.focusHost` | `PreviewAutomationHostFocus` | *(void)* | no | +| `subscribePreviewEvents` | `{}` | `PreviewEvent` | **yes** | +| `subscribeDiscoveredLocalServers` | `{}` | `DiscoveredLocalServerList` | **yes** | + +Key wire shapes (all in `preview.ts`): `PreviewSessionSnapshot` (`:133`) = +`{ threadId, tabId, navStatus:PreviewNavStatus, canGoBack:bool, canGoForward:bool, +viewport?:PreviewViewportSetting, updatedAt }`. `PreviewNavStatus` (`:160` area) and +`PreviewViewportSetting` (`:175` area) are **`_tag`-tagged** unions +(`Idle|Loading|Success|LoadFailed` and `fill|freeform|preset`). `PreviewEvent` +(`:236`) is a union **discriminated by `type`** (`opened|navigated|resized|failed| +closed`). `DiscoveredLocalServerList` (`:264`) = `{ servers:[{host,port,url, +processName:string|null,pid:number|null,terminal:{threadId,terminalId}|null}], +scannedAt:string }`. `PreviewError` (`:297`) `_tag`-union. +Preview-automation (`previewAutomation.ts`): `PreviewAutomationStreamEvent` (`:579`) +union by `type` (`connected|request`); `PreviewAutomationError` (`:833`) is a large +`_tag`-union (14 variants). Automation screenshots are base64 **strings**, not binary. + +### 3.11 Streaming subscriptions (server config / lifecycle / auth) + +`rpc.ts:398-403, 576-682`. + +| tag | payload | success | stream | +|---|---|---|---| +| `subscribeServerConfig` | `{}` | `ServerConfigStreamEvent` (`server.ts:504-510`) | **yes** | +| `subscribeServerLifecycle` | `{}` | `ServerLifecycleStreamEvent` (`server.ts:527+`) | **yes** | +| `subscribeAuthAccess` | `{}` | `AuthAccessStreamEvent` (`auth.ts`) | **yes** | + +`ServerConfigStreamEvent` (`server.ts:473-510`) is a union of +`{version:1, type:"snapshot", config}` / `type:"keybindingsUpdated"` / +`type:"providerStatuses"` / `type:"settingsUpdated"` (each carrying `payload`). +`AuthAccessStreamEvent` (`auth.ts:312`) is a union **discriminated by `type`** with +`version:1` + `revision:number`: `snapshot{payload:{pairingLinks[],clientSessions[]}}`, +`pairingLinkUpserted`, `pairingLinkRemoved{payload:{id}}`, `clientUpserted`, +`clientRemoved{payload:{sessionId}}`. `SourceControlDiscoveryResult` +(`sourceControl.ts:147`) uses `Schema.Option` for its `version`/`detail`/`account`/ +`host` fields (see §5.4). + +**Complete method count:** 66 RPCs registered in `WsRpcGroup` +(`packages/contracts/src/rpc.ts:684-753`): 7 orchestration + 12 server-meta + +2 cloud + 3 source-control + 4 projects + 3 shell/fs/assets + 12 vcs/git + +1 review + 9 terminal + 12 preview + 3 config/lifecycle/auth subscriptions. + +--- + +## 4. Push / subscription model + +### 4.1 Mechanism = streaming RPCs (no separate channel) + +There is **no separate pub/sub channel and no server-initiated `Request`**. All +server-initiated updates flow through **streaming RPCs** (`stream: true`) that the +*client* initiates. The server keeps the request open and pushes `Chunk` frames +(each carrying one or more encoded values) for the life of the subscription +(§2.3). The client must `Ack` each `Chunk`. + +The streaming subscription RPCs are exactly those marked **stream = yes** in §3: +`orchestration.subscribeShell`, `orchestration.subscribeThread`, +`subscribeVcsStatus`, `subscribeTerminalEvents`, `subscribeTerminalMetadata`, +`subscribePreviewEvents`, `subscribeDiscoveredLocalServers`, `terminal.attach`, +`previewAutomation.connect`, `subscribeServerConfig`, `subscribeServerLifecycle`, +`subscribeAuthAccess`, plus the progress streams `git.runStackedAction`, +`cloud.installRelayClient`. + +### 4.2 Snapshot-then-live pattern & ordering + +Most subscriptions emit a **snapshot first, then a live tail**, concatenated +server-side with `Stream.concat(Stream.make(snapshot…), liveStream)`: + +- `subscribeThread` — `apps/server/src/ws.ts:1113-1171`: first value + `{kind:"snapshot", snapshot:{snapshotSequence, thread}}`, then + `{kind:"event", event}` for that thread. `snapshotSequence` tells the client + which global `sequence` the snapshot already includes. +- `subscribeShell` — `apps/server/src/ws.ts:1062-1095`: `{kind:"snapshot",…}` then + `project-upserted|project-removed|thread-upserted|thread-removed` deltas, each + carrying a `sequence` (`orchestration.ts:421-452`). +- `subscribeServerConfig` — `apps/server/src/ws.ts:1691-1741`: `{type:"snapshot", + config}` then `keybindingsUpdated|providerStatuses|settingsUpdated`. +- `subscribeServerLifecycle` — `apps/server/src/ws.ts:1742-1756`: replays sorted + historical events (by `sequence`) then the live tail filtered to + `sequence > snapshot.sequence`. +- `subscribeAuthAccess` — `apps/server/src/ws.ts:1757-1788`: snapshot (`revision:1`) + then monotonically-increasing `revision` deltas. + +**Ordering guarantees.** Within a single subscription stream, frames are ordered +(the WebSocket + the per-chunk Ack backpressure preserve order). Cross-cutting +ordering is via the monotonic `sequence` integer on orchestration events +(`orchestration.ts:990`) and `snapshotSequence`/`revision` counters. The client +should treat `sequence` as the source of truth for dedup/reordering, since a +reconnect produces a *new* snapshot that overlaps previously-seen events. + +### 4.3 Re-subscription after reconnect + +There is **no resumption token or server-side replay-on-reconnect**. When the +socket drops, the Effect RPC socket does not auto-reconnect (§1.5); the connection +supervisor establishes a fresh socket and the client re-issues **all** its RPCs +from scratch — a new `server.getConfig`, and a new `subscribe*` for each stream, +each of which returns a fresh snapshot. To avoid missing events across the gap, +the client can use `orchestration.replayEvents({ fromSequenceExclusive })` +(§3.1) to fetch every event after the last `sequence` it processed, then reconcile +against the new snapshot's `snapshotSequence`. + +A Swift client should therefore: track the highest `sequence` seen; on reconnect, +re-subscribe (getting a snapshot) and/or call `replayEvents` from the last known +sequence; dedup by `sequence`/`eventId`. + +--- + +## 5. Schema encoding conventions (for Swift `Codable`) + +Payloads/successes/errors are encoded with each RPC's Effect `Schema` via the +JSON codec (`Schema.toCodecJson`, server side +`effect/src/unstable/rpc/RpcServer.ts:633-639`). Conventions that affect Swift +types: + +### 5.1 Dates / times → ISO-8601 strings +- The pervasive `IsoDateTime` is literally `Schema.String` + (`packages/contracts/src/baseSchemas.ts:20-21`) — a plain ISO-8601 string, + **not** validated, e.g. `"2026-07-04T12:00:00.000Z"`. Server generates them via + `DateTime.formatIso` (`apps/server/src/ws.ts:117`). +- `Schema.DateTimeUtc` — used **pervasively** in the diagnostics/auth/vcs/review/ + sourceControl result schemas (e.g. `server.ts` trace/process `readAt`, + `lastSeenAt`, bucket `startedAt/endedAt`; `auth.ts` `expiresAt/issuedAt/ + createdAt/lastConnectedAt`; `review.ts` `generatedAt`) — also encodes to/from an + ISO-8601 UTC **string** (`effect/src/Schema.ts:11412-11428`, `toCodecJson → + String` via `dateTimeUtcFromString`). Not the `[epochMillis, offset]` tuple form. + There is **no** `Schema.Date` anywhere on this surface. +- **Numeric "time" fields that are NOT date strings** (watch out): + `AssetCreateUrlResult.expiresAt` is an **epoch number** (`assets.ts:26`); + `ServerSettings.automaticGitFetchInterval` is a **number of milliseconds** + (`Schema.DurationFromMillis`, `settings.ts`); `AuthAccessTokenResult.expires_in` + is a number of seconds. +- **Swift:** decode ISO timestamp fields as `String` (or a custom ISO8601 `Date` + strategy); decode the three fields above as numbers. + +### 5.2 Branded IDs → plain strings +All entity IDs (`ThreadId`, `ProjectId`, `CommandId`, `EventId`, `MessageId`, +`TurnId`, `ProviderInstanceId`, `CheckpointRef`, `AuthSessionId`, …) are +`TrimmedNonEmptyString.pipe(Schema.brand(...))` +(`packages/contracts/src/baseSchemas.ts:26-60`). The brand is **erased on the +wire** — they are ordinary non-empty strings. Model them as `String` in Swift. + +### 5.3 Optionality — three distinct encodings +- `Schema.optional(x)` → the **key may be absent** (or present). On encode, when + the value is `undefined` the key is omitted. Model as a Swift optional + (`?`), and be prepared for the key to be missing entirely. +- `Schema.optionalKey(x)` → same "absent key" behavior (used e.g. + `ignoreWhitespace` `orchestration.ts:1194`). +- `Schema.NullOr(x)` → the key is **present** with value `x` **or JSON `null`** + (e.g. `branch: string | null`). Model as Swift optional but expect explicit + `null`, not an absent key. +- `Schema.optional(Schema.NullOr(x))` → key may be absent *or* present-and-null. +- **`withDecodingDefault`**: several fields are decoded with a default when + absent, so the server can **omit** them: `runtimeMode` (dflt `"full-access"`), + `interactionMode` (dflt `"default"`), `archivedAt` (dflt `null`), + `proposedPlans` (dflt `[]`) — see `orchestration.ts:844-846,358,361-363`. A + Swift decoder must supply these defaults when the key is missing. + +### 5.4 Options as objects +`Schema.Option(x)` encodes as `{ "_tag": "None" }` or +`{ "_tag": "Some", "value": … }` (`effect/src/Schema.ts:8191-8199,8248`) — a +**tagged object, not a bare `null`**. This is a real (not merely theoretical) shape +on this wire: it is used for value fields in `server.ts` diagnostics results +(e.g. trace `firstSpanAt/lastSpanAt/error`, process `pgid`, signal `message`), +`vcs.ts`, and `sourceControl.ts` (`SourceControlDiscoveryResult` `version`/`detail`/ +`account`/`host` fields, `sourceControl.ts:147`). So a field may be `null` (from +`NullOr`), an **absent key** (from `optional`), or a `{_tag:"None"|"Some"}` object +(from `Option`) — three distinct "no value" encodings; check the schema. + +### 5.5 Unions & discriminators — key varies by union +Effect unions are **not** uniformly `_tag`-discriminated. The discriminator field +depends on the schema: +- **`_tag`** — protocol envelopes (§2), tagged errors, tagged classes + (`ConnectionTarget` `model.ts:41-46`, all `*Error` types). +- **`type`** — `OrchestrationCommand` (`orchestration.ts:465+`), + `OrchestrationEvent` (`:1001+`), `ServerConfigStreamEvent` (`server.ts:504-510`, + additionally tagged with `version:1`). +- **`kind`** — orchestration stream items: `OrchestrationThreadStreamItem` + (`snapshot|event`, `:1115-1124`), `OrchestrationShellStreamItem`/`StreamEvent` + (`snapshot|project-upserted|project-removed|thread-upserted|thread-removed`, + `:421-452`). +- **`status`**, **`tone`**, etc. — plain string-literal enums + (`Schema.Literals([...])`), encoded as bare strings. +Inspect the specific schema to pick the discriminator; do not assume `_tag`. + +### 5.6 Errors, causes & defects +- A declared (expected) RPC error arrives inside an `Exit.Failure.cause` as + `{"_tag":"Fail","error":{ "_tag":"", …fields }}` (§2.2). Error + structs are `Schema.TaggedError…`, so they carry their own `_tag` plus fields + (e.g. `EnvironmentAuthorizationError { _tag, message, requiredScope }`). +- Unexpected crashes arrive as `{"_tag":"Die","defect":}`. `Schema.Defect()` + encodes a JS `Error` as `{ name, message, cause? }` and arbitrary values as + their JSON form (`effect/src/Schema.ts:9130-9174`); it is **best-effort and + lossy** — treat `defect` as opaque diagnostic JSON. +- Some error schemas embed a `cause: Schema.optional(Schema.Defect())` field + (e.g. `OrchestrationDispatchCommandError` `orchestration.ts:1260-1266`). + +### 5.7 Strings with checks +`TrimmedNonEmptyString`/`TrimmedString` **trim on decode & encode** +(`baseSchemas.ts:5-14`) and reject empty; `NonNegativeInt`/`PositiveInt`/`PortSchema` +enforce numeric ranges (`:16-18`). The server rejects violating payloads with a +decode failure (surfaced as a `Die`/protocol error). The Swift client should send +already-trimmed, in-range values. + +### 5.8 Binary & secrets +No inline binary and **no `Schema.Uint8Array`/`Schema.Redacted`** anywhere on the +WS surface (the sole `Schema.Uint8Array` in contracts is `ipc.ts:914`, a desktop +IPC channel, not this WS group). Images/assets go through `assets.createUrl` → HTTP +asset route (§3.6); preview-automation screenshots are base64 **strings**; terminal +I/O is UTF-8 text within `TerminalWriteInput`/`TerminalEvent`. Secrets in settings +(`serverPassword`, provider tokens, `ProviderInstanceEnvironmentVariable.value`) are +plain `Schema.String` on the wire — the server redacts them for the client before +sending (`ServerSettings.redactServerSettingsForClient`, applied in +`apps/server/src/ws.ts:910,1214-1215,1714`), so read-back values may be masked, but +the type is still just a string, with a sibling `sensitive`/`valueRedacted` boolean. + +### 5.9 `_tag` literals ≠ class names (gotcha) +A tagged error's `_tag` string is not always the TypeScript class name. Notably +`KeybindingsConfigError`'s wire `_tag` is **`"KeybindingsConfigParseError"`** +(`keybindings.ts:160`), which is exactly what the reference client switches on +(`packages/client-runtime/src/rpc/session.ts:53`). Match on the literal `_tag` +value from the schema, never the exported symbol name. + +--- + +## 6. Risks & versioning notes for an independent Swift implementation + +1. **Unstable Effect RPC envelope.** Everything in §2 comes from + `effect/unstable/rpc` at `effect@4.0.0-beta.78` + (`node_modules/.pnpm/effect@4.0.0-beta.78_patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5`). + The `_tag`/`Chunk`/`Exit` framing, the Ack protocol, and the ping cadence are + internal and **not a stable public contract** — they can change on any Effect + bump. Pin behavior to this version and add a protocol conformance test. + +2. **Ack backpressure is mandatory (easy to miss).** Failing to `Ack` each + `Chunk` stalls every stream after the first chunk (§2.3). This is the single + most likely bug in a from-scratch client. + +3. **Application-level ping is mandatory for liveness.** The server won't proactively + close on silence, but the *client* must send `{"_tag":"Ping"}` every 5 s and + watch for `{"_tag":"Pong"}`; ~10 s without a pong should be treated as dead + (§1.4). Do not rely solely on TCP/WS control-frame keepalives. + +4. **No reconnect resume.** The socket does not auto-reconnect and there is no + resumption token. You must re-`server.getConfig`, re-`subscribe*`, and + optionally `replayEvents(fromSequenceExclusive)` after every reconnect, then + dedup by `sequence`/`eventId` (§4.3). Snapshots overlap prior events. + +5. **Discriminator field is not uniform** (`_tag` vs `type` vs `kind`) — §5.5. + A generic "decode by `_tag`" will mis-parse orchestration commands/events and + stream items. + +6. **`withDecodingDefault` fields may be omitted by the server** — your decoder + must inject defaults for `runtimeMode`, `interactionMode`, `archivedAt`, + `proposedPlans` (§5.3), or decoding fails on real payloads. + +7. **`ModelSelection` legacy transform.** Always send `{ instanceId, model }`; + the legacy `{ provider, model }` shape is only accepted via a decode-time + promotion (`orchestration.ts:85-102`) and should not be emitted by a new client. + +8. **Ticket lifecycle.** `wsTicket` is short-lived (`expiresAt` in the ticket + result) and must be minted per connection via `POST /api/auth/websocket-ticket` + with a valid access token. A stale ticket → upgrade rejected (401-class). The + header-based (`Authorization: Bearer/DPoP`) upgrade path is available to native + clients as an alternative (§1.2). + +9. **Scope errors are in-band, not fatal.** `EnvironmentAuthorizationError` comes + back as a normal `Exit.Failure` for the offending call; the socket stays open + (§1.2). Handle it per-request. + +10. **Request `id` uniqueness.** Ids are opaque strings but must be unique among + in-flight requests on the connection; reusing an id while a prior request is + open will cross-wire responses (`RpcClient.ts:1069-1077` routes by + `requestId`). A per-connection incrementing counter (as string) is safest. + +11. **Untyped/opaque fields.** `OrchestrationThreadActivity.payload` is + `Schema.Unknown` (`orchestration.ts:318`) and defect `cause`s are opaque JSON — + model these as free-form JSON (`AnyCodable`) in Swift. + +12. **Trimming/normalization.** The server trims strings and enforces int ranges + on decode (§5.7); send normalized values to avoid surprising + round-trip differences. + +--- + +### Appendix A — canonical example frames (reconstructed) + +Client opens `wss://127.0.0.1:3773/ws?wsTicket=…`, then: + +```jsonc +// C→S invoke server.getConfig (id 0) +{"_tag":"Request","id":"0","tag":"server.getConfig","payload":{},"headers":[]} + +// S→C success exit +{"_tag":"Exit","requestId":"0","exit":{"_tag":"Success","value":{ /* ServerConfig */ }}} + +// C→S subscribe to a thread (streaming; id 1) +{"_tag":"Request","id":"1","tag":"orchestration.subscribeThread", + "payload":{"threadId":"th_123"},"headers":[]} + +// S→C first chunk = snapshot +{"_tag":"Chunk","requestId":"1","values":[ + {"kind":"snapshot","snapshot":{"snapshotSequence":100,"thread":{ /* OrchestrationThread */ }}}]} + +// C→S MUST ack to receive more +{"_tag":"Ack","requestId":"1"} + +// S→C live event chunk +{"_tag":"Chunk","requestId":"1","values":[ + {"kind":"event","event":{"sequence":101,"type":"thread.message-sent","aggregateKind":"thread", + "aggregateId":"th_123","occurredAt":"2026-07-04T12:00:00.000Z","eventId":"ev_9", + "commandId":null,"causationEventId":null,"correlationId":null,"metadata":{}, + "payload":{ /* ThreadMessageSentPayload */ }}}]} +{"_tag":"Ack","requestId":"1"} + +// heartbeat (every 5s) +{"_tag":"Ping"} // C→S +{"_tag":"Pong"} // S→C + +// C→S start a turn (write via dispatchCommand; id 2) +{"_tag":"Request","id":"2","tag":"orchestration.dispatchCommand","headers":[], + "payload":{"type":"thread.turn.start","commandId":"cmd_1","threadId":"th_123", + "message":{"messageId":"m_1","role":"user","text":"hi","attachments":[]}, + "runtimeMode":"full-access","interactionMode":"default","createdAt":"2026-07-04T12:00:01.000Z"}} +{"_tag":"Exit","requestId":"2","exit":{"_tag":"Success","value":{"sequence":102}}} + +// C→S cancel the subscription +{"_tag":"Interrupt","requestId":"1"} + +// example failure exit (scope missing) +{"_tag":"Exit","requestId":"3","exit":{"_tag":"Failure","cause":[ + {"_tag":"Fail","error":{"_tag":"EnvironmentAuthorizationError", + "message":"The authenticated token is missing required scope: …","requiredScope":"…"}}]}} +``` + +*(Frames reconstructed from the envelope schemas and serialization code cited +above; field values are illustrative.)* diff --git a/apps/mac/scripts/make-app.sh b/apps/mac/scripts/make-app.sh new file mode 100755 index 00000000000..cc5cc3da3a3 --- /dev/null +++ b/apps/mac/scripts/make-app.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Build SergeCode.app without Xcode: SwiftPM binary + hand-assembled bundle. +# Usage: scripts/make-app.sh [--debug] +set -euo pipefail + +MAC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG="release" +if [[ "${1:-}" == "--debug" ]]; then + CONFIG="debug" +fi + +swift build --package-path "$MAC_DIR" -c "$CONFIG" + +BIN="$MAC_DIR/.build/$CONFIG/SergeCodeMac" +APP="$MAC_DIR/dist/SergeCode.app" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$MAC_DIR/Support/Info.plist" "$APP/Contents/Info.plist" +cp "$BIN" "$APP/Contents/MacOS/SergeCodeMac" + +# Prefer the stable self-signed identity when present: TCC permissions +# (Documents-folder access for the node sidecar) are keyed to the code +# identity, and an ad-hoc signature changes every rebuild — which both +# drops previously granted access and leaves the sidecar's file opens +# hanging in the TCC prompt path when launched via Finder/`open`. Create +# the identity once (see ARCHITECTURE.md "Build without Xcode") and every +# rebuild keeps its grant. +IDENTITY="SergeCode Dev Signing" +if security find-identity -v -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then + codesign --force -s "$IDENTITY" "$APP" +else + echo "note: '$IDENTITY' identity not found/trusted; falling back to ad-hoc signing" >&2 + echo " (TCC grants reset on every rebuild — see ARCHITECTURE.md)" >&2 + codesign --force -s - "$APP" +fi + +echo "Built $APP"