From fe066e558f31df6393696976c8eb072210453021 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:04:56 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(ios):=20read-only=20inspection=20vie?= =?UTF-8?q?ws=20=E2=80=94=20Soul=20/=20Memory=20/=20Skills=20/=20Tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → "Inspect Lisa" links to four GET-only screens (plan §G6, Appendix B): Soul (identity / purpose / constitution + mood bars + values / opinions / desires), Memory (user + memory text), Skills, Tools. A small AsyncContent loader renders loading / error / content uniformly so each view stays a thin wrapper over its fetch. Models mirror src/soul/types.ts and /api/{soul,memory,skills,tools}, lenient (all-optional) so a missing field never breaks decode. Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- .../ios-companion/Sources/InspectViews.swift | 117 ++++++++++++++++++ .../ios-companion/Sources/LisaClient.swift | 6 + packaging/ios-companion/Sources/Models.swift | 49 ++++++++ .../ios-companion/Sources/SettingsView.swift | 11 ++ 4 files changed, 183 insertions(+) create mode 100644 packaging/ios-companion/Sources/InspectViews.swift diff --git a/packaging/ios-companion/Sources/InspectViews.swift b/packaging/ios-companion/Sources/InspectViews.swift new file mode 100644 index 0000000..7446707 --- /dev/null +++ b/packaging/ios-companion/Sources/InspectViews.swift @@ -0,0 +1,117 @@ +import SwiftUI + +/// Read-only "speed views" of Lisa's interior (docs/IOS_COMPANION_PLAN.md §G6, +/// Appendix B): Soul, Memory, Skills, Tools. All GET-only; reached from Settings. + +/// A tiny loader that runs an async fetch on appear and renders loading / error / +/// content, so each inspection view stays a one-liner over its data. +struct AsyncContent: View { + let load: () async throws -> T + @ViewBuilder let content: (T) -> Content + + @State private var value: T? + @State private var error: String? + + var body: some View { + Group { + if let value { + content(value) + } else if let error { + ContentUnavailableView("Couldn't load", systemImage: "exclamationmark.triangle", description: Text(error)) + } else { + ProgressView() + } + } + .task { + do { value = try await load(); error = nil } + catch { self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)" } + } + } +} + +struct SoulView: View { + @EnvironmentObject var app: AppState + var body: some View { + AsyncContent(load: { try await app.client.soul() }) { resp in + if !resp.born || resp.summary == nil { + ContentUnavailableView("Not born yet", systemImage: "moon.stars", + description: Text("Lisa hasn't run her birth ritual.")) + } else if let s = resp.summary { + List { + if let n = s.name, !n.isEmpty { Section("Name") { Text(n) } } + soulText("Identity", s.identity) + soulText("Purpose", s.purpose) + soulText("Constitution", s.constitution) + if let emo = s.emotions?.values, !emo.isEmpty { + Section("Mood") { + ForEach(emo.sorted(by: { $0.value > $1.value }), id: \.key) { k, v in + HStack { + Text(k).font(.subheadline) + Spacer() + ProgressView(value: max(0, min(1, v))).frame(width: 120) + } + } + } + } + soulItems("Values", s.values) + soulItems("Opinions", s.opinions) + soulItems("Desires", s.desires) + if let t = s.tampered, !t.isEmpty { + Section("⚠ Tampered files") { ForEach(t, id: \.self) { Text($0).font(.caption.monospaced()) } } + } + } + } + } + .navigationTitle("Soul") + } + + @ViewBuilder private func soulText(_ title: String, _ value: String?) -> some View { + if let value, !value.isEmpty { Section(title) { Text(value).font(.callout) } } + } + @ViewBuilder private func soulItems(_ title: String, _ items: [SoulItem]?) -> some View { + if let items, !items.isEmpty { + Section("\(title) (\(items.count))") { + ForEach(Array(items.enumerated()), id: \.offset) { _, it in Text(it.label).font(.callout) } + } + } + } +} + +struct MemoryView: View { + @EnvironmentObject var app: AppState + var body: some View { + AsyncContent(load: { try await app.client.memory() }) { mem in + List { + if !mem.user.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section("Who you are (user)") { Text(mem.user).font(.system(.callout, design: .monospaced)) } + } + if !mem.memory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section("Memory") { Text(mem.memory).font(.system(.callout, design: .monospaced)) } + } + } + } + .navigationTitle("Memory") + } +} + +struct NamedListView: View { + let title: String + let load: () async throws -> [NamedItem] + var body: some View { + AsyncContent(load: load) { items in + if items.isEmpty { + ContentUnavailableView("None", systemImage: "tray", description: Text("Nothing here.")) + } else { + List(items) { item in + VStack(alignment: .leading, spacing: 2) { + Text(item.name).font(.headline) + if let d = item.description, !d.isEmpty { + Text(d).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(title) + } +} diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index 49cdcb9..e64af19 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -72,6 +72,12 @@ final class LisaClient { func islandPing() async throws -> IslandPing { try await decode("/api/island/ping", as: IslandPing.self) } func controlPolicy() async throws -> ControlPolicy { try await decode("/api/control/policy", as: ControlPolicy.self) } + // ── read: inspection ── + func soul() async throws -> SoulResponse { try await decode("/api/soul", as: SoulResponse.self) } + func memory() async throws -> MemoryResponse { try await decode("/api/memory", as: MemoryResponse.self) } + func skills() async throws -> [NamedItem] { try await decode("/api/skills", as: SkillsResponse.self).skills } + func tools() async throws -> [NamedItem] { try await decode("/api/tools", as: ToolsResponse.self).tools } + // ── control: managed agents ── func managedStart(task: String) async throws { try await fire("/api/agents/managed/start", json: ["task": task]) } func managedSend(_ id: String, _ text: String) async throws { try await fire("/api/agents/managed/\(id)/send", json: ["text": text]) } diff --git a/packaging/ios-companion/Sources/Models.swift b/packaging/ios-companion/Sources/Models.swift index c6b26a6..b767a37 100644 --- a/packaging/ios-companion/Sources/Models.swift +++ b/packaging/ios-companion/Sources/Models.swift @@ -80,3 +80,52 @@ struct PushPrefs: Codable, Equatable { var idle: Bool = true var advisor: Bool = false } + +// ── read-only inspection (/api/soul, /api/memory, /api/skills, /api/tools) ── + +struct SoulResponse: Codable { + var born: Bool + var summary: SoulSummary? +} + +/// Mirrors src/soul/types.ts SoulSummary, but lenient (optional) — a roster +/// glance should render whatever the server sends, not fail on a missing field. +struct SoulSummary: Codable { + var name: String? + var identity: String? + var purpose: String? + var constitution: String? + var emotions: Emotions? + var values: [SoulItem]? + var opinions: [SoulItem]? + var desires: [SoulItem]? + var tampered: [String]? +} + +struct Emotions: Codable { + var values: [String: Double]? +} + +/// A values/opinions/desires row. Their exact key varies, so accept several and +/// surface the first present (see ValueEntry/OpinionEntry/DesireEntry server-side). +struct SoulItem: Codable, Hashable { + var name: String? + var statement: String? + var what: String? + var text: String? + var summary: String? + var label: String { statement ?? what ?? text ?? summary ?? name ?? "—" } +} + +struct MemoryResponse: Codable { + var user: String + var memory: String +} + +struct NamedItem: Codable, Identifiable, Hashable { + var name: String + var description: String? + var id: String { name } +} +struct SkillsResponse: Codable { var skills: [NamedItem] } +struct ToolsResponse: Codable { var tools: [NamedItem] } diff --git a/packaging/ios-companion/Sources/SettingsView.swift b/packaging/ios-companion/Sources/SettingsView.swift index 7cc9140..b4eeffb 100644 --- a/packaging/ios-companion/Sources/SettingsView.swift +++ b/packaging/ios-companion/Sources/SettingsView.swift @@ -79,6 +79,17 @@ struct SettingsView: View { Text("Change these on the Mac (localhost only).").font(.caption).foregroundStyle(.secondary) } + Section("Inspect Lisa") { + NavigationLink { SoulView() } label: { Label("Soul", systemImage: "sparkles") } + NavigationLink { MemoryView() } label: { Label("Memory", systemImage: "brain") } + NavigationLink { NamedListView(title: "Skills", load: { try await app.client.skills() }) } label: { + Label("Skills", systemImage: "wand.and.stars") + } + NavigationLink { NamedListView(title: "Tools", load: { try await app.client.tools() }) } label: { + Label("Tools", systemImage: "hammer") + } + } + if !status.isEmpty { Section { Text(status).font(.caption).foregroundStyle(.secondary) } } From b1361ad8589058a0b3a0c2acf15e7d2b179cbf82 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:06:54 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(ios):=20Reve=20tab=20=E2=80=94=20rec?= =?UTF-8?q?ap,=20"while=20you=20were=20away",=20desire,=20advisor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tab (plan Appendix B): a "while you were away" idle note + current desire (from /api/island/ping), an agent-activity recap over a 2h/8h/24h window (/api/agents/recap), and advisor suggestions (/api/advisor/latest) that can be dismissed — POST /api/advisor/dismiss feeds the server's down-weighting loop. Loader re-runs when the window changes or pairing flips on. Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- packaging/ios-companion/Sources/App.swift | 2 + .../ios-companion/Sources/LisaClient.swift | 9 ++ packaging/ios-companion/Sources/Models.swift | 18 ++++ .../ios-companion/Sources/ReveView.swift | 96 +++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 packaging/ios-companion/Sources/ReveView.swift diff --git a/packaging/ios-companion/Sources/App.swift b/packaging/ios-companion/Sources/App.swift index 4fe6eb5..2afa819 100644 --- a/packaging/ios-companion/Sources/App.swift +++ b/packaging/ios-companion/Sources/App.swift @@ -18,6 +18,8 @@ struct RootView: View { .tabItem { Label("Dispatch", systemImage: "cpu") } ChatView() .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") } + ReveView() + .tabItem { Label("Reve", systemImage: "moon.stars") } SettingsView() .tabItem { Label("Settings", systemImage: "gearshape") } } diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index e64af19..53ba482 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -78,6 +78,15 @@ final class LisaClient { func skills() async throws -> [NamedItem] { try await decode("/api/skills", as: SkillsResponse.self).skills } func tools() async throws -> [NamedItem] { try await decode("/api/tools", as: ToolsResponse.self).tools } + // ── read: Reve ── + func recap(sinceMinutes: Int = 120) async throws -> RecapResponse { + try await decode("/api/agents/recap?sinceMinutes=\(sinceMinutes)", as: RecapResponse.self) + } + func advisorLatest() async throws -> AdvisorResponse { try await decode("/api/advisor/latest", as: AdvisorResponse.self) } + func advisorDismiss(id: String, category: String?) async throws { + try await fire("/api/advisor/dismiss", json: ["id": id, "category": category ?? ""]) + } + // ── control: managed agents ── func managedStart(task: String) async throws { try await fire("/api/agents/managed/start", json: ["task": task]) } func managedSend(_ id: String, _ text: String) async throws { try await fire("/api/agents/managed/\(id)/send", json: ["text": text]) } diff --git a/packaging/ios-companion/Sources/Models.swift b/packaging/ios-companion/Sources/Models.swift index b767a37..f441408 100644 --- a/packaging/ios-companion/Sources/Models.swift +++ b/packaging/ios-companion/Sources/Models.swift @@ -129,3 +129,21 @@ struct NamedItem: Codable, Identifiable, Hashable { } struct SkillsResponse: Codable { var skills: [NamedItem] } struct ToolsResponse: Codable { var tools: [NamedItem] } + +// ── Reve (/api/agents/recap, /api/advisor/latest) ── + +struct RecapResponse: Codable { + var text: String + var sinceMinutes: Int? +} + +struct AdvisorSuggestion: Codable, Identifiable { + var id: String + var category: String? + var urgency: String? + var text: String +} +struct AdvisorResponse: Codable { + var suggestions: [AdvisorSuggestion] + var at: String? +} diff --git a/packaging/ios-companion/Sources/ReveView.swift b/packaging/ios-companion/Sources/ReveView.swift new file mode 100644 index 0000000..f4609ad --- /dev/null +++ b/packaging/ios-companion/Sources/ReveView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +/// Reve — Lisa's reflective surface (docs/IOS_COMPANION_PLAN.md Appendix B): a +/// "while you were away" note + current desire, a recap of recent agent activity +/// over a chosen window, and advisor suggestions (dismissable — feeds the +/// server's "learn to shut up" loop). +struct ReveView: View { + @EnvironmentObject var app: AppState + + @State private var ping: IslandPing? + @State private var recap: String = "" + @State private var suggestions: [AdvisorSuggestion] = [] + @State private var window = 120 // minutes + @State private var error: String? + @State private var loading = false + + private let windows: [(String, Int)] = [("2h", 120), ("8h", 480), ("24h", 1440)] + + var body: some View { + NavigationStack { + Group { + if !app.config.isConfigured { + ContentUnavailableView("Not paired", systemImage: "wifi.slash", + description: Text("Add your Mac in Settings.")) + } else { + List { + if let p = ping { + if let note = p.last_idle_message_text, !note.isEmpty { + Section("While you were away") { Text(note).font(.callout) } + } + if let desire = p.current_desire, !desire.isEmpty { + Section("Current desire") { + Label(desire, systemImage: "scope").font(.callout) + } + } + } + + Section { + Picker("Window", selection: $window) { + ForEach(windows, id: \.1) { Text($0.0).tag($0.1) } + } + .pickerStyle(.segmented) + if recap.isEmpty { + Text(loading ? "…" : "No agent activity in this window.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text(recap).font(.system(.callout, design: .monospaced)) + } + } header: { Text("Recap") } + + if !suggestions.isEmpty { + Section("Suggestions") { + ForEach(suggestions) { s in + VStack(alignment: .leading, spacing: 4) { + if let c = s.category { + Text(c.uppercased()).font(.caption2).foregroundStyle(.secondary) + } + Text(s.text).font(.callout) + Button("Dismiss", role: .destructive) { dismiss(s) } + .font(.caption).buttonStyle(.borderless) + } + } + } + } + + if let error { Section { Text(error).font(.caption).foregroundStyle(.secondary) } } + } + } + } + .navigationTitle("Reve") + .refreshable { await load() } + .task(id: ReveLoadKey(window: window, configured: app.config.isConfigured)) { await load() } + } + } + + private func load() async { + guard app.config.isConfigured else { return } + loading = true + defer { loading = false } + async let pingResult = app.client.islandPing() + async let recapResult = app.client.recap(sinceMinutes: window) + async let advisorResult = app.client.advisorLatest() + ping = try? await pingResult + recap = (try? await recapResult)?.text ?? "" + suggestions = (try? await advisorResult)?.suggestions ?? [] + error = nil + } + + private func dismiss(_ s: AdvisorSuggestion) { + suggestions.removeAll { $0.id == s.id } + Task { try? await app.client.advisorDismiss(id: s.id, category: s.category) } + } +} + +/// Re-run the loader when the window changes or pairing flips on. +private struct ReveLoadKey: Equatable { let window: Int; let configured: Bool } From 7e4ee739e4625e498f0e89effaa57bd2594b8f3b Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:08:52 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(ios):=20Sense=20tab=20=E2=80=94=20co?= =?UTF-8?q?nsent=20(revoke-only)=20+=20recent=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tab (plan §G6): per-signal consent state from /api/consent and the last ambient events from /api/sense/recent. Revoke-only by design — tightening consent is safe from anywhere, but granting a sensitive signal remotely would widen the surface, which the privacy floor (§7.2) keeps a Mac action; the footer points grants at the Mac. Revoke / revoke-all reload the list. Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- packaging/ios-companion/Sources/App.swift | 2 + .../ios-companion/Sources/LisaClient.swift | 6 ++ packaging/ios-companion/Sources/Models.swift | 22 +++++ .../ios-companion/Sources/SenseView.swift | 87 +++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 packaging/ios-companion/Sources/SenseView.swift diff --git a/packaging/ios-companion/Sources/App.swift b/packaging/ios-companion/Sources/App.swift index 2afa819..df65db9 100644 --- a/packaging/ios-companion/Sources/App.swift +++ b/packaging/ios-companion/Sources/App.swift @@ -20,6 +20,8 @@ struct RootView: View { .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") } ReveView() .tabItem { Label("Reve", systemImage: "moon.stars") } + SenseView() + .tabItem { Label("Sense", systemImage: "sensor.tag.radiowaves.forward") } SettingsView() .tabItem { Label("Settings", systemImage: "gearshape") } } diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index 53ba482..bf6ad93 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -87,6 +87,12 @@ final class LisaClient { try await fire("/api/advisor/dismiss", json: ["id": id, "category": category ?? ""]) } + // ── Sense: consent (revoke-only from a phone) + events ── + func consent() async throws -> [ConsentRow] { try await decode("/api/consent", as: ConsentResponse.self).grants } + func consentRevoke(signal: String) async throws { try await fire("/api/consent/revoke", json: ["signal": signal]) } + func consentRevokeAll() async throws { try await fire("/api/consent/revoke-all") } + func senseRecent() async throws -> [SenseEvent] { try await decode("/api/sense/recent", as: SenseResponse.self).events } + // ── control: managed agents ── func managedStart(task: String) async throws { try await fire("/api/agents/managed/start", json: ["task": task]) } func managedSend(_ id: String, _ text: String) async throws { try await fire("/api/agents/managed/\(id)/send", json: ["text": text]) } diff --git a/packaging/ios-companion/Sources/Models.swift b/packaging/ios-companion/Sources/Models.swift index f441408..16d2876 100644 --- a/packaging/ios-companion/Sources/Models.swift +++ b/packaging/ios-companion/Sources/Models.swift @@ -147,3 +147,25 @@ struct AdvisorResponse: Codable { var suggestions: [AdvisorSuggestion] var at: String? } + +// ── Sense (/api/consent, /api/sense/recent) ── + +struct ConsentRow: Codable, Identifiable { + var signal: String + var granted: Bool + var grantedAt: String? + var description: String? + var id: String { signal } +} +struct ConsentResponse: Codable { var grants: [ConsentRow] } + +struct SenseEvent: Codable, Identifiable { + var signal: String + var kind: String + var app: String? + var title: String? + var summary: String + var ts: Double + var id: String { "\(signal)/\(kind)/\(ts)" } +} +struct SenseResponse: Codable { var events: [SenseEvent] } diff --git a/packaging/ios-companion/Sources/SenseView.swift b/packaging/ios-companion/Sources/SenseView.swift new file mode 100644 index 0000000..85ca0f5 --- /dev/null +++ b/packaging/ios-companion/Sources/SenseView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Sense — ambient-signal consent + recent events (docs/IOS_COMPANION_PLAN.md §G6, +/// §7.2). Deliberately *revoke-only* from a phone: tightening consent is always +/// safe, but granting a sensitive signal remotely would widen the surface, which +/// the privacy floor says stays a Mac action. So we show state + let you revoke, +/// and point grants at the Mac. +struct SenseView: View { + @EnvironmentObject var app: AppState + + @State private var grants: [ConsentRow] = [] + @State private var events: [SenseEvent] = [] + @State private var error: String? + + var body: some View { + NavigationStack { + Group { + if !app.config.isConfigured { + ContentUnavailableView("Not paired", systemImage: "wifi.slash", + description: Text("Add your Mac in Settings.")) + } else { + List { + Section { + ForEach(grants) { row in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(row.signal).font(.headline) + if let d = row.description, !d.isEmpty { + Text(d).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if row.granted { + Button("Revoke", role: .destructive) { revoke(row.signal) } + .font(.caption).buttonStyle(.borderless) + } else { + Text("off").font(.caption).foregroundStyle(.secondary) + } + } + } + if grants.contains(where: { $0.granted }) { + Button("Revoke all", role: .destructive) { revokeAll() } + } + } header: { Text("Consent") } footer: { + Text("Tightening is safe from anywhere. Grant new signals on the Mac (privacy floor).") + } + + Section("Recent events") { + if events.isEmpty { + Text("Nothing captured.").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(events) { e in + VStack(alignment: .leading, spacing: 2) { + Text(e.summary).font(.callout).lineLimit(2) + Text("\(e.signal) · \(e.kind)\(e.app.map { " · \($0)" } ?? "")") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + + if let error { Section { Text(error).font(.caption).foregroundStyle(.secondary) } } + } + } + } + .navigationTitle("Sense") + .refreshable { await load() } + .task(id: app.config) { await load() } + } + } + + private func load() async { + guard app.config.isConfigured else { return } + async let g = app.client.consent() + async let e = app.client.senseRecent() + grants = (try? await g) ?? [] + events = (try? await e) ?? [] + error = grants.isEmpty && events.isEmpty ? "Couldn't reach Lisa." : nil + } + + private func revoke(_ signal: String) { + Task { try? await app.client.consentRevoke(signal: signal); await load() } + } + private func revokeAll() { + Task { try? await app.client.consentRevokeAll(); await load() } + } +} From 7af615f6c67d08fb6aab21af62b302cceae416b8 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:10:43 +0800 Subject: [PATCH 04/12] feat(ios): live mood indicator in Chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mood chip above the chat transcript, seeded from /api/island/ping and kept live via the `mood` SSE event. Honest scope: this wires the mood *data* (the unblocked part); it's a stand-in for the mood *portrait* — bundling the mac-client's portrait art is a follow-up (the iOS app has no asset catalog yet). Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- .../ios-companion/Sources/ChatView.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packaging/ios-companion/Sources/ChatView.swift b/packaging/ios-companion/Sources/ChatView.swift index b7c0b4c..0a5344d 100644 --- a/packaging/ios-companion/Sources/ChatView.swift +++ b/packaging/ios-companion/Sources/ChatView.swift @@ -4,7 +4,23 @@ import SwiftUI final class ChatModel: ObservableObject { @Published var transcript = "" @Published var sending = false + @Published var mood = "" private var task: Task? + private var moodTask: Task? + + /// Seed the mood from a ping, then track the `mood` SSE for live changes. + func startMood(_ client: LisaClient) { + moodTask?.cancel() + moodTask = Task { @MainActor in + if let p = try? await client.islandPing() { mood = p.mood } + do { + for try await msg in client.eventsStream() where msg.type == "mood" { + if let s = msg.slug { mood = s } + } + } catch { /* stream dropped — reseeded on next appear */ } + } + } + func stopMood() { moodTask?.cancel(); moodTask = nil } func send(_ text: String, client: LisaClient) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -32,6 +48,12 @@ struct ChatView: View { var body: some View { NavigationStack { VStack(spacing: 0) { + if !model.mood.isEmpty { + MoodChip(mood: model.mood) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal).padding(.vertical, 6) + Divider() + } ScrollView { Text(model.transcript.isEmpty ? "Say hi to Lisa." : model.transcript) .frame(maxWidth: .infinity, alignment: .leading) @@ -54,6 +76,47 @@ struct ChatView: View { .padding() } .navigationTitle("Chat") + .task(id: app.config) { model.startMood(app.client) } + .onDisappear { model.stopMood() } + } + } +} + +/// Lisa's current mood as a small chip. A stand-in for the mood *portrait* — +/// the data is wired (mood SSE); bundling the mac-client's portrait art is a +/// follow-up (the iOS app has no asset catalog yet). +struct MoodChip: View { + let mood: String + var body: some View { + HStack(spacing: 6) { + Image(systemName: symbol).foregroundStyle(color) + Text(mood.capitalized).font(.subheadline.weight(.medium)) + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(color.opacity(0.12), in: Capsule()) + } + + private var symbol: String { + switch mood.lowercased() { + case "happy", "content", "joyful", "playful": return "face.smiling" + case "curious", "intrigued": return "sparkle.magnifyingglass" + case "proud": return "star.fill" + case "weary", "tired": return "moon.zzz" + case "frustrated", "annoyed": return "exclamationmark.bubble" + case "affectionate", "warm": return "heart.fill" + case "awe", "wonder": return "sparkles" + default: return "circle.fill" + } + } + private var color: Color { + switch mood.lowercased() { + case "happy", "content", "joyful", "playful": return .green + case "curious", "intrigued", "awe", "wonder": return .blue + case "proud": return .yellow + case "weary", "tired": return .gray + case "frustrated", "annoyed": return .red + case "affectionate", "warm": return .pink + default: return .secondary } } } From 4e19be1c3965d7be6f6d8a7f084d3c59dfca54a1 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:12:06 +0800 Subject: [PATCH 05/12] feat(ios): paired-devices list in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → "Paired devices" lists each per-device token (/api/devices) with platform + last-seen. Read-only on the phone: revoking is loopback-only on the server (a Mac-owner action), so the footer points there rather than offering a button that would 403. Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- .../ios-companion/Sources/InspectViews.swift | 33 +++++++++++++++++++ .../ios-companion/Sources/LisaClient.swift | 3 ++ packaging/ios-companion/Sources/Models.swift | 10 ++++++ .../ios-companion/Sources/SettingsView.swift | 4 +++ 4 files changed, 50 insertions(+) diff --git a/packaging/ios-companion/Sources/InspectViews.swift b/packaging/ios-companion/Sources/InspectViews.swift index 7446707..88ff563 100644 --- a/packaging/ios-companion/Sources/InspectViews.swift +++ b/packaging/ios-companion/Sources/InspectViews.swift @@ -94,6 +94,39 @@ struct MemoryView: View { } } +struct DevicesView: View { + @EnvironmentObject var app: AppState + var body: some View { + AsyncContent(load: { try await app.client.devices() }) { devices in + List { + Section { + if devices.isEmpty { + Text("No paired devices.").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(devices) { d in + VStack(alignment: .leading, spacing: 2) { + Text(d.name).font(.headline) + Text(d.platform + (d.lastSeenAt.map { " · seen \(Self.rel($0))" } ?? "")) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } footer: { + Text("Each device has its own revocable token. Revoke one on the Mac (localhost only).") + } + } + } + .navigationTitle("Paired devices") + } + + /// Relative time from an epoch-ms timestamp. + static func rel(_ ms: Double) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: Date(timeIntervalSince1970: ms / 1000), relativeTo: Date()) + } +} + struct NamedListView: View { let title: String let load: () async throws -> [NamedItem] diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index bf6ad93..d0e2683 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -93,6 +93,9 @@ final class LisaClient { func consentRevokeAll() async throws { try await fire("/api/consent/revoke-all") } func senseRecent() async throws -> [SenseEvent] { try await decode("/api/sense/recent", as: SenseResponse.self).events } + // ── read: paired devices (revoke is a Mac-only action) ── + func devices() async throws -> [DeviceInfo] { try await decode("/api/devices", as: DevicesResponse.self).devices } + // ── control: managed agents ── func managedStart(task: String) async throws { try await fire("/api/agents/managed/start", json: ["task": task]) } func managedSend(_ id: String, _ text: String) async throws { try await fire("/api/agents/managed/\(id)/send", json: ["text": text]) } diff --git a/packaging/ios-companion/Sources/Models.swift b/packaging/ios-companion/Sources/Models.swift index 16d2876..dd90de0 100644 --- a/packaging/ios-companion/Sources/Models.swift +++ b/packaging/ios-companion/Sources/Models.swift @@ -169,3 +169,13 @@ struct SenseEvent: Codable, Identifiable { var id: String { "\(signal)/\(kind)/\(ts)" } } struct SenseResponse: Codable { var events: [SenseEvent] } + +// ── Devices (/api/devices) — list is token-auth; revoke is Mac-only ── +struct DeviceInfo: Codable, Identifiable { + var id: String + var name: String + var platform: String + var createdAt: Double? + var lastSeenAt: Double? +} +struct DevicesResponse: Codable { var devices: [DeviceInfo] } diff --git a/packaging/ios-companion/Sources/SettingsView.swift b/packaging/ios-companion/Sources/SettingsView.swift index b4eeffb..bc272ef 100644 --- a/packaging/ios-companion/Sources/SettingsView.swift +++ b/packaging/ios-companion/Sources/SettingsView.swift @@ -79,6 +79,10 @@ struct SettingsView: View { Text("Change these on the Mac (localhost only).").font(.caption).foregroundStyle(.secondary) } + Section { + NavigationLink { DevicesView() } label: { Label("Paired devices", systemImage: "iphone.gen3") } + } + Section("Inspect Lisa") { NavigationLink { SoulView() } label: { Label("Soul", systemImage: "sparkles") } NavigationLink { MemoryView() } label: { Label("Memory", systemImage: "brain") } From 6073b68ba75e35d77d26ef751e298be7ca2dc554 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:13:45 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(ios):=20dispatch=20ledger=20view=20?= =?UTF-8?q?=E2=80=94=20list=20+=20per-entry=20log=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dispatch tab's toolbar opens Lisa's own fire-and-forget dispatches (the ledger, distinct from the observed-agent roster): pid / task / alive from /api/dispatch/list, and a captured log tail per entry from /api/dispatch/status. The status endpoint is policy-gated, so a 403/404 just leaves the tail empty. Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- .../Sources/DispatchLedgerView.swift | 87 +++++++++++++++++++ .../ios-companion/Sources/LisaClient.swift | 4 + packaging/ios-companion/Sources/Models.swift | 11 +++ .../ios-companion/Sources/RosterView.swift | 7 ++ 4 files changed, 109 insertions(+) create mode 100644 packaging/ios-companion/Sources/DispatchLedgerView.swift diff --git a/packaging/ios-companion/Sources/DispatchLedgerView.swift b/packaging/ios-companion/Sources/DispatchLedgerView.swift new file mode 100644 index 0000000..0bbe887 --- /dev/null +++ b/packaging/ios-companion/Sources/DispatchLedgerView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// LISA's own fire-and-forget dispatches (the ledger), distinct from the +/// observed-agent roster (docs/IOS_COMPANION_PLAN.md §6.2): pid / task / alive, +/// from /api/dispatch/list, with a captured log tail per entry from +/// /api/dispatch/status. Reached from the Dispatch tab's toolbar. +struct DispatchLedgerView: View { + @EnvironmentObject var app: AppState + @State private var items: [DispatchView] = [] + @State private var error: String? + @State private var loaded = false + + var body: some View { + Group { + if !loaded { + ProgressView() + } else if let error { + ContentUnavailableView("Couldn't load", systemImage: "exclamationmark.triangle", description: Text(error)) + } else if items.isEmpty { + ContentUnavailableView("No dispatches", systemImage: "tray", + description: Text("Lisa hasn't dispatched any agents recently.")) + } else { + List(items) { d in + NavigationLink { DispatchDetailView(entry: d) } label: { + HStack(spacing: 10) { + Circle().fill(d.alive ? .blue : .gray).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(d.task).font(.subheadline).lineLimit(1) + Text("\(d.agent) · pid \(d.pid)\(d.alive ? " · alive" : "")") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + } + } + .navigationTitle("Dispatches") + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + do { items = try await app.client.dispatchList(); error = nil } + catch { self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)" } + loaded = true + } +} + +struct DispatchDetailView: View { + @EnvironmentObject var app: AppState + let entry: DispatchView + @State private var tail = "" + @State private var status: DispatchStatus? + + var body: some View { + List { + Section("Dispatch") { + LabeledContent("Agent", value: entry.agent) + LabeledContent("Task", value: entry.task) + LabeledContent("pid", value: String(entry.pid)) + LabeledContent("Alive", value: (status?.alive ?? entry.alive) ? "yes" : "no") + LabeledContent("cwd", value: entry.cwd) + } + Section("Log tail") { + if tail.isEmpty { + Text(entry.hasLog ? "…" : "No log captured.").font(.caption).foregroundStyle(.secondary) + } else { + ScrollView(.horizontal) { + Text(tail).font(.system(.caption2, design: .monospaced)) + } + } + } + } + .navigationTitle(entry.agent) + .navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + // status is gated by the control policy; tolerate a 403/404 (leave tail empty). + if let s = try? await app.client.dispatchStatus(id: entry.id) { + status = s + tail = s.tail ?? "" + } + } +} diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index d0e2683..308eb0f 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -69,6 +69,10 @@ final class LisaClient { // ── read ── func sessions() async throws -> [AgentSession] { try await decode("/api/agents/sessions", as: SessionsResponse.self).sessions } func dispatchList() async throws -> [DispatchView] { try await decode("/api/dispatch/list", as: DispatchListResponse.self).dispatches } + func dispatchStatus(id: String) async throws -> DispatchStatus { + let enc = id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? id + return try await decode("/api/dispatch/status?id=\(enc)", as: DispatchStatus.self) + } func islandPing() async throws -> IslandPing { try await decode("/api/island/ping", as: IslandPing.self) } func controlPolicy() async throws -> ControlPolicy { try await decode("/api/control/policy", as: ControlPolicy.self) } diff --git a/packaging/ios-companion/Sources/Models.swift b/packaging/ios-companion/Sources/Models.swift index dd90de0..e7701cc 100644 --- a/packaging/ios-companion/Sources/Models.swift +++ b/packaging/ios-companion/Sources/Models.swift @@ -59,6 +59,17 @@ struct DispatchListResponse: Codable { var dispatches: [DispatchView] } +/// /api/dispatch/status?id= — a DispatchView plus a captured log tail. +struct DispatchStatus: Codable { + var ok: Bool + var id: String? + var agent: String? + var task: String? + var startedAt: String? + var alive: Bool? + var tail: String? +} + struct IslandPing: Codable { var online: Bool var mood: String diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index 338f65c..ba70f71 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -118,6 +118,13 @@ struct RosterView: View { } .navigationTitle("Dispatch") .navigationDestination(for: AgentSession.self) { SessionDetailView(session: $0) } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { DispatchLedgerView() } label: { + Image(systemName: "list.bullet.rectangle") + } + } + } .refreshable { await model.load(app.client) } .task(id: app.config) { await model.load(app.client) From 27b85aca1b71ca9a18dd9769f86bafb822a39d27 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:14:56 +0800 Subject: [PATCH 07/12] feat(ios): SSE auto-reconnect with backoff + foreground resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roster stream now reconnects on drop with exponential backoff (1→30s cap), doing a full /api/agents/sessions resync before each retry so transitions during the gap aren't lost; healthy traffic resets the backoff. On return to foreground (scenePhase → .active) it resyncs and reconnects, since iOS suspends SSE in the background. Previously a dropped stream just went silent until the next appear. Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- .../ios-companion/Sources/RosterView.swift | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index ba70f71..8c55764 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -20,15 +20,23 @@ final class RosterModel: ObservableObject { func startStream(_ client: LisaClient) { streamTask?.cancel() streamTask = Task { @MainActor in - do { - for try await msg in client.eventsStream() { - if (msg.type == "agent_session_update" || msg.type == "claude_session_update"), - let s = msg.agentSession { - merge(s) + var backoffSec: UInt64 = 1 + while !Task.isCancelled { + do { + for try await msg in client.eventsStream() { + backoffSec = 1 // healthy traffic resets the backoff + if (msg.type == "agent_session_update" || msg.type == "claude_session_update"), + let s = msg.agentSession { + merge(s) + } } + } catch { + // dropped — fall through to a full resync + backed-off reconnect } - } catch { - // stream ended / dropped — the view reloads on next appear + if Task.isCancelled { break } + await load(client) // catch transitions missed during the gap + try? await Task.sleep(nanoseconds: backoffSec * 1_000_000_000) + backoffSec = min(backoffSec * 2, 30) // 1,2,4,…,30s cap } } } @@ -97,6 +105,7 @@ func stateColor(_ s: AgentSession) -> Color { struct RosterView: View { @EnvironmentObject var app: AppState @StateObject private var model = RosterModel() + @Environment(\.scenePhase) private var scenePhase var body: some View { NavigationStack { @@ -130,6 +139,12 @@ struct RosterView: View { await model.load(app.client) model.startStream(app.client) } + .onChange(of: scenePhase) { _, phase in + // iOS suspends SSE in the background; on return to foreground do a + // full resync and reconnect so the roster is correct + live again. + guard phase == .active, app.config.isConfigured else { return } + Task { await model.load(app.client); model.startStream(app.client) } + } .onDisappear { model.stopStream() } } } From 3892b3063839832dd31bfcff98dc90b8101cd811 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:16:34 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat(ios):=20widget=20polish=20=E2=80=94?= =?UTF-8?q?=20lock-screen=20accessories=20+=20tap-to-open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentCountWidget now also supports the lock-screen accessory families (.accessoryInline "▶ N active · ⏸ M stuck", .accessoryRectangular) alongside systemSmall/Medium, and the whole widget is a tap target via .widgetURL(lisapocket://roster). Registers the `lisapocket://` URL scheme (CFBundleURLTypes) — the app's deep-link handler (next commit) routes it; the same scheme backs the ntfy push Click URL. Verified: ./build.sh -> BUILD SUCCEEDED; CFBundleURLTypes present in the built Info.plist. Co-Authored-By: Claude Opus 4.8 --- .../Widgets/AgentCountWidget.swift | 31 ++++++++++++++++--- packaging/ios-companion/project.yml | 4 +++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packaging/ios-companion/Widgets/AgentCountWidget.swift b/packaging/ios-companion/Widgets/AgentCountWidget.swift index ac51691..036b8a8 100644 --- a/packaging/ios-companion/Widgets/AgentCountWidget.swift +++ b/packaging/ios-companion/Widgets/AgentCountWidget.swift @@ -32,10 +32,32 @@ struct AgentCountWidgetView: View { @Environment(\.widgetFamily) private var family var body: some View { - if let snap = entry.snapshot, snap.updatedAt > .distantPast { - populated(snap) + switch family { + case .accessoryInline: + Text(inlineText) + case .accessoryRectangular: + rectangular + default: + if let snap = entry.snapshot, snap.updatedAt > .distantPast { populated(snap) } + else { unconfigured } + } + } + + // Lock-screen accessory families: terse, no background (system styles them). + private var inlineText: String { + guard let s = entry.snapshot, s.updatedAt > .distantPast else { return "Lisa — open to pair" } + return "▶ \(s.working) active · ⏸ \(s.stuck) stuck" + } + + @ViewBuilder private var rectangular: some View { + if let s = entry.snapshot, s.updatedAt > .distantPast { + VStack(alignment: .leading, spacing: 2) { + Label("Dispatch", systemImage: "cpu").font(.caption2.bold()) + Text("\(s.working) active · \(s.stuck) stuck").font(.caption) + Text(summary(s)).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } } else { - unconfigured + Text("Open Lisa Pocket").font(.caption) } } @@ -93,9 +115,10 @@ struct AgentCountWidget: Widget { StaticConfiguration(kind: kind, provider: AgentCountProvider()) { entry in AgentCountWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "lisapocket://roster")) // tap → open the Dispatch tab } .configurationDisplayName("Agent activity") .description("Active and stuck agents on your Mac.") - .supportedFamilies([.systemSmall, .systemMedium]) + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } } diff --git a/packaging/ios-companion/project.yml b/packaging/ios-companion/project.yml index 8db7c3a..9ee695a 100644 --- a/packaging/ios-companion/project.yml +++ b/packaging/ios-companion/project.yml @@ -33,6 +33,10 @@ targets: UILaunchScreen: {} NSSupportsLiveActivities: true NSCameraUsageDescription: "Scan the pairing QR code Lisa shows on your Mac." + CFBundleURLTypes: + - CFBundleURLName: ai.meetlisa.pocket + CFBundleURLSchemes: + - lisapocket NSAppTransportSecurity: NSAllowsLocalNetworking: true entitlements: From 733027ba86dda4e22426a97df5f8d2517f75dc7c Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:18:39 +0800 Subject: [PATCH 09/12] feat(ios): route lisapocket:// deep-links to the right session The app now handles its URL scheme (.onOpenURL): lisapocket://roster selects the Dispatch tab (the Widget's tap target); lisapocket://session?agent=&id= (the ntfy push Click) selects Dispatch and opens that session. RosterView drives a NavigationStack path and resolves the pending session once it's in the roster, tolerating either arrival order (link before or after the roster loads). Verified: ./build.sh -> BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 --- packaging/ios-companion/Sources/App.swift | 14 +++++++----- .../ios-companion/Sources/AppState.swift | 22 +++++++++++++++++++ .../ios-companion/Sources/RosterView.swift | 17 +++++++++++++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packaging/ios-companion/Sources/App.swift b/packaging/ios-companion/Sources/App.swift index df65db9..a38ce32 100644 --- a/packaging/ios-companion/Sources/App.swift +++ b/packaging/ios-companion/Sources/App.swift @@ -12,18 +12,20 @@ struct LisaPocketApp: App { } struct RootView: View { + @EnvironmentObject var app: AppState var body: some View { - TabView { + TabView(selection: $app.selectedTab) { RosterView() - .tabItem { Label("Dispatch", systemImage: "cpu") } + .tabItem { Label("Dispatch", systemImage: "cpu") }.tag(0) ChatView() - .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") } + .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") }.tag(1) ReveView() - .tabItem { Label("Reve", systemImage: "moon.stars") } + .tabItem { Label("Reve", systemImage: "moon.stars") }.tag(2) SenseView() - .tabItem { Label("Sense", systemImage: "sensor.tag.radiowaves.forward") } + .tabItem { Label("Sense", systemImage: "sensor.tag.radiowaves.forward") }.tag(3) SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") } + .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) } + .onOpenURL { app.handleDeepLink($0) } } } diff --git a/packaging/ios-companion/Sources/AppState.swift b/packaging/ios-companion/Sources/AppState.swift index 5bccee3..67d96e8 100644 --- a/packaging/ios-companion/Sources/AppState.swift +++ b/packaging/ios-companion/Sources/AppState.swift @@ -1,10 +1,17 @@ import Foundation import SwiftUI +/// A roster session a deep-link wants to open (agent + sessionId). +struct PendingNav: Equatable { var agent: String; var id: String } + @MainActor final class AppState: ObservableObject { @Published var config: ServerConfig @Published private(set) var client: LisaClient + /// Drives the TabView selection (0=Dispatch … 4=Settings) — deep-links set it. + @Published var selectedTab = 0 + /// Set by a `lisapocket://session?…` deep-link; RosterView consumes + clears it. + @Published var pendingSession: PendingNav? init() { let d = UserDefaults.standard @@ -38,4 +45,19 @@ final class AppState: ObservableObject { update(host: host, port: port, token: token) return true } + + /// Route a `lisapocket://` deep-link (from a push Click or the home Widget). + /// `lisapocket://roster` → Dispatch tab; `lisapocket://session?agent=&id=` → + /// Dispatch tab + ask RosterView to open that session. + func handleDeepLink(_ url: URL) { + guard url.scheme == "lisapocket" else { return } + selectedTab = 0 // Dispatch + guard url.host == "session" else { return } + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let agent = items.first { $0.name == "agent" }?.value + let id = items.first { $0.name == "id" }?.value + if let agent, let id, !agent.isEmpty, !id.isEmpty { + pendingSession = PendingNav(agent: agent, id: id) + } + } } diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index 8c55764..a0c502e 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -106,9 +106,10 @@ struct RosterView: View { @EnvironmentObject var app: AppState @StateObject private var model = RosterModel() @Environment(\.scenePhase) private var scenePhase + @State private var path: [AgentSession] = [] var body: some View { - NavigationStack { + NavigationStack(path: $path) { Group { if !app.config.isConfigured { ContentUnavailableView("Not paired", systemImage: "wifi.slash", @@ -137,6 +138,7 @@ struct RosterView: View { .refreshable { await model.load(app.client) } .task(id: app.config) { await model.load(app.client) + resolvePending() model.startStream(app.client) } .onChange(of: scenePhase) { _, phase in @@ -145,9 +147,22 @@ struct RosterView: View { guard phase == .active, app.config.isConfigured else { return } Task { await model.load(app.client); model.startStream(app.client) } } + // Deep-link (push Click / widget): open the requested session once it's + // in the roster — handle either arrival order (link before/after load). + .onChange(of: app.pendingSession) { _, _ in resolvePending() } + .onChange(of: model.sessions) { _, _ in resolvePending() } .onDisappear { model.stopStream() } } } + + /// If a deep-link is pending and its session is in the roster, push it once. + private func resolvePending() { + guard let p = app.pendingSession, + let match = model.sessions.first(where: { $0.agent == p.agent && $0.sessionId == p.id }) + else { return } + path = [match] + app.pendingSession = nil + } } struct RosterRow: View { From 46bc694a0ee701e8dbcbda9400d10f7c91bd5fc2 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:20:38 +0800 Subject: [PATCH 10/12] feat(ios): optional Face ID / passcode lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device token grants full control of the Mac's agents, so Settings → Security can require biometric unlock (LocalAuthentication, .deviceOwnerAuth → Face ID/Touch ID with passcode fallback). When armed + paired, the app locks at launch and re-locks on backgrounding; a full-screen LockView auto-prompts and offers a manual retry. If no auth is enrolled, it doesn't trap the user. Adds NSFaceIDUsageDescription. Verified: ./build.sh -> BUILD SUCCEEDED; NSFaceIDUsageDescription in the built Info.plist. Co-Authored-By: Claude Opus 4.8 --- packaging/ios-companion/Sources/App.swift | 5 ++++ .../ios-companion/Sources/AppState.swift | 30 +++++++++++++++++++ .../ios-companion/Sources/LockView.swift | 23 ++++++++++++++ .../ios-companion/Sources/SettingsView.swift | 8 +++++ packaging/ios-companion/project.yml | 1 + 5 files changed, 67 insertions(+) create mode 100644 packaging/ios-companion/Sources/LockView.swift diff --git a/packaging/ios-companion/Sources/App.swift b/packaging/ios-companion/Sources/App.swift index a38ce32..ba2eaf2 100644 --- a/packaging/ios-companion/Sources/App.swift +++ b/packaging/ios-companion/Sources/App.swift @@ -13,6 +13,7 @@ struct LisaPocketApp: App { struct RootView: View { @EnvironmentObject var app: AppState + @Environment(\.scenePhase) private var scenePhase var body: some View { TabView(selection: $app.selectedTab) { RosterView() @@ -27,5 +28,9 @@ struct RootView: View { .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) } .onOpenURL { app.handleDeepLink($0) } + .overlay { if app.locked { LockView() } } + .onChange(of: scenePhase) { _, phase in + if phase == .background { app.lockIfEnabled() } // re-arm when leaving foreground + } } } diff --git a/packaging/ios-companion/Sources/AppState.swift b/packaging/ios-companion/Sources/AppState.swift index 67d96e8..5e2a2f9 100644 --- a/packaging/ios-companion/Sources/AppState.swift +++ b/packaging/ios-companion/Sources/AppState.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import LocalAuthentication /// A roster session a deep-link wants to open (agent + sessionId). struct PendingNav: Equatable { var agent: String; var id: String } @@ -12,6 +13,9 @@ final class AppState: ObservableObject { @Published var selectedTab = 0 /// Set by a `lisapocket://session?…` deep-link; RosterView consumes + clears it. @Published var pendingSession: PendingNav? + /// Optional Face ID / passcode gate over the app (token grants full control). + @Published var biometricLockEnabled: Bool + @Published var locked: Bool init() { let d = UserDefaults.standard @@ -20,6 +24,9 @@ final class AppState: ObservableObject { let cfg = ServerConfig(host: host, port: storedPort == 0 ? 5757 : storedPort, token: TokenStore.load()) self.config = cfg self.client = LisaClient(config: cfg) + let lockOn = d.bool(forKey: "lisa.biometricLock") + self.biometricLockEnabled = lockOn + self.locked = lockOn && cfg.token != nil // require unlock at launch when armed } func update(host: String, port: Int, token: String?) { @@ -60,4 +67,27 @@ final class AppState: ObservableObject { pendingSession = PendingNav(agent: agent, id: id) } } + + // ── biometric lock ── + func setBiometricLock(_ on: Bool) { + biometricLockEnabled = on + UserDefaults.standard.set(on, forKey: "lisa.biometricLock") + if !on { locked = false } + } + + /// Re-arm the lock when the app leaves the foreground (called on background). + func lockIfEnabled() { + if biometricLockEnabled && config.token != nil { locked = true } + } + + /// Prompt Face ID / Touch ID (falling back to the device passcode). If no auth + /// is available at all, don't trap the user — just unlock. + func unlock() async { + let ctx = LAContext() + var err: NSError? + guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err) else { locked = false; return } + if let ok = try? await ctx.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Lisa Pocket"), ok { + locked = false + } + } } diff --git a/packaging/ios-companion/Sources/LockView.swift b/packaging/ios-companion/Sources/LockView.swift new file mode 100644 index 0000000..80dc01b --- /dev/null +++ b/packaging/ios-companion/Sources/LockView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Full-screen gate shown while `app.locked` (docs/IOS_COMPANION_PLAN.md §5.3 — +/// the token is a full-control credential, so an optional Face ID / passcode lock +/// guards it). Auto-prompts on appear; a manual Unlock retries after a cancel. +struct LockView: View { + @EnvironmentObject var app: AppState + + var body: some View { + ZStack { + Color(.systemBackground).ignoresSafeArea() + VStack(spacing: 16) { + Image(systemName: "lock.fill").font(.largeTitle).foregroundStyle(.secondary) + Text("Lisa Pocket is locked").font(.headline) + Button { Task { await app.unlock() } } label: { + Label("Unlock", systemImage: "faceid") + } + .buttonStyle(.borderedProminent) + } + } + .task { await app.unlock() } + } +} diff --git a/packaging/ios-companion/Sources/SettingsView.swift b/packaging/ios-companion/Sources/SettingsView.swift index bc272ef..d585bd9 100644 --- a/packaging/ios-companion/Sources/SettingsView.swift +++ b/packaging/ios-companion/Sources/SettingsView.swift @@ -83,6 +83,14 @@ struct SettingsView: View { NavigationLink { DevicesView() } label: { Label("Paired devices", systemImage: "iphone.gen3") } } + Section("Security") { + Toggle("Require Face ID / passcode", isOn: Binding( + get: { app.biometricLockEnabled }, + set: { app.setBiometricLock($0) })) + Text("Locks the app behind biometrics — the device token grants full control of your Mac's agents.") + .font(.caption).foregroundStyle(.secondary) + } + Section("Inspect Lisa") { NavigationLink { SoulView() } label: { Label("Soul", systemImage: "sparkles") } NavigationLink { MemoryView() } label: { Label("Memory", systemImage: "brain") } diff --git a/packaging/ios-companion/project.yml b/packaging/ios-companion/project.yml index 9ee695a..3287c07 100644 --- a/packaging/ios-companion/project.yml +++ b/packaging/ios-companion/project.yml @@ -33,6 +33,7 @@ targets: UILaunchScreen: {} NSSupportsLiveActivities: true NSCameraUsageDescription: "Scan the pairing QR code Lisa shows on your Mac." + NSFaceIDUsageDescription: "Unlock Lisa Pocket with Face ID." CFBundleURLTypes: - CFBundleURLName: ai.meetlisa.pocket CFBundleURLSchemes: From e9518423d19b1a746adceae669e119144ea69c6b Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:26:02 +0800 Subject: [PATCH 11/12] test(ios): logic tests + extract pure helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a hosted XCTest target (LisaPocketTests) covering the pure logic, and extracts that logic so it's testable in isolation: - rosterCounts(_:at:) — the Widget snapshot bucketing (pulled out of RosterModel.publishSnapshot). - AppState.parseDeepLink(_:) -> DeepLinkRoute — the lisapocket:// parse (pulled out of handleDeepLink), nonisolated so it runs off the main actor. Tests also cover sortRows ranking. build.sh gains a `test` action (`./build.sh test`) and project.yml a LisaPocket scheme wiring the test target. Verified: ./build.sh test -> Executed 8 tests, 0 failures (TEST SUCCEEDED) on the iOS 17 simulator. Co-Authored-By: Claude Opus 4.8 --- .../ios-companion/Sources/AppState.swift | 27 +++++-- .../ios-companion/Sources/RosterView.swift | 22 +++--- .../ios-companion/Tests/LisaPocketTests.swift | 70 +++++++++++++++++++ packaging/ios-companion/build.sh | 10 ++- packaging/ios-companion/project.yml | 24 +++++++ 5 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 packaging/ios-companion/Tests/LisaPocketTests.swift diff --git a/packaging/ios-companion/Sources/AppState.swift b/packaging/ios-companion/Sources/AppState.swift index 5e2a2f9..6aab60a 100644 --- a/packaging/ios-companion/Sources/AppState.swift +++ b/packaging/ios-companion/Sources/AppState.swift @@ -5,6 +5,13 @@ import LocalAuthentication /// A roster session a deep-link wants to open (agent + sessionId). struct PendingNav: Equatable { var agent: String; var id: String } +/// Where a `lisapocket://` deep-link points. +enum DeepLinkRoute: Equatable { + case ignore // not our scheme + case roster // lisapocket://roster (or unknown host) + case session(agent: String, id: String) // lisapocket://session?agent=&id= +} + @MainActor final class AppState: ObservableObject { @Published var config: ServerConfig @@ -57,15 +64,27 @@ final class AppState: ObservableObject { /// `lisapocket://roster` → Dispatch tab; `lisapocket://session?agent=&id=` → /// Dispatch tab + ask RosterView to open that session. func handleDeepLink(_ url: URL) { - guard url.scheme == "lisapocket" else { return } - selectedTab = 0 // Dispatch - guard url.host == "session" else { return } + switch AppState.parseDeepLink(url) { + case .ignore: return + case .roster: selectedTab = 0 + case .session(let agent, let id): + selectedTab = 0 + pendingSession = PendingNav(agent: agent, id: id) + } + } + + /// Pure parse of a deep-link into a route. `nonisolated` so it's testable off + /// the main actor. Unknown lisapocket hosts fall back to the roster. + nonisolated static func parseDeepLink(_ url: URL) -> DeepLinkRoute { + guard url.scheme == "lisapocket" else { return .ignore } + guard url.host == "session" else { return .roster } let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] let agent = items.first { $0.name == "agent" }?.value let id = items.first { $0.name == "id" }?.value if let agent, let id, !agent.isEmpty, !id.isEmpty { - pendingSession = PendingNav(agent: agent, id: id) + return .session(agent: agent, id: id) } + return .roster } // ── biometric lock ── diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index a0c502e..131808a 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -59,19 +59,23 @@ final class RosterModel: ObservableObject { /// Mirror the roster's counts (metadata only — no session content) to the App /// Group so the home-screen Widget can render them, then nudge it to reload. private func publishSnapshot() { - var working = 0, waiting = 0, error = 0 - for s in sessions { - if s.activity?.pendingPermission != nil || s.state == "waiting" { waiting += 1 } - else if s.state == "error" { error += 1 } - else if s.state == "working" { working += 1 } - } - SharedStore.writeSnapshot(AgentSnapshot( - working: working, waiting: waiting, error: error, - total: sessions.count, updatedAt: Date())) + SharedStore.writeSnapshot(rosterCounts(sessions)) WidgetCenter.shared.reloadAllTimelines() } } +/// Bucket roster sessions into the Widget's counts (pending-permission and +/// "waiting" both count as needs-you). Pure — unit-tested. +func rosterCounts(_ sessions: [AgentSession], at now: Date = Date()) -> AgentSnapshot { + var working = 0, waiting = 0, error = 0 + for s in sessions { + if s.activity?.pendingPermission != nil || s.state == "waiting" { waiting += 1 } + else if s.state == "error" { error += 1 } + else if s.state == "working" { working += 1 } + } + return AgentSnapshot(working: working, waiting: waiting, error: error, total: sessions.count, updatedAt: now) +} + /// Permission/error first, then waiting, then working, then the rest. Newest within a bucket. func sortRows(_ rows: [AgentSession]) -> [AgentSession] { func rank(_ s: AgentSession) -> Int { diff --git a/packaging/ios-companion/Tests/LisaPocketTests.swift b/packaging/ios-companion/Tests/LisaPocketTests.swift new file mode 100644 index 0000000..bc3c2e7 --- /dev/null +++ b/packaging/ios-companion/Tests/LisaPocketTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import LisaPocket + +/// Logic tests for the pure helpers — no network, no Keychain, no app launch. +final class LisaPocketTests: XCTestCase { + + private func session(_ state: String, id: String = "s", agent: String = "claude-code", + pending: String? = nil, mtime: String? = nil) -> AgentSession { + let activity = pending.map { + SessionActivity(turnCount: nil, lastTools: nil, filesTouched: nil, lastCommandName: nil, + lastError: nil, gitBranch: nil, tokens: nil, pendingPermission: $0) + } + return AgentSession(agent: agent, sessionId: id, project: "p", cwd: nil, state: state, + stateReason: "", lastMtime: mtime, activity: activity, controllable: nil, + resumable: nil, adoptedSessionId: nil) + } + + // ── rosterCounts: each session lands in exactly one bucket ── + func testRosterCountsBuckets() { + let snap = rosterCounts([ + session("working", id: "a"), + session("working", id: "b"), + session("waiting", id: "c"), + session("error", id: "d"), + session("working", id: "e", pending: "Bash"), // pending ⇒ waiting bucket + session("done", id: "f"), + ], at: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(snap.working, 2) + XCTAssertEqual(snap.waiting, 2) // one "waiting" + one pending-permission + XCTAssertEqual(snap.error, 1) + XCTAssertEqual(snap.total, 6) // done counts toward total only + XCTAssertEqual(snap.stuck, 3) // waiting + error + } + + func testRosterCountsEmpty() { + let snap = rosterCounts([], at: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(snap.total, 0) + XCTAssertEqual(snap.stuck, 0) + } + + // ── sortRows: pending-permission first, then error, waiting, working ── + func testSortRowsRanking() { + let sorted = sortRows([ + session("working", id: "w"), + session("done", id: "d"), + session("error", id: "e"), + session("working", id: "p", pending: "Bash"), + session("waiting", id: "wa"), + ]) + XCTAssertEqual(sorted.map(\.sessionId), ["p", "e", "wa", "w", "d"]) + } + + // ── parseDeepLink ── + func testParseDeepLinkSession() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://session?agent=codex&id=s9")!), + .session(agent: "codex", id: "s9")) + } + func testParseDeepLinkRoster() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://roster")!), .roster) + } + func testParseDeepLinkUnknownHostFallsBackToRoster() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://whatever")!), .roster) + } + func testParseDeepLinkSessionMissingParamsFallsBackToRoster() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://session?agent=codex")!), .roster) + } + func testParseDeepLinkIgnoresForeignScheme() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "https://example.com")!), .ignore) + } +} diff --git a/packaging/ios-companion/build.sh b/packaging/ios-companion/build.sh index c7f9d87..c3a3fe1 100755 --- a/packaging/ios-companion/build.sh +++ b/packaging/ios-companion/build.sh @@ -20,13 +20,17 @@ export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Develope command -v xcodegen >/dev/null || { echo "✗ need xcodegen — run: brew install xcodegen" >&2; exit 1; } +# Action: `build` (default) or `test` (runs the LisaPocketTests logic tests). +ACTION="build" +if [ "${1:-}" = "test" ]; then ACTION="test"; shift; fi + echo "==> xcodegen generate" xcodegen generate DEST="${1:-platform=iOS Simulator,name=iPhone 17 Pro}" -echo "==> xcodebuild ($DEST)" +echo "==> xcodebuild $ACTION ($DEST)" xcodebuild -project LisaPocket.xcodeproj -scheme LisaPocket \ -sdk iphonesimulator -destination "$DEST" \ - -derivedDataPath .build build CODE_SIGNING_ALLOWED=NO + -derivedDataPath .build "$ACTION" CODE_SIGNING_ALLOWED=NO -echo "✓ Lisa Pocket built for the simulator." +echo "✓ Lisa Pocket $ACTION succeeded (simulator)." diff --git a/packaging/ios-companion/project.yml b/packaging/ios-companion/project.yml index 3287c07..dfa5f6b 100644 --- a/packaging/ios-companion/project.yml +++ b/packaging/ios-companion/project.yml @@ -69,3 +69,27 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: ai.meetlisa.pocket.widgets + + # Logic tests for the pure helpers (parsing / bucketing / sorting). Hosted by the + # app so `@testable import LisaPocket` resolves; run with: ./build.sh test (or + # xcodebuild test). XcodeGen sets TEST_HOST from the app dependency. + LisaPocketTests: + type: bundle.unit-test + platform: iOS + sources: + - path: Tests + dependencies: + - target: LisaPocket + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.meetlisa.pocket.tests + GENERATE_INFOPLIST_FILE: YES + +schemes: + LisaPocket: + build: + targets: + LisaPocket: all + test: + targets: + - LisaPocketTests From 768e8e2e49c7354837bea0aaf0b10969fb6005f4 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 15:26:51 +0800 Subject: [PATCH 12/12] docs(ios): sync README with the follow-up features Reve / Sense tabs, mood indicator, Inspect Lisa, paired devices, Face ID lock, dispatch ledger, SSE auto-reconnect, widget accessory families + deep-links, and `./build.sh test`. Notes the remaining follow-ups (APNs Live-Activity refresh; real mood portrait art). Co-Authored-By: Claude Opus 4.8 --- packaging/ios-companion/README.md | 39 ++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packaging/ios-companion/README.md b/packaging/ios-companion/README.md index 3d23294..14340d6 100644 --- a/packaging/ios-companion/README.md +++ b/packaging/ios-companion/README.md @@ -14,22 +14,32 @@ uses all live in `src/web/server.ts`. **Compile-verified MVP.** The app builds clean for the iOS Simulator (Xcode 26, iOS 17+ target). It covers: -- **Dispatch** — roster from `/api/agents/sessions` + live `/events` SSE; rows keyed - off `controllable` / `resumable`; per-session control: managed **approve/deny** · - send · cancel, PTY send · **output** · cancel, and **adopt (resume)** for idle - claude sessions (handles the 409/403 the server returns). -- **Chat** — streams `POST /chat`. -- **Settings** — pairing (**scan** the Mac's QR code via the camera, or paste a - `lisa-pair://…` / `?token=` string → Keychain), ntfy push registration, and a - read-only view of the remote-control policy. -- **Glance** — a **Live Activity / Dynamic Island** for a pinned agent (Lock Screen + - compact / expanded / minimal Dynamic Island) and a **home-screen Widget** showing - active / stuck agent counts, both in a WidgetKit extension target. The Widget renders - a counts-only snapshot the app shares through an App Group — the auth token stays in - the Keychain and no session content ever reaches the extension. +- **Dispatch** — roster from `/api/agents/sessions` + live `/events` SSE (auto-reconnect + with backoff + a full resync on foreground); rows keyed off `controllable` / + `resumable`; per-session control: managed **approve/deny** · send · cancel, PTY send · + **output** · cancel, and **adopt (resume)** for idle claude sessions (handles the + 409/403 the server returns). The toolbar opens the **dispatch ledger** (Lisa's own + fire-and-forget runs, with a per-entry log tail). +- **Chat** — streams `POST /chat`, with a live **mood** indicator (mood SSE). +- **Reve** — "while you were away" note + current desire, an agent-activity **recap** + (2h/8h/24h), and dismissable advisor suggestions. +- **Sense** — ambient-signal **consent** (revoke-only from the phone; granting stays a + Mac action) + recent sense events. +- **Settings** — pairing (**scan** the Mac's QR code, or paste a `lisa-pair://…` / + `?token=` string → Keychain), ntfy push, read-only remote-control policy, **paired + devices**, an optional **Face ID / passcode** lock, and read-only **Inspect Lisa** + (Soul / Memory / Skills / Tools). +- **Glance** — a **Live Activity / Dynamic Island** for a pinned agent and a + **home-screen / lock-screen Widget** (systemSmall/Medium + accessory families) showing + active / stuck counts, in a WidgetKit extension. The Widget renders a counts-only + snapshot the app shares through an App Group — the token stays in the Keychain and no + session content reaches the extension — and tapping it deep-links into the app. +- **Deep-links** — `lisapocket://` opens the app from a Widget tap or an ntfy push + (the push carries a `Click` to the relevant session). **Not yet** (follow-ups): live Live-Activity updates via APNs, so a pinned agent stays -fresh while backgrounded — needs an Apple push key; ntfy push works today. +fresh while backgrounded — needs an Apple push key; ntfy push works today. A real mood +**portrait** (the chip is a stand-in; the iOS app has no asset catalog yet). > Like the Live Activity, the home-screen Widget is **compile-verified on the > Simulator**. Its data only flows on a **signed** build: App Group capabilities aren't @@ -41,6 +51,7 @@ fresh while backgrounded — needs an Apple push key; ntfy push works today. ```sh brew install xcodegen # one-time ./build.sh # xcodegen generate + xcodebuild for the simulator +./build.sh test # run the LisaPocketTests logic tests on a simulator ``` The Xcode project is generated from `project.yml` (not committed). Simulator builds