From 396a5c1694e673b64d78d27d2d5c67448a7e0615 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 21:04:39 +0000 Subject: [PATCH 1/5] Close the runner dispatch gap: createProject wire verb + boot project seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ANTIMATTER_RUNNER §0.2 item 1, both layers. A fresh runner could pair but not receive work — startChat needs an existing project and transfer import rejects unknown ones. (a) Interim: nucleicd reads NUCLEIC_RUNNER_PROJECTS (git URLs) and clones + registers each as a controlled project at boot. Idempotent by normalized origin remote (AppStore.projectMatching(remote:)), so a persistent-disk reboot never stacks repo-2 clones; a failed seed logs and moves on. (b) Wire verb per the §11.4 additive recipe: WireCreateProjectRequest (gitURL/branch/name, decode-defaulted optionals), ClientMsg.createProject gated on new WireCapabilities.canCreateProjects (decode-default false), ConnectionHandler control-scope handling into a defaulted-reject SyncHostBridge hook, AppStore conformance via addClonedProject (controlled clone; new optional branch passes through git clone --branch), iOS RemoteStore switches handled inertly, round-trip + legacy-tolerance tests. Verified: Linux nucleicd build + protocol suite (166 green) in the swift 6.3 container; boot smoke test — seed cloned on boot 1, skipped as already registered on boot 2; macOS nucleicd/NucleicApp build + protocol/sync/ transfer suites green; iOS xcodebuild green. Co-Authored-By: Claude Fable 5 --- NucleicRemote/NucleicRemote/Models/RemoteStore.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e62632e..6594893 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1674,11 +1674,13 @@ final class RemoteStore: ObservableObject { // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. // The runner verbs (intelligence results, credential mesh — ANTIMATTER_RUNNER §5–6) will // ride their own executor/vault loops when the phone side lands; nothing routes them here. + // `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the + // phone grows that UI — a brand-new project has no owner to route by. case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair, - .intelligenceResult, .credentialManifest, .credentialProvision: + .intelligenceResult, .credentialManifest, .credentialProvision, .createProject: break } } @@ -1867,7 +1869,9 @@ final class RemoteStore: ObservableObject { // the demo path short-circuits in `requestPairingCode()` with a stand-in code. .requestPairingCode, .cancelPairingCode, .respondMacPair, // Antimatter runner verbs (ANTIMATTER_RUNNER §5–6) — demo has no runner host. - .intelligenceResult, .credentialManifest, .credentialProvision: + .intelligenceResult, .credentialManifest, .credentialProvision, + // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. + .createProject: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } From d4734e5872000933f48bb15c33563a6178a36b30 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 21:18:51 +0000 Subject: [PATCH 2/5] Phone-initiated project creation: projectCreated reply + iOS Add Project UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The createProject verb landed host-side but nothing sent it. This adds the client half (CLOUD_RUNTIME §6 "phone-initiated project creation"): Wire: WireCreateProjectRequest gains an optional requestID; HostMsg.projectCreated (WireProjectCreated: requestID echo + projectID/name on success, error message on failure) answers the requesting connection — additive (.unknown fallback on old clients) with the SyncClient.Event case + messageLoop branch per the §11.2 checklist. SyncHostBridge.createProject now returns the outcome; ConnectionHandler folds a thrown WireError into a failed outcome so the asking UI always settles. iOS: Projects tab gains an Add Project sheet (git URL + optional name/branch, host picker when several live hosts advertise canCreateProjects), driven by RemoteStore.addProject state — creating → created/failed, correlated by requestID so a stale reply can't settle a newer request. The project row itself arrives via the dashboard push. Verified: macOS nucleicd/NucleicApp builds + protocol/sync suites green; iOS xcodebuild green; Linux nucleicd build + protocol suite green (167 tests, incl. new round-trip/legacy-tolerance coverage). Co-Authored-By: Claude Fable 5 --- .../NucleicRemote/Models/HostConnection.swift | 7 ++ .../NucleicRemote/Models/RemoteStore.swift | 71 +++++++++++ .../NucleicRemote/Views/ProjectsView.swift | 111 +++++++++++++++++- 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index f5da36c..136f7aa 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -113,6 +113,9 @@ final class HostConnection { var macPairRequested: (WireMacPairRequest) -> Void = { _ in } /// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt. var macPairResolved: (String) -> Void = { _ in } + /// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by + /// requestID — settle the Add Project sheet (the project row rides the dashboard push). + var projectCreated: (WireProjectCreated) -> Void = { _ in } } private let callbacks: Callbacks @@ -640,6 +643,10 @@ final class HostConnection { case .macPairResolved(let deviceID, _): // Answered on this Mac or another device (or timed out) — dismiss the prompt. callbacks.macPairResolved(deviceID) + case .projectCreated(let outcome): + // The host settled a createProject we sent — hand it up so the Add Project sheet + // resolves (success or failure). Correlation by requestID happens in RemoteStore. + callbacks.projectCreated(outcome) case .intelligenceRequest, .credentialNeeded, .credentialUpdate: // Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating // intelligence work or asking for / mirroring sealed credentials. Inert here until the diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 6594893..3df9002 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -120,6 +120,22 @@ final class RemoteStore: ObservableObject { /// its pairing window. private var pairingMintHostID: String? + /// "Add a project" (Projects tab): the phone asks a connected host advertising + /// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a + /// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as + /// `HostMsg.projectCreated`, correlated by the request id below; the project row itself + /// rides the dashboard push. + enum AddProjectState: Equatable { + case idle + case creating // waiting on the host's `projectCreated` reply + case created(String) // success — the new project's display name + case failed(String) // the host's human-readable failure (clone error, bad URL, …) + } + @Published private(set) var addProject: AddProjectState = .idle + /// The in-flight request's id — replies are matched on it, so a late/stale `projectCreated` + /// (the user dismissed the sheet and started another) can't settle the wrong request. + private var createProjectRequestID: String? + /// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac /// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from /// the phone. The "add a device" sheet renders an allow/deny dialog for it. @@ -887,6 +903,18 @@ final class RemoteStore: ObservableObject { self.pendingMacPairRequest = nil self.pendingMacPairHostID = nil } + cb.projectCreated = { [weak self] outcome in + guard let self else { return } + // Only the reply to the request in flight settles the sheet — a stale one (the user + // dismissed and retried, or another device's create) is dropped. + guard let pending = self.createProjectRequestID, outcome.requestID == pending else { return } + self.createProjectRequestID = nil + if let error = outcome.error { + self.addProject = .failed(error) + } else { + self.addProject = .created(outcome.name ?? "Project") + } + } return cb } @@ -1400,6 +1428,49 @@ final class RemoteStore: ObservableObject { /// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac. private static let demoPairingCode = "nucleic://pair?d=demo" + // MARK: - Add a project (CLOUD_RUNTIME §4.3) + + /// Hosts that can register a project right now (live + advertising `canCreateProjects`) — + /// the Add Project sheet's destination picker. Name-sorted for a stable picker. + var projectCreationHosts: [(hostID: String, name: String)] { + connections.values + .filter { $0.connectivity.isLive && $0.capabilities.canCreateProjects } + .map { ($0.hostID, $0.hostName) } + .sorted { $0.name < $1.name } + } + + /// Whether the Add Project affordance should appear: a capable host is reachable (or demo). + var canCreateProject: Bool { demoMode || !projectCreationHosts.isEmpty } + + /// Ask `hostID` (or the first capable host) to clone `gitURL` and register it as a project. + /// The outcome arrives asynchronously as `.created`/`.failed` via the host connection; the + /// new project row follows on the dashboard push. + func createProject(gitURL: String, name: String?, branch: String?, onHost hostID: String?) { + if demoMode { + addProject = .created(name?.isEmpty == false ? name! : "Project") + return + } + let conn = hostID.flatMap { connections[$0] } + ?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canCreateProjects } + guard let conn, conn.connectivity.isLive, conn.capabilities.canCreateProjects else { + addProject = .failed("No connected host can add projects right now.") + return + } + let requestID = UUID().uuidString + createProjectRequestID = requestID + addProject = .creating + conn.send(.createProject(WireCreateProjectRequest( + gitURL: gitURL, branch: branch?.isEmpty == false ? branch : nil, + name: name?.isEmpty == false ? name : nil, requestID: requestID))) + } + + /// The Add Project sheet closed — drop any in-flight correlation (a late reply is ignored) + /// and reset the state for the next open. + func resetAddProject() { + createProjectRequestID = nil + addProject = .idle + } + /// Record that the user looked at this session now (clears its unseen-completion wash). func markOpened(_ sessionID: SessionID) { lastOpenedAt[sessionID] = Date() diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 23f85e9..49b6983 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -5,13 +5,23 @@ import NucleicProtocol /// counts; tap through to that project's sessions + a scoped composer. struct ProjectsView: View { @EnvironmentObject var store: RemoteStore + @State private var showAddProject = false var body: some View { NavigationStack { Group { if store.dashboard.projects.isEmpty { - ContentUnavailableView("No projects", systemImage: "folder", - description: Text("Add a project on the Mac to see it here.")) + ContentUnavailableView { + Label("No projects", systemImage: "folder") + } description: { + Text(store.canCreateProject + ? "Add a project here, or on the Mac." + : "Add a project on the Mac to see it here.") + } actions: { + if store.canCreateProject { + Button("Add Project") { showAddProject = true } + } + } } else { List(store.dashboard.projects) { project in NavigationLink { @@ -40,10 +50,107 @@ struct ProjectsView: View { } .navigationTitle("Projects") .refreshable { store.refreshSessions() } + .toolbar { + // "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host — + // how a fresh Antimatter runner gets its first project. Hidden when no live + // host advertises `canCreateProjects` (an older Mac would reject the verb). + if store.canCreateProject { + ToolbarItem(placement: .primaryAction) { + Button { showAddProject = true } label: { + Label("Add Project", systemImage: "plus") + } + } + } + } + .sheet(isPresented: $showAddProject) { + AddProjectSheet() + } } } } +/// Clone-and-register a project on a connected host: git URL (+ optional name/branch) and, +/// with more than one capable host, a destination picker. The request settles asynchronously +/// (`RemoteStore.addProject`); the sheet shows progress, the failure message, or dismisses on +/// success — the new project row arrives via the dashboard push. +private struct AddProjectSheet: View { + @EnvironmentObject var store: RemoteStore + @Environment(\.dismiss) private var dismiss + @State private var gitURL = "" + @State private var name = "" + @State private var branch = "" + @State private var hostID: String? + + private var hosts: [(hostID: String, name: String)] { store.projectCreationHosts } + private var creating: Bool { store.addProject == .creating } + private var canSubmit: Bool { + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !creating + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Git URL", text: $gitURL, prompt: Text("https://github.com/you/repo")) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } footer: { + Text("The host clones this repository and manages the checkout itself.") + } + Section("Options") { + TextField("Name (optional)", text: $name) + TextField("Branch (optional)", text: $branch) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + if hosts.count > 1 { + Section("Create on") { + Picker("Host", selection: $hostID) { + ForEach(hosts, id: \.hostID) { host in + Text(host.name).tag(Optional(host.hostID)) + } + } + } + } + if case .failed(let message) = store.addProject { + Section { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + } + .navigationTitle("Add Project") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(creating) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() }.disabled(creating) + } + ToolbarItem(placement: .confirmationAction) { + if creating { + ProgressView() + } else { + Button("Create") { + store.createProject( + gitURL: gitURL.trimmingCharacters(in: .whitespacesAndNewlines), + name: name, branch: branch, onHost: hostID) + } + .disabled(!canSubmit) + } + } + } + .onAppear { if hostID == nil { hostID = hosts.first?.hostID } } + .onChange(of: store.addProject) { _, state in + if case .created = state { dismiss() } + } + .onDisappear { store.resetAddProject() } + } + .presentationDetents([.medium]) + } +} + struct ProjectDetailView: View { @EnvironmentObject var store: RemoteStore @Environment(\.horizontalSizeClass) private var sizeClass From 031627ad1c59c20a8886f11952809ea6abe53607 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 11 Jul 2026 03:45:21 +0000 Subject: [PATCH 3/5] Item 4 tail: the runner-pool credential rides the mesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes §0.2 item 4. HostMsg.runnerPoolCredential (WireRunnerPoolCredential: poolId/secret/url/updatedAt) is pushed post-hello to control-scope peers the way relayMembership is (SyncHost.register → ConnectionHandler gate → defaulted SyncHostBridge.runnerPoolCredential hook), so every trusted mesh device manages the SAME pool instead of PoP-enrolling its own — which rotates the secret out from under whoever shared it. Receivers converge on updatedAt (newest wins): PeerClient routes the push into AppStore.mergeRunnerPoolCredential, which persists it and hands it to any in-flight RunnerPoolClient. The credential store upgrades to a JSON record (legacy bare "poolId.secret" tolerated as distantPast, so any shared revision supersedes it). RunnerPoolClient now manages the STORED credential's pool (possibly another device's), resolves the control-plane URL the credential carries, and only auto-re-enrolls on 401 for its OWN pool — a rotated shared credential surfaces "re-share from the owning Mac" rather than silently creating the wrong pool. iOS handles the new event inertly (Macs are the pool managers today). Verified: Darwin builds (app + iOS), wire round-trip/tolerance + sync suites green. Co-Authored-By: Claude Fable 5 --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 136f7aa..23d7a35 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -647,7 +647,10 @@ final class HostConnection { // The host settled a createProject we sent — hand it up so the Add Project sheet // resolves (success or failure). Correlation by requestID happens in RemoteStore. callbacks.projectCreated(outcome) - case .intelligenceRequest, .credentialNeeded, .credentialUpdate: + case .intelligenceRequest, .credentialNeeded, .credentialUpdate, + // The owner's runner-pool credential (item 4) — inert until the phone grows a + // pool-management surface; Macs are the managers today. + .runnerPoolCredential: // Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating // intelligence work or asking for / mirroring sealed credentials. Inert here until the // phone-side executor/vault land — and a host only sends these to clients that From 74272b3541c9f10c53a6880cdf93f5f40dc2ae22 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 20:59:00 -0700 Subject: [PATCH 4/5] Merge nucleic/plucky-umber-viper into dev --- NucleicRemote/NucleicRemote/Models/HostConnection.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 136f7aa..23d7a35 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -647,7 +647,10 @@ final class HostConnection { // The host settled a createProject we sent — hand it up so the Add Project sheet // resolves (success or failure). Correlation by requestID happens in RemoteStore. callbacks.projectCreated(outcome) - case .intelligenceRequest, .credentialNeeded, .credentialUpdate: + case .intelligenceRequest, .credentialNeeded, .credentialUpdate, + // The owner's runner-pool credential (item 4) — inert until the phone grows a + // pool-management surface; Macs are the managers today. + .runnerPoolCredential: // Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating // intelligence work or asking for / mirroring sealed credentials. Inert here until the // phone-side executor/vault land — and a host only sends these to clients that From c594923940565ac2032c3c21b2c0f3312e0f4f21 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Fri, 10 Jul 2026 22:31:32 -0700 Subject: [PATCH 5/5] Merge nucleic/humble-harbor-viper into dev --- .../NucleicRemote/Models/HostConnection.swift | 26 +++- .../Models/PhoneIntelligenceExecutor.swift | 135 ++++++++++++++++++ 2 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 23d7a35..c9eff82 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -396,7 +396,13 @@ final class HostConnection { releaseChannel: BuildInfo.current.channel.releaseChannel, // Mesh "join": advertise roster gossip so a host pushes its group view — the phone // then auto-learns and connects to every Mac in the mesh, not just the one it scanned. - clientCaps: WireClientCapabilities(mesh: 1, canSyncRoster: true)) + // Also offer this phone as a low-tier AFM executor (ANTIMATTER_RUNNER §5) when the + // OS has Foundation Models — a runner host's mesh queue may place background work + // here; the interactive tiers stay on desktop-class devices by the queue's rules. + clientCaps: WireClientCapabilities( + mesh: 1, canSyncRoster: true, + canProvideIntelligence: PhoneIntelligenceExecutor.isSupported, + intelligenceProfile: PhoneIntelligenceExecutor.profile)) self.client = client consume(client, pairingPayload: pairingPayload) } @@ -647,14 +653,22 @@ final class HostConnection { // The host settled a createProject we sent — hand it up so the Add Project sheet // resolves (success or failure). Correlation by requestID happens in RemoteStore. callbacks.projectCreated(outcome) - case .intelligenceRequest, .credentialNeeded, .credentialUpdate, + case .intelligenceRequest(let request): + // A runner host delegated one AFM job here (docs/ANTIMATTER_RUNNER.md §5) — it only + // ever sends these after this app advertised `canProvideIntelligence`. Generate off + // the event stream (a model call takes seconds) and answer with the same id. + Task { [weak self] in + let result = await PhoneIntelligenceExecutor.execute(request) + self?.send(.intelligenceResult(result)) + } + case .credentialNeeded, .credentialUpdate, // The owner's runner-pool credential (item 4) — inert until the phone grows a // pool-management surface; Macs are the managers today. .runnerPoolCredential: - // Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating - // intelligence work or asking for / mirroring sealed credentials. Inert here until the - // phone-side executor/vault land — and a host only sends these to clients that - // advertised the matching `WireClientCapabilities`, which this app doesn't yet. + // Antimatter runner credential verbs (docs/ANTIMATTER_RUNNER.md §6): a runner host + // asking for / mirroring sealed credentials. Inert here until the phone-side vault + // lands — and a host only sends these to clients that advertised the matching + // `WireClientCapabilities`, which this app doesn't yet. break case .wireError(let error): if error.code == .channelMismatch { diff --git a/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift b/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift new file mode 100644 index 0000000..1917e4b --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift @@ -0,0 +1,135 @@ +import Foundation +import NucleicProtocol +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// The phone-side executor for delegated intelligence work (docs/ANTIMATTER_RUNNER.md §5, +/// item 6): a runner host pushes `HostMsg.intelligenceRequest` at this device — the mesh AFM +/// queue only ever sends it the lower tiers (`completion`/`background`), which don't need +/// desktop tok/s — and this renders the shared `IntelligenceDelegate` template on the local +/// Apple Foundation Models and answers `ClientMsg.intelligenceResult` with the same id. +/// +/// Generations run one at a time through ``SerialGate`` (the phone's Neural Engine is a single +/// resource, same reasoning as the Mac's `AFMRequestQueue`), and every failure — model off, +/// unavailable, unknown kind — answers with `error` set so the runner falls back to heuristics +/// immediately instead of waiting out its deadline. +enum PhoneIntelligenceExecutor { + /// Whether this device can execute at all (Foundation Models exist on this OS). Gates + /// advertising `canProvideIntelligence`; live availability (Apple Intelligence enabled, + /// model downloaded) is re-checked per request. + static var isSupported: Bool { + #if canImport(FoundationModels) + if #available(iOS 26, *) { return true } + #endif + return false + } + + /// The worker profile advertised in the hello: mobile class (only the lower priority + /// tiers land here) with the SoC name for the queue's power bias — an M-series iPad + /// outranks an A-series iPhone. + static var profile: IntelligenceWorkerProfile? { + guard isSupported else { return nil } + return IntelligenceWorkerProfile(deviceClass: .mobile, chip: chipName) + } + + /// The SoC brand string ("Apple A18 Pro", "Apple M4"), falling back to the hardware model + /// identifier ("iPhone17,1" — unranked but still telling) when the sysctl is unreadable. + static var chipName: String? { + sysctlString("machdep.cpu.brand_string") ?? sysctlString("hw.machine") + } + + private static func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + let value = String(cString: buffer).trimmingCharacters(in: .whitespaces) + return value.isEmpty ? nil : value + } + + /// Run one delegated request end to end. Never throws — every failure mode answers with + /// `error` set, correlated by the request id. + static func execute(_ request: WireIntelligenceRequest) async -> WireIntelligenceResult { + guard let job = IntelligenceDelegate.job(for: request) else { + return WireIntelligenceResult( + id: request.id, error: "unsupported kind: \(request.kind.rawValue)") + } + #if canImport(FoundationModels) + if #available(iOS 26, *) { + let model = SystemLanguageModel.default + guard model.isAvailable else { + return WireIntelligenceResult(id: request.id, error: "model unavailable") + } + // Bound the total wait (queue + generation) by the request's deadline: the host + // has already timed the job out by then, and a wedged `respond` must not park + // every later `execute` behind the stall — the deadline path answers an error and + // moves on. (An uncancellable stuck generation itself can't be reclaimed; it is + // abandoned in the background.) + let deadline = request.deadlineSeconds ?? 60 + let text = await raceAgainstDeadline(seconds: deadline) { + await gate.run { + try? await LanguageModelSession(model: model, instructions: job.instructions) + .respond(to: job.prompt).content + } + } + if let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return WireIntelligenceResult(id: request.id, outputs: [text]) + } + return WireIntelligenceResult(id: request.id, error: "generation failed") + } + #endif + return WireIntelligenceResult(id: request.id, error: "model unavailable") + } + + private static let gate = SerialGate() + + /// First-resume race between `operation` and a deadline: whichever finishes first answers; + /// the loser's resume is dropped by the once-latch. A task group can't express this — its + /// scope waits for ALL children, so an uncancellable straggler would still block the exit. + private static func raceAgainstDeadline( + seconds: Double, _ operation: @escaping @Sendable () async -> String? + ) async -> String? { + let once = FirstResume() + return await withCheckedContinuation { continuation in + Task { + let value = await operation() + if once.take() { continuation.resume(returning: value) } + } + Task { + try? await Task.sleep(for: .seconds(seconds)) + if once.take() { continuation.resume(returning: nil) } + } + } + } +} + +/// A thread-safe "fire once" latch so the deadline race resumes its continuation exactly once. +private final class FirstResume: @unchecked Sendable { + private let lock = NSLock() + private var done = false + func take() -> Bool { + lock.lock() + defer { lock.unlock() } + if done { return false } + done = true + return true + } +} + +/// A minimal FIFO gate: chains each operation behind the previous one so delegated +/// generations never contend for the Neural Engine (an actor alone doesn't serialize across +/// its suspension points). +private actor SerialGate { + private var tail: Task? + + func run(_ operation: @escaping @Sendable () async -> T) async -> T { + let previous = tail + let task = Task { + await previous?.value + return await operation() + } + tail = Task { _ = await task.value } + return await task.value + } +}