Read plan + code. Map:
What PocketAI is
On-device AI apps. Models run locally on iPhone/Mac/Android. No account, no server. Mac can serve its bigger models to paired iPhone + any OpenAI-compatible tool on LAN. Pro = Keygen.sh Ed25519 license key, verified offline, one-time $39→$59. No IAP, no subscription.
Layout — 5 sibling dirs, not one git repo
| Dir | What |
|---|---|
PocketAI.Core | Rust workspace (8 crates) + Swift packages. Shared brain |
PocketAI.Mac | SwiftUI + MenuBarExtra, 8.4k lines |
PocketAI.Iphone | SwiftUI, 10.7k lines |
PocketAI.Android | Expo/React Native port of iPhone, 7.8k lines |
PocketAI.Website | static HTML |
strategy/ | engineering source of truth. 01-architecture.md normative, BOARD.md 119 task cards |
Core (Rust, 19k lines)
One crate per concern so parallel agents never touch same files:
pocket-types— ids,PocketError,DeviceProfilepocket-store— rusqlite, migration 0001 (conversations/messages/projects/chunks+fts5/devices/peers/audit/egress)pocket-models— catalog, HF search, fit engine (est_runtime_bytes = weights×1.05 + kv@4096 + 350MB), download plan/verify/commitpocket-engine—Enginetrait, router (MLX safetensors→Mlx, GGUF→LlamaCpp,mac:<id>/→Remote), memory guard with LRU admit/evict, llama.cpp engine, RAG, tools, benchmarkpocket-trust— Ed25519 identity, TLS certs, pairing, Keygen license verifypocket-server— axum:/v1/*OpenAI-compatible +/pocket/v1/*control + SSE eventspocket-audit— hash-chained audit log, egress ledgerpocket-ffi— only crate exported to Swift. UniFFI. 2591 lines, ~70 methods
Key boundary trick
Engine trait is #[uniffi::export(with_foreign)] — MLX engine lives in Swift (PocketMLX) but implements the Rust trait. Rust owns state/routing; Swift owns Apple frameworks (Keychain, URLSession background downloads, Speech, AVSpeech, FoundationModels, MLX).
Swift side: CoreClient protocol with two impls — LiveCore (real PocketCore) and PreviewCore (fixtures, fake 40 tok/s stream). -PocketPreviewCore YES swaps whole app. That's how UI got built without waiting on Rust. MLX does not run in Simulator, so this is load-bearing.
Pairing
Mac QR pocketai://pair?...&fp=<sha256 cert>&s=<128-bit secret> TTL 300s single-use → iPhone resolves via Bonjour _pocketai._tcp, pins leaf cert to fp → POST /pocket/v1/pair → Mac shows "Allow this iPhone?" → bearer token to Keychain.
Trust tooling (the wedge)
Offline mode blocks app-initiated internet, pauses downloads, keeps paired LAN chat. Network Proof = egress table. CI lint check-no-raw-http.sh forbids URLSession.shared outside Net/ and Peer/.
LiveShortly module
Swift-only, no FFI change. PocketKit/LiveShortly/ + PocketLiveShortly package (1387-line session view) + Mac Sources/LiveShortly/ (LSChatView 626, LSSessionModel, LSShareSheet, LSPermissionBar). Device-flow auth, sessions list, live SSE viewer, comment composer, Allow/Deny on input_requested. 8 event types, unknown types render raw.
State
Nearly all 119 cards done. Remaining blocks are DEVICE (on-device perf/VoiceOver/multi-GB download survival) or OWNER (paid GitHub plan, CORE_REPO_TOKEN, Apple Intelligence toggle, TCC grants). STATUS.md last entry 2026-10-14; Mac sources touched 2026-10-29.
Two real stubs spotted in pocket-ffi/src/core.rs:
core.rs:979—pub fn stop(&self, _conversation_id: Id) {}empty;cancel_conversationat:413is the working onecore.rs:1740—subscribe(&self, _sink:)ignores the sink, so the §6 event bus isn't delivering to Swift
Ready. What we building on it?
Built + signed, not notarized yet — notarization in flight right now.
Signed: build/dist/export/PocketAI.app
Developer ID Application: Rohit Sehgal (4RK6YA795V)→ Apple Root CA- Hardened runtime on (
flags=0x10000(runtime)), secure timestamp 29 Oct 15:47 - arm64 thin,
com.getpocketai.app, version 1.0 (1)
Notarization: scripts/notarize-mac.sh still running (PID 36434), notarytool submit --wait blocking since 15:47.
- Submission
59d80e95—In Progress, 12 min elapsed - Earlier attempt
f0fb898c(15:40) came back Invalid:"The executable does not have the hardened runtime enabled."Already fixed — current export has it.
Right now Gatekeeper rejects it: spctl says rejected / source=Unnotarized Developer ID, no stapled ticket. Script will staple + build PocketAI.dmg once Apple returns Accepted.
Other artifacts: build/Build/Products/Debug/PocketAI.app (dev build), build-uitest/.../PocketAI.app + UITests runner. No DMG yet.
Want me to watch the submission and report when it flips Accepted?
Watching submission 59d80e95 in background. Polls every 45s, up to ~45 min.
When it flips I report: status, Apple's reasons if Invalid, and on Accepted — stapled ticket + Gatekeeper verdict + build/dist/PocketAI.dmg.
Meanwhile — what are we building around PocketAI?
Vision maps onto three engines — two proven, one uneven.
1. Local on-device — done
MLX (PocketMLXEngine, 788 lines), llama.cpp (pocket-engine/llama, 874), Apple Foundation Models as zero-download. Router picks by format. Memory guard budgets from os_proc_available_memory() not total RAM, LRU-evicts idle models, never mid-generation. Fit engine refuses before download, not after.
2. Mac/system serving — done, proven end to end
RemoteEngine → pinned TLS → Mac's axum server. Model ids prefixed mac:<device-id>/ route to Remote. D115 recorded 24 tokens streamed from host MLX over pinned TLS; D114 pairing round trip paired=1, RTT 0.038s. Plus /v1/* OpenAI-compatible so Cursor/scripts hit the same Mac.
3. Claude over LiveShortly — Mac rich, iPhone thin, Android zero
| files | what | |
|---|---|---|
Core PocketLiveShortly | 9 (2914 lines) | client, device-flow auth, SSE stream, session view, connect, new-session |
| Mac | 17 | own LiveShortly/ layer: LSChatView 626, LSSessionModel, LSShareSheet, LSPermissionBar, LSNewSessionView + sidebar/palette/HUD/onboarding/project hooks |
| iPhone | 5 | AppModel, Settings, Home, ProjectDetail, ChatsList — consumes shared views only, no own layer |
| Android | 0 | nothing |
Real gaps for the "world class" bar:
- Android is a different animal. No Rust core, no FFI — llama.rn + its own
src/core/engine/*,downloads.ts,catalog.android.json. Local + remote only. LiveShortly would be a from-scratch TS port of 2914 Swift lines. - LiveShortly is watch-mode, not drive-mode. Per
11-liveshortly-integration.md§1, daemon/v1agent driving, fork/handoff from app, and push notifications are explicitly out of V1. Today: sessions list, live SSE viewer, comments, Allow/Deny oninput_requested. Starting Claude from the app isn't there. - No push. iOS suspends in seconds and APNs needs a provider server (
00-README.md§3). A permission prompt that lands while the phone is asleep waits for app open. That's the sharpest edge on "Claude on my phone." subscribe()is a stub (pocket-ffi/src/core.rs:1740drops the sink). The §6 event bus doesn't reach Swift, so model/server/device events can't drive UI reactively — each screen polls.
Highest-leverage next move, my call: wire subscribe() for real, then bring iPhone LiveShortly to Mac parity — permission bar + session model are the two pieces the phone most needs and the phone is where an approval request actually finds you.
Notarization watch still armed. Which lane first?
Found the WIP. Localization is half-done, and the half that's missing is the half the stores read.
| Target | keys | translated |
|---|---|---|
PocketUI (shared) | 54 | all 8 langs — en, hi, es, fr, de, ja, zh-Hans, pt-BR |
| Mac app | 183 | en only |
| iPhone app | 287 | en only |
| Android | — | no i18n stack at all |
So the shared component layer is fully localized and proved the format (plurals, %lld tok/s, %@ of %@ · %@ · %@ left). The two app catalogs are extracted but untranslated — every key sits at state: translated with only an en entry.
The shipping problem: both project.yml files declare all 8 in knownRegions and CFBundleLocalizations:
PocketAI.Iphone/project.yml:8 and :50, PocketAI.Mac/project.yml:8 and :58
iOS and the App Store therefore advertise PocketAI as available in Hindi, Spanish, French, German, Japanese, Simplified Chinese and Brazilian Portuguese. Set a device to Hindi today and you get PocketUI's chrome in Hindi wrapped around 470 English strings. Worse than not declaring them.
Also wants cleaning before anyone translates — keys that shouldn't be in a catalog:
- Mac:
"","—" - iPhone:
"1024","512","768","·","•"
Mark those .doNotTranslate (or drop the literal into a non-localized Text(verbatim:)), else they ship to translators and come back "translated."
Scope: 470 strings × 7 languages = 3290 units.
My call on approach — the catalogs are plain JSON, so this is a data task, not a code task. One throwaway script walks strings{} and writes the 7 localizations blocks; translation happens in batches. No new dependency, no tooling, Xcode reads it straight back. PocketUI is the reference for how format specifiers and plurals must survive.
Two ways to go:
- Gate fix only (minutes) — strip the 7 junk keys, cut
knownRegions/CFBundleLocalizationsback to[en]. Ship honest, re-add locales as translations land. - Full fill — translate all 470 × 7 into both catalogs.
Recommend 1 now regardless, since it's true today whatever we do next, then 2 as its own pass.
Which? Notarization watch still running.
Pulled from BOARD.md (99 done / 20 blocked), GATES.md, OWNER-SIGNOFF.md, plus what this session turned up. Nothing below is a missing implementation except where marked CODE — the 20 board cards are all written and CI-green, held only by a gate.
0. In flight right now
- Mac notarization — submission
59d80e95In Progress. Watch armed. On Accepted the script staples + buildsbuild/dist/PocketAI.dmg. Until thenspctlrejects the signed app.
1. CODE — real gaps found this session
-
subscribe()is a stub —PocketAI.Core/crates/pocket-ffi/src/core.rs:1740drops theEventSink. The §6 event bus never reaches Swift; every screen polls instead of reacting. -
stop()is an empty body —core.rs:979.cancel_conversation(:413) is the working one; either deletestopor forward it. - MAC-06 cert export — flagged "no FFI yet" in GATES. Small addition.
- Localization: 470 untranslated strings.
PocketUIhas all 8 languages (54 keys). Mac (183) and iPhone (287) are en-only, yet both declare 8 locales inknownRegions+CFBundleLocalizations(project.yml:8/:50iPhone,:8/:58Mac) — stores advertise Hindi/Spanish/French/German/Japanese/Chinese/Portuguese and serve English. - Strip 7 non-translatable keys from the catalogs: Mac
"","—"; iPhone"1024","512","768","·","•". - iPhone LiveShortly parity — Mac has 17 touching files incl. its own
LiveShortly/layer (LSChatView, LSSessionModel, LSPermissionBar, LSShareSheet, LSNewSessionView). iPhone has 5 and consumes shared views only. The permission bar matters most on the phone. - Android has no LiveShortly — 0 references. Would be a TS port of 2914 Swift lines, on a stack with no Rust core.
2. Four toggles on this Mac — flip 7 cards green
-
sudo DevToolsSecurity -enable→ unblocks the written PocketAIUITests suite → MAC-02, MAC-03, MAC-05, MAC-08, MAC-11, UI-09, UI-11 - Approve Local Network prompt → APL-08 (Bonjour; status was
.notDeterminedauto-launched) - Approve Speech Recognition prompt → APL-12 (STT; TTS→STT already wired)
- Enable Apple Intelligence → APL-16 (Foundation Models self-test runs instead of skipping)
3. Needs the physical iPhone
- UI-05 live tok/s while generating
- UI-07 fresh-install-to-first-answer < 5 min + VoiceOver pass
- UI-08 1,000-conversation 120fps Instruments trace
- UI-10 / MAC-04 on-device download state transitions
- MAC-12 on-device feature pass (projects, voice, vision, images)
- APL-03 keychain / thermal / low-power fidelity
- APL-05 multi-GB download survives backgrounding, relaunch, airplane mode
- APL-18 Pro unlock — on-device Keygen key activation
- QA-05 perf harness ×5 per model, CSV against budgets
- QA-10 accessibility — VoiceOver full pass, Inspector 0 warnings
4. Owner account / decision — launch critical
- COM-01 App Store Connect Issuer ID + Small Business Program enrollment. Critical path.
- COM-02 Stripe + Zapier → Keygen license checkout
- WEB-02 pick waitlist mechanism (mailto / pre-order / form)
- Paid GitHub plan (branch protection) +
CORE_REPO_TOKENsecret for cross-repo CI
5. Polish
- UI-02 export app icon via Icon Composer, run brand.html pixel-diff
-
CodeBlockhas no syntax highlighting (needs a per-language tokenizer) -
MessageViewcan't render markdown tables (AttributedString(markdown:)doesn't support them — needs theswift-markdownfallback)
6. Deferred by decision — not pending
Agents with approvals + Pocket Bridge (ACP over stdio to claude-code-acp / codex-acp / opencode / goose). Spec parked in strategy/next-release/agents.md. V1 keeps only the seams: event bus, device auth, audit log.
Launch gate, per the ledger: section 4 alone ships it. Section 2 is four clicks for seven cards. Localization is the one item not in any existing ledger and it blocks an honest store listing.
Want this written into strategy/ as a consolidated ledger, or keep it in OWNER-SIGNOFF.md's shape and just append the new findings?
Plan already exists — strategy/12-licensing.md, 451 lines, status proposal awaiting your review. It covers exactly this. Don't re-plan it; decide it.
You already built the private/public key approach
Keygen isn't doing your crypto. It's only minting.
| Piece | Where | Yours already? |
|---|---|---|
| Ed25519 offline verify | pocket-trust/src/license.rs (verified: 11 tests) | yes |
| Pinned 32-byte public key + account/product/policy ids | pocket-trust/src/keygen_pubkey.rs | yes |
activate_license(key) -> Entitlements, fully offline | pocket-ffi/src/core.rs | yes |
Pro gates on pairing_offer() / server_start() | pocket-ffi | yes |
| Mac license entry + keychain persist + re-activate on launch | Mac/Settings/SettingsView.swift, AppModel.swift | yes |
Envelope is key/<base64url payload>.<base64url ed25519 sig> — a signed JSON blob. Nothing Keygen-specific in it. Minting it yourself = generate a keypair, swap 4 constants in keygen_pubkey.rs, hold the private key. Verifier, tests, FFI, Mac UI unchanged.
So the question isn't "can we build it." It's what you give up by owning the signing key, and what a distributable key can never do.
Distributable (bearer) license keys — the honest ledger
What they buy you
- No infrastructure. No server to run, nothing to pay for, nothing to go down on launch day. A licensing outage can never lock out a paying customer.
- Actually offline. Matches the product's whole claim. Network Proof shows zero licensing requests, forever. For an audience that bought this app specifically because nothing leaves, that number is the pitch.
- No account, consistent with the wedge. Your stated advantage over LM Studio's LM Link is "no account." An activation call is a soft account.
- No privacy surface. No device fingerprints collected, no activation table, no personal data, no DPA questions.
- Instant fulfilment, any channel. Email, paper, QR, a conference sticker. No redemption flow to break.
- Cheap support. Reinstall = paste the key again. No seats to juggle, no "my Mac died" tickets.
- Survives you. If you stop maintaining PocketAI, every key still works. That's a real selling point for one-time-purchase buyers who got burned by subscription shutdowns.
- No vendor can deplatform you.
What they can never do
- A key is a bearer token. Infinitely copyable. One paste on Reddit and everyone has Pro. This is not a bug you can fix — it's what makes offline verification possible.
- Seat caps are impossible, by construction. Counting machines requires something that sees all the machines. "5 devices" in your terms would be unenforced honour system.
- Revocation is best-effort only. A refunded or leaked key on an offline Mac works forever. Ceiling is an opportunistic
/revocations.json+ a denylist baked into the next build. State that limit in the license terms; don't paper over it. - Chargebacks have no teeth. Refund processed, key still working.
- No visibility. You can't tell a key leaked, or size the damage, because nothing phones home.
- Expiry rests on the user's clock. Rollback bypasses it. Mitigate with a monotonic high-water mark (§9.2). Moot if licenses are perpetual, which yours are.
- Private key becomes recall-class risk. It leaks → anyone mints lifetime licenses → only fix is shipping a new pinned public key, which invalidates every existing key. Keygen carries that risk for you today; self-minting moves it to you.
My read
Leakage ceiling is the whole argument, and it's a 100× difference:
- Pure offline: one leaked key unlocks unlimited machines.
- One activation call per machine, ever (
12-licensing.md§1 option B): one leaked key unlocks 5.
That's bought for a single request per machine for the life of that machine — and it's honestly describable in UI copy: "this is the only time PocketAI needs the network." Network Proof shows it instead of hiding it, which is on-brand rather than against it. B also gives refunds and chargebacks actual teeth.
So: B, as the doc already recommends. Two things I'd add that it doesn't say —
- Don't over-invest. Any scheme falls to a patched binary. Licensing stops casual sharing, not reverse engineering. The failure mode worth engineering against is a key posted publicly, not a friend telling a friend — and a 5-seat cap covers legitimate multi-device use anyway.
- Price for weak enforcement. $39 one-time on a direct notarized download is the right shape: cheap enough that paying beats hunting for a key.
If you want to stay fully offline anyway (defensible — it's the purest version of the promise), then go option A honestly: drop the device-count language from the terms entirely, ship the revocation list + baked denylist, and treat leakage as a cost of the positioning. What you must not do is advertise "5 devices" while enforcing nothing.
Either path, keep Mac distribution direct. The moment the Mac app goes to the Mac App Store, external keys are a Guideline 3.1.1 problem again (§10).
Pending, concrete
- Revocation is plumbed but inert —
verify_license_key(..., revoked: &[String])is always called with&[]. True on both paths. - Entitlement isn't persisted —
license_entitlementlives inInnerand dies with the process; launch re-verifies the key rather than a cached token. - Generate the Ed25519 keypair offline, paper backup, write the re-issue script before launch, not during the incident. Two keys from day one (current + next) so rotation isn't a flag day.
Seven decisions blocking all of it (§11)
- A, B, or C? Seats 4 or 5? User-releasable?
- Self-mint or stay on Keygen?
- Trial: unit (remote turn / chat / session), budget (5 lifetime / 5 monthly), shared or separate LiveShortly pool, post-trial pairing?
- Worker + Supabase, or Supabase alone?
- Price — still $39 → $59 with a 5-device key?
- Refunds auto-revoke or manual?
- Is LiveShortly's paywall even yours to set, or the service's?
Answer 1, 2 and 5 and I'll implement — most of the risk here is ambiguity, not code. Want me to fold this pro/con ledger into §1 of 12-licensing.md so the decision record lives with the plan?
Understood — no code. Thoughts.
1. The decision tree collapses, and it doesn't land where I said
Walk it honestly:
You want a seat cap, revocation, and refund teeth → that needs one online call per machine → that's option B → Keygen already ships B, and it's free to 100 active licensed users.
Self-minting buys you: no vendor lock-in, and that's it. It costs you: custody of a recall-class secret, a Worker, a Supabase schema, a webhook on the money path, a re-issue script, and a rotation runbook. Taking on a key whose leak invalidates every license you've ever sold — before you have revenue — is paying real risk to save $0.
Revise my earlier answer: B, but via Keygen, not via self-minting. Revisit when the free tier actually binds — around 100 paying users, call it $4k of revenue. The switch stays cheap forever because the envelope is generic: 4 constants in keygen_pubkey.rs. That genericity is the asset. Don't spend it early.
2. You already own a device-identity system, and it suggests a different cap
The doc proposes inventing a fingerprint (salted SHA-256 of IOPlatformUUID). But pairing already does this work: Ed25519 device keys in Keychain, a devices table with pubkey + token hash, pairing_identity(), pinned-TLS handshake.
Which means the Mac can count something with perfect accuracy, fully offline: the phones it serves. Its own table.
So there are two different caps available, and they do different jobs:
- "≤5 Macs per key" — anti-piracy. Unenforceable offline. Needs the server.
- "≤N paired phones per Mac" — product shaping. Perfectly enforceable, offline, zero infrastructure, no fingerprint invention.
The second is honest and free but stops no piracy — a leaked key gives everyone a Mac that serves 5 phones. Worth being clear-eyed: it's not a security control, it's a product boundary.
The general point: you cannot buy anti-piracy offline. Not with better crypto, not with cleverness. If you want it, you pay one network call. If that call is unacceptable, stop calling the cap enforcement and call it terms.
3. Piracy isn't the loss function at $39
Pirates at this price point were never going to pay. The actual costs are support burden and paying customers' sense of fairness. Both are served by generosity, not tightness — 5 releasable seats reads as generous, and that's the point of the number, not deterrence.
Corollary: anyone determined enough patches the binary regardless. Enforcement spend is capped by that ceiling. Spend the budget on the trial instead.
4. The trial is the better lever, and it's already offline-enforceable
5 remote turns, counted on the Mac, lifetime — needs no server, no fingerprint, no vendor. Perfectly countable at the one place every remote request funnels through.
Conversion comes from a good trial far more than from a tight cap. The trial is also the part that's purely yours to design, with no custody risk attached. If effort is going anywhere this quarter, it goes there.
5. The funnel has a structural break, and it's Apple's fault
Guideline 3.1.1 means the phone can never show a price or a purchase link. But trial exhaustion happens on the phone — that's where the person is sitting when the 6th remote reply fails.
So intent peaks on a device that is legally forbidden to sell. The phone can only say "unlock on your Mac," and the person has to get up and go to another machine. That's the worst possible place for friction.
Thought: pre-empt it on the Mac. The Mac knows the count — surface "2 free remote replies left" in the HUD or menu bar while the person is still at the Mac, so the sell happens where selling is allowed. Treat the phone's error card as a fallback, not the funnel.
6. Perpetual keys quietly kill two problems
Clock rollback stops mattering (nothing to expire past). The monotonic high-water mark in §9.2 becomes optional. And "survives you" turns into a real differentiator for an audience that got burned by subscription shutdowns — Off Grid's subscription backlash is in your own research notes.
Keep them perpetual. The one-time-purchase positioning and the weak-enforcement reality actually agree with each other here.
7. The question I'd want answered before any of this
§11.7 — is LiveShortly's paywall even yours to set? If LiveShortly is a service with its own account model, gating it behind a PocketAI license may be their call as much as yours. That's not a licensing-architecture question, it's a "do we have the right to sell this" question, and it's upstream of everything in the doc.
Net: the cheapest correct move is to decide nothing structural, stay on Keygen, and spend the time on the trial and the Mac-side sell moment. The self-minting option stays open at a 4-constant cost for as long as you keep the envelope generic.
Documented in strategy/12-licensing.md — new §13 · WIP · thinking log, 451 → 612 lines. Marked thoughts only, nothing decided, nothing implemented.
Sections:
- 13.0 it's already built — Keygen only mints, the Ed25519 verifier/pinned key/offline activation are ours
- 13.1 revised recommendation: B via Keygen, not self-minting, until the free tier binds (~100 users / ~$4k)
- 13.2 distributable-key ledger — 8 pros, 7 cons, and the load-bearing line: anti-piracy can't be bought offline
- 13.3 we already own device identity → two different caps (≤5 Macs = anti-piracy, needs server; ≤N phones per Mac = product shaping, free and offline-perfect)
- 13.4 piracy isn't the loss function at $39
- 13.5 trial is the better lever, already offline-enforceable
- 13.6 perpetual keys kill clock-rollback and become a feature
- 13.7 3.1.1 funnel break — intent peaks on the device forbidden to sell; pre-empt on the Mac
- 13.8 upstream: is LiveShortly's paywall even ours to set
- 13.9 net recommendation
- 13.10 pending regardless — inert revocation, unpersisted entitlement, key-custody prep
Two cross-refs added so nobody reads the old advice cold: header line 6 points to §13, and §1.2 carries a Revised 2026-10-29 note (12-licensing.md:73) since its recommendation changed.
Not a separate WIP.md — kept with the plan it revises. Say the word if you'd rather it split out.
Notarization watch still armed on 59d80e95.
Four files, one modifier each — plus one dead block removed.
| File | Change |
|---|---|
PocketAI.Mac/Sources/Chat/ChatView.swift:~254 | + .defaultScrollAnchor(.bottom) — the actual bug |
PocketAI.Mac/Sources/LiveShortly/LSChatView.swift:~300 | + .defaultScrollAnchor(.bottom) — identical sentinel pattern |
PocketAI.Core/swift/Sources/PocketLiveShortly/LSSessionView.swift:~247 | + .defaultScrollAnchor(.bottom) — shared by both apps; stops the animated jump down from the first event |
PocketAI.Iphone/Sources/Chats/ChatView.swift:296 | − dead .task that claimed to scroll on open but always hit guard let last = messages.last while empty. Comment left explaining what actually does the work |
Why one modifier is the whole fix on Mac: it makes the first layout bottom-anchored, so the "tail" sentinel is on screen, so .onAppear keeps pinnedToBottom = true, so follow() works from then on. Patching follow() or the sentinel instead would have left the initial layout still top-anchored.
defaultScrollAnchor is iOS 17 / macOS 14; floors are iOS 18 / macOS 15, so no availability gate.
Not verified by a build. Two blockers:
laptopis unreachable —ssh: connect to host 192.168.29.51 port 22: Operation timed out. Your CLAUDE.md routes allxcodebuildto the remote MBP, so I stopped rather than silently building here.- Notarization of the Mac app is still in flight and will staple
build/dist/export/PocketAI.appwhen it returns. A local build writes tobuild/Build, a separate path — but worth knowing before I touch the project.
No test added: scroll position needs a UI test, and the PocketAIUITests suite is itself gated on sudo DevToolsSecurity -enable (OWNER-SIGNOFF §A).
Want me to build locally anyway, or wait for laptop?
Documented in two places, both existing files — no new doc.
strategy/DECISIONS.md → D127 (house style, follows D126): what was broken in each of the four transcripts, the mechanism on Mac (top-anchored layout → tail sentinel off-screen → .onDisappear clears pinnedToBottom → follow() refuses), the dead iPhone .task, and why one modifier at the root beat patching follow() or the sentinel. Records that it's unbuilt (laptop timed out, notarization mid-flight) and untested (XCUITest still gated on sudo DevToolsSecurity -enable, per D117/D119/D120).
strategy/02-design-system.md:243 → new spec bullet under §7.2, so the next transcript doesn't inherit it:
Opening a transcript lands on the newest message, never the first. This applies to every transcript in either app — chat and LiveShortly sessions alike. The anchor is what delivers it: a
scrollToinonAppear/.taskraces the message load and silently no-ops, and a bottom-pinned flag driven by an off-screen tail sentinel starts out wrong on a top-anchored layout. See D127.
Code changes from the previous turn are still in the working tree, unbuilt. Notarization watch still armed on 59d80e95.
Not notarized. Still in progress — 41 minutes so far.
status: In Progress submission 59d80e95, created 10:17:52Z (now 10:58:39Z)
stapler: PocketAI.app does not have a ticket stapled to it
spctl: rejected — source=Unnotarized Developer ID
dmg: does not exist
Signed, yes — Developer ID Application: Rohit Sehgal (4RK6YA795V), hardened runtime, timestamped. Notarized, no.
notarize-mac.sh + notarytool both still alive (2 processes), so the pipeline hasn't died — it's waiting on Apple. Once Accepted it staples and builds build/dist/PocketAI.dmg.
41 min is long but not alarming; Apple's typical turnaround is under 15 min and the queue occasionally drags. The earlier attempt f0fb898c came back Invalid in ~9 min, so the account isn't blocked.
My watch is armed and will report the moment it flips either way.
Done, type-checked. iPhone uses the shared LSSessionView, Mac uses its own LSChatView — so this landed exactly where you asked.
PocketAI.Core/swift/Sources/PocketLiveShortly/LSSessionView.swift — 4 changes:
1. Auto-refresh. startStream now wraps the SSE consumption in a while !Task.isCancelled retry loop. On a drop it backs off 1s → 2s → 4s → 8s → 15s → 15s and reconnects; after 6 failed attempts it surfaces the old error. Catch-up is free: the server replays its buffer on connect and the existing events.contains(where: { $0.id == e.id }) dedupe absorbs the overlap. Clean session_ended still exits rather than reconnecting.
Marked the ceiling rather than engineering around it:
// ponytail: a disconnect longer than the server's replay buffer
// loses the events in between; re-fetch the session on reconnect
// if that ever shows up in practice.
2. Manual Refresh in the … menu. The menu previously only rendered if let detail, detail.isOwner == true — a shared-session viewer had no … at all. Now the Menu is unconditional with Refresh (arrow.clockwise) at the top for everyone; owner-only items moved behind the binding with a Divider().
3. "Reconnecting…" status, not an error. loadError renders in PocketColor.statusBad (red), which would misreport a self-healing drop. Added a reconnecting flag and a separate banner in textTertiary.
4. Manual Refresh clears a pending retry. open() now resets reconnecting too — otherwise tapping Refresh mid-backoff cancels the retry task but leaves the banner stuck.
Verified with swift build --target PocketLiveShortly → clean, 10.9s. Real type-check, not just a parse.
Mac has the same gap, left alone — LSSessionModel.swift:134 sets "Stream dropped — reopen the session to catch up." with no retry. Same defect, different file; say the word and it gets the same treatment.
Want D128 logged in DECISIONS.md like the last fix?
D128 logged in strategy/DECISIONS.md, house style, lane L3.
While writing it I checked the spec and found the same pattern as D127 — 11-liveshortly-integration.md:126 (LSI-04) already required "auto-reconnect with backoff (1 s → 30 s max) while the view is open." The shipped code never implemented it. So I aligned the fix rather than leave it at my own numbers:
- backoff now 1/2/4/8/16/30s, capped at 30s, retrying for as long as the view is open
- after 6 attempts it shows
"Can't reach the server — still retrying."in red while still retrying underneath — a long outage gets reported without the retry quietly stopping. Clears on the next frame.
Rebuilt: swift build --target PocketLiveShortly clean, 3.6s.
Two things recorded in D128 as honest caveats:
- Placement deviates from spec. LSI-04 wanted reconnect inside a
PocketKit/LiveShortly/LiveShortlyStream.swiftabstraction; the shipped client isPocketLiveShortly/LSClient.stream, so the loop lives in the view's stream task. Behaviour matches, structure doesn't. - Mac still has the defect —
LSSessionModel.swift:134, same give-up-on-drop, untouched.
Two spec lines now have code that didn't match them (D127 §7.2 anchor, D128 LSI-04 reconnect). Worth a sweep for other "spec said it, code skipped it" gaps if you want one.
Still outstanding from earlier: nothing built through Xcode — laptop unreachable, and notarization of 59d80e95 was still In Progress as of 10:58Z.
New watcher armed — polls every 90s for up to ~4 hours, reports status plus Apple's log if it comes back Invalid, or the stapled ticket + Gatekeeper verdict + DMG if Accepted.
The old 45-minute watcher is about to expire and will report a final In Progress line. Ignore that one — it's the timeout, not a verdict.
53 minutes is slow but within what Apple's queue does on a bad day; the account isn't blocked, since the earlier attempt f0fb898c came back Invalid in ~9 minutes. Nothing to do but wait. If it's still pending in an hour or two, Apple's system status page for Developer ID Notary Service is the thing to check.
Apple's own guidance: most submissions finish in under 15 minutes, and they tell developers to design CI around a worst case of about an hour. Apple publishes live health for it as "Developer ID Notary Service" on their system status page.
Your own baseline backs that up — submission f0fb898c came back Invalid in ~9 minutes this morning, same account, same 20MB zip. So the pipeline is normally fast here.
What makes it drag:
- queue backlog or an active Notary Service incident (hours, not minutes)
- first submission of a new bundle id, occasionally slower
- large uploads — not your case at 20MB
So 53+ minutes is outside normal but not broken, and there's no action that speeds it up. Resubmitting would just put a second copy at the back of the same queue.
Worth knowing about your script: notarytool submit --wait with no --timeout waits indefinitely, so it won't give up on its own — it'll staple and build the DMG whenever Apple answers, even hours from now.
Want me to check Apple's system status page for a live Notary Service incident? That'd tell us whether this is your submission or everyone's.
Watcher's armed — I'll report the moment Apple answers, either way.
Working tree meanwhile has the two design fixes uncommitted and unbuilt through Xcode (laptop still down):
- D127 — bottom anchor on 3 transcripts, dead task removed on iPhone
- D128 — LiveShortly auto-reconnect + Refresh in
…(this one type-checked viaswift build)
Easy things available while you wait, none needing Xcode or the phone:
- Mac
LSSessionModel.swift:134— same give-up-on-drop as D128 - Localization gate fix — strip the 7 junk catalog keys, cut
knownRegions/CFBundleLocalizationsto[en]so the stores stop advertising 8 languages you don't ship - Sweep for other "spec said it, code skipped it" gaps, since D127 and D128 were both that
Or I hold here.
That's the old 45-minute watcher expiring, exactly as flagged — not a verdict.
notarization status: In Progress
Submission log is not yet available or submissionId does not exist
The log being unavailable is consistent with still-queued: Apple only publishes one once it reaches a verdict. Exit code 1 is just my script's "not Accepted" branch firing on timeout.
Replacement watcher (watch-notary2.sh, ~4h horizon) is alive and polling. Still waiting on Apple.
Notarized. Took 71 minutes.
status: Accepted
stapler: The validate action worked! ← ticket stapled
spctl: accepted — source=Notarized Developer ID
origin=Developer ID Application: Rohit Sehgal (4RK6YA795V)
dmg: build/dist/PocketAI.dmg — 23M, 17:00
Full chain green: Developer ID signed, hardened runtime, secure timestamp, notarized, ticket stapled, Gatekeeper accepts. PocketAI.dmg is shippable from getpocketai.app — opens on any Mac with no warning, including offline, since the ticket is stapled into the bundle.
Worth noting: the DMG was built from the pre-fix source. The D127/D128 transcript and LiveShortly changes aren't in it — they came after the 15:47 archive. Next build needs the whole archive → notarize → staple → DMG cycle again.
~/rsehgal/github/getpocketai/PocketAI.Mac/build/dist/PocketAI.dmg
Added as a spec + two tracked cards.
strategy/02-design-system.md → new §7.10 Notifications (S-NOTIF / M-NOTIF), marked TODO, not built. Grounded in what exists: the only notification anywhere today is Mac's PairingNotifier (AppModel.swift:439) for "Allow this iPhone?"; iPhone has none.
The constraint I led with, because it determines the whole design: iOS suspends a backgrounded app in seconds and APNs needs a provider server you don't run (00-README.md §3; push is out of scope per 11-liveshortly-integration.md §1/§6). A sleeping iPhone cannot be reached. Everything specced is a local notification posted by an already-running process, and the copy has to say so — an approval request that only arrives when the app opens would otherwise be the one dishonest promise in a product built on honest instruments.
Mac (M-NOTIF) — per-type switches:
- Approval needed — LiveShortly
input_requestedwith Allow/Deny asUNNotificationActions, so a verdict never needs the window. Hero case, and the seamnext-release/agents.mdconsumes. - Reply finished (suppressed when frontmost) · Download finished/failed with reason · Device paired/revoked, phone connected (fold
PairingNotifierin rather than keep two paths) · Licence activated/failed.
iPhone (S-NOTIF) — deliberately narrower:
- Download finished/failed from the background
URLSessioncompletion — the one case iOS does wake us for. - Reply finished, foreground or on return.
- Approval needed, labelled honestly as surfacing on next open. No notification type that silently never fires.
Rules: permission via the existing PermissionPrimer.swift pattern, never cold · declining is first-class, every type has an in-app equivalent so nothing is reachable only by notification · no marketing or engagement nudges (D5, and the no-nags commitment) · grouped by thread id per conversation.
Three open questions left for you in the doc: whether "Reply finished" earns its place on Mac over the Pulse strip; whether Allow/Deny from the lock screen should need authentication; and whether the iPhone's honest-but-narrow set justifies asking for the permission in V1 or should wait for the agents release.
Also: §7.8 Settings now lists a Notifications section, and strategy/BOARD.md has two todo cards — UI-20 (L5) and MAC-16 (L6), wave next.
All three added as task cards in strategy/11-liveshortly-integration.md §3 (LSI-10/11/12), plus rows on strategy/BOARD.md. Each grounded in the code rather than restated as a wish.
LSI-10 · Incremental fetch + cache — confirmed: LSSessionView.open() calls client.session(id) → GET /api/sessions/{id}, which returns the whole event log every time, and LSSessionsSection.load() already admits it in a ponytail: comment — "15s staleness guard, no cache layer — the full list fetch is the slow part." Also LSClient.stream(_ id:) takes no Last-Event-ID, so even reconnects replay the server buffer instead of resuming from the last seq held.
Split into three wins by cost, because one of them isn't free:
- Resume the stream with
Last-Event-ID— client-only,SSEParseralready supports the header, strictly less data. - Cache the transcript on disk per session — client-only, renders instantly then reconciles against the stream.
- Delta fetch on cold open (
?since_seq=) — needs the server, and §1 of that same doc says "Server changes: none." So it's a scope decision for you, not a task I can just file. Either 1+2 is the V1 answer, or §1 gets reopened deliberately.
Side benefit worth having: 1 and 2 also shrink the LiveShortly line in Network Proof.
LSI-11 · Loading + refresh on the list — LSSessionsSection (reached from Chats/ChatsListView.swift:315) has .task and .refreshable, so drag-to-refresh already works, but there's no loading indicator: loaded only flips after the fetch returns, so a slow list renders empty and reads as "no sessions." LSSessionView does this correctly with "Loading session…", the list doesn't. Spec: match that treatment, keep the drag, add an explicit Refresh (gesture-free and VoiceOver-friendly), and give the error row a Retry instead of being a dead end.
LSI-12 · "working" is hardcoded — LSSessionView.swift:166 and :244 print a literal Text("working") whenever agentBusy, which comes from a coarse event-type switch at :303–:309. A model generating a reply and a tool editing a file both read as "working." The data to do better is already in hand — the server distinguishes prompt/tool_call/file_write/output, and runningLabel (:894) already does it properly inside work runs. So: prompt → thinking, tool_call → running <tool>, file_write → editing <file>, output → running, falling back to "working" only for unrecognised types so a new server event degrades to today's wording.
One decision needed from you: whether LSI-10's step 3 justifies reopening the no-server-changes rule, or whether steps 1+2 are enough.