diff --git a/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift b/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift new file mode 100644 index 0000000..8e0f0d9 --- /dev/null +++ b/NucleicRemote/NucleicRemote/AppIntents/SessionIntents.swift @@ -0,0 +1,101 @@ +import AppIntents +import NucleicProtocol + +// MARK: - Open a session (deep-link intent, §4.4) + +/// Open a specific session in the app. Reuses the existing `nucleic://session/` route via +/// `RemoteStore.route(to:)` — so a Spotlight result, a Siri phrase, or a Shortcut jumps straight +/// into the waiting session. `openAppWhenRun` foregrounds the app (this is a navigation action). +struct OpenSessionIntent: AppIntent { + static let title: LocalizedStringResource = "Open Session" + static let description = IntentDescription("Open a Nucleic agent session.") + static let openAppWhenRun = true + + @Parameter(title: "Session") + var session: SessionEntity + + init() {} + init(session: SessionEntity) { self.session = session } + + @MainActor + func perform() async throws -> some IntentResult { + RemoteStore.shared.route(to: SessionID(rawValue: session.id)) + return .result() + } + + static var parameterSummary: some ParameterSummary { + Summary("Open \(\.$session)") + } +} + +// MARK: - Unblock with a follow-up (§4.2, approve-scope) + +/// Send a follow-up prompt to a paused agent — the "type the next step to unblock it" flow that +/// stays at `approve` scope (docs/UX_IOS §2). Runs in the background over the app's live channel +/// (no foregrounding); if the Mac isn't reachable it fails clean with a spoken error (§3.1). +struct SendFollowUpIntent: AppIntent { + static let title: LocalizedStringResource = "Send Follow-Up" + static let description = IntentDescription( + "Send a follow-up prompt to a Nucleic agent to unblock or steer it.") + + @Parameter(title: "Session") + var session: SessionEntity + + @Parameter(title: "Message", requestValueDialog: "What should the agent do next?") + var text: String + + init() {} + init(session: SessionEntity, text: String) { + self.session = session + self.text = text + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + let store = RemoteStore.shared + guard store.isPaired else { throw IntentError.notPaired } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .result(dialog: "That message was empty.") } + guard await store.awaitLiveConnection() else { throw IntentError.macUnreachable } + store.sendInput(trimmed, to: SessionID(rawValue: session.id)) + return .result(dialog: "Sent to \(session.title).") + } + + static var parameterSummary: some ParameterSummary { + Summary("Tell \(\.$session) to \(\.$text)") + } +} + +// MARK: - "What needs me?" (§4.3) + +/// List the agents waiting on the user — for approval, or a next prompt — using the same attention +/// rule as the badge and NEEDS YOU section (`SessionStatus.needsYou`). Answers Siri's "what needs me +/// in Nucleic?" and feeds Shortcuts automations. Runs in the background; returns the list plus a +/// spoken summary. +struct SessionsNeedingMeIntent: AppIntent { + static let title: LocalizedStringResource = "Sessions Needing Me" + static let description = IntentDescription( + "List the Nucleic agents waiting on you — for approval or your next prompt.") + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue<[SessionEntity]> & ProvidesDialog { + let store = RemoteStore.shared + guard store.isPaired else { throw IntentError.notPaired } + store.bootstrapForIntent() + // Give a cold process a brief moment to bring a channel up so the answer is current; fall + // back to the persisted list if the Mac stays unreachable. + _ = await store.awaitLiveConnection(timeout: 4) + let needy = store.liveSessions + .filter { $0.status.needsYou($0.disposition) } + .sorted(by: StatusStyle.attentionThenRecency) + .map(SessionEntity.init) + + let dialog: IntentDialog + switch needy.count { + case 0: dialog = "Nothing needs you right now." + case 1: dialog = "One agent needs you: \(needy[0].title)." + default: dialog = "\(needy.count) agents need you." + } + return .result(value: needy, dialog: dialog) + } +}