Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.
Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.
Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention.
Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main.
Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance.
* refactor(web): route create-form permission control through one native-select predicate
Extract usesNativePermissionSelect(flavor) (grok || codex-family, matching
the existing iOS/Android predicate of the same name) and route
PermissionField's select-vs-toggle gate through it instead of an inline
condition. Rename the codex-family-only state codexFamilyPermissionMode to
nativePermissionMode since it now backs a shared predicate, not just the
codex family. Behavior is unchanged: any stale sessionStorage draft written
under the old codexFamilyPermissionMode key has no value under the new key
and falls back to 'default', which only matters within a single browser
tab's lifetime.
* feat(web): let Claude pick a permission mode when creating a session
Claude was the only create-form flavor still on the global HAPI YOLO toggle
while grok and the codex family got the native permission select, so there was
no way to start a session in Plan Mode without creating it first and switching
the mode from the composer. usesNativePermissionSelect now gates the control
for claude as well, and the spawn body carries permissionMode (including
'default') instead of yolo, which is the shape the other native-select flavors
already send.
The stored hapi:newSession:yolo preference is bridged into the select rather
than dropped, but only for the flavors that have actually moved onto it
(LEGACY_YOLO_BRIDGE_AGENTS = codex, claude). copilot, gemini, kimi and opencode
moved earlier and settled on 'default'; re-enabling Yolo for them now would
widen permissions rather than migrate a preference. This narrows the
sessionStorage draft bridge too, which until now fired for the whole codex
family with no allow-list, so their draft restores yield 'default' instead of
'yolo' — same-tab-lifetime state only.
Claude and the codex family share one nativePermissionMode state and their mode
sets do not overlap, so the existing agent-change reset plus the flavor filters
in the draft loader and the stored launch settings are what keep a codex mode
out of a Claude spawn. Adds the regression test that pins it: pick a mode under
codex, switch to Claude, create, assert the payload carries 'default'.
* feat(ios): let Claude pick a permission mode when creating a session
Extend usesNativePermissionSelect to include claude alongside grok and the
codex family, matching the web change. buildSpawnRequest now derives both
yolo and permissionMode from that single predicate instead of two separate
local flags, so claude sends permissionMode (including 'default') and no
longer sends yolo. Unlike web, iOS carries no persistent YOLO preference
across sessions to migrate — the toggle only lives in the in-memory form or
a draft deleted on success — so there is no bridging logic to add here.
* feat(android): let Claude pick a permission mode when creating a session
Extend usesNativePermissionSelect to include claude alongside grok and the
codex family, matching the web and iOS changes. buildSpawnRequest derives
both yolo and permissionMode from that single predicate, so claude sends
permissionMode (including 'default') and no longer sends yolo. Unlike web,
Android has no persistent YOLO preference to migrate: the toggle only lives
in the form draft, which is deleted once a session is created.
The agent-switch test asserted claude renders the YOLO toggle; it now checks
the native select for claude and keeps the toggle assertion on cursor, which
still carries it.
Constrain transcript rows to the viewport and replace competing scroll anchors with measured tail corrections. Preserve following through media resizing and reset scroll state when the transcript remounts.
Gate history paging on reader gestures and cover tail-following behavior with seven regression tests.
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.
Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
FNV-1a filename fallback, FoundationNetworking imports for URLSession
types, corelibs URLCache diskPath: initializer, get-only
waitsForConnectivity, delegate-based SSE transport where
URLSession.bytes(for:) does not exist
Per-session parked notes mirroring the Android B-M4d feature:
- HapiProtocol Models/ScratchlistApi.swift: wire types for entries CRUD
(idempotent create via client entryId+createdAt), attachment metadata,
limits (defaults from shared/src/scratchlistAttachments.ts), upload
envelope, and the typed error codes (scratchlist_at_cap,
scratchlist_attachment_too_large, scratchlist_attachment_in_use, ...).
- Endpoints/ScratchlistEndpoints.swift: GET/POST/PUT/DELETE entries,
limits, base64 upload, raw-bytes attachment fetch, attachment delete.
- Stores/ScratchlistStore.swift: @MainActor @Observable per-session cache
behind the SessionScratchlistStoring seam - open/release observation,
16 ms-coalesced refetch on the scratchlistUpdatedAt SSE signal,
optimistic create/update/delete with surgical entryId reconcile +
rollback (refresh preserves in-flight optimistic creates), 200-entry cap
pre-check + hub 409 verdict, uploadsInFlight, cached limits with
offline defaults, UTF-16 text clamp at 10000.
- Stores/ScratchlistAttachmentGuard.swift: pure Fits/Downscale/Reject
budget verdicts ported verbatim.
- SessionListStore: onScratchlistInvalidation callback fired when a
session patch carries scratchlistUpdatedAt (the seam the M4b comment
reserved); HubSession wires it into the store and injects the store
into ChatInteractor.
- ChatInteractor (additive tail section): scratchlist store property,
scratchlistCount badge seam, insertComposerText, parkComposerDraft
(composer clears only after the hub accepts; at-cap/failed keep the
draft).
- Features/Scratchlist/: sheet off the chat toolbar's note icon with
count badge - entry cards (4-line preview, relative age, authed
thumbnails via NSCache loader, filename chips), edit sheet
(PhotosPicker -> guard -> JPEG downscale -> upload spinner tile,
remove, delete/save), full-screen viewer (GeneratedImage pattern),
per-entry To composer, and Park current draft in the screen header (a
deliberate placement divergence from Android's composer button - the
composer UI is owned by the concurrent attachments package). iOS-only
import step transcodes disallowed-but-decodable rasters (HEIC) to JPEG
before the guard.
- Tests (36, all transcribed from or mirroring the Android suites): store
CRUD optimistic/rollback/at-cap/invalidation/upload/limits with
canonical wire-body asserts + the SessionListStore seam test, 9 guard
verdicts, 9 interactor park/insert/badge seam tests over a fake store.
Verified on Linux via a dockerized Swift 6.0 scratch copy (FIFO HTTP
performer): full HapiClient build under strict concurrency, 36/36 new
tests green, SessionListStore 17/17 and ChatInteractor 29/29 suites
green; app-side screen model + loader typechecked against stub
frameworks; SwiftUI views and the CG-based import parse-checked (macOS
CI compiles them).
Interaction layer turning the read-only chat into a working remote control,
mirroring the merged Android B-M3ab feature-for-feature (web authority where
the ports disagreed):
- ChatInteractor (HapiClient, fully swift-test covered): optimistic composer
sends (appendOptimistic -> POST -> status settle), queue-by-default with a
long-press Send&Steer intent while a turn is active, tap-to-retry on failed
rows (steer retries degrade to queue), per-session drafts
(UserDefaultsChatDrafts, hub-scoped keys, debounced + flushed on close).
- session_inactive (409) recovery: one POST /resume (current permissionMode)
then retry; a superseding session id seeds the new window
(MessageWindowControllers.seed), migrates the draft, retargets the
optimistic row and emits sessionSuperseded -- ChatView/HomeView replace the
navigation entry in place.
- Queued bar: uninvoked sends in web sort order with Cancel (optimistic
DELETE; invoked-race ingests the authoritative row as sent), Edit
(cancel + composer prefill, newer-draft guard) and Steer (invoked answers
reconcile a missed consume); single-flight per-row op guard.
reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
claude {} / allowTools (Bash(cmd)) / mode:acceptEdits, codex-family
decision approved / approved_for_session / abort via deny -- plus
AskUserQuestion flat answers (option cards, Other free text, no-questions
fallback, cursor stable ids) and request_user_input nested answers
(user_note suffix, required validation); optimistic Resolving /
AlreadyHandled (404/409) overrides settled by the agentState patch.
ChatPipeline now re-attaches the window row's client status so failed
user rows actually render the retry affordance (web normalize.ts parity;
the Android reference misses this overlay).
- Session config sheet (toolbar gear): catalog-driven permission-mode picker
with tones, claude static model/effort catalogs (ModelCatalog port), codex
models via new GET /sessions/:id/codex-models endpoint + wire types with
per-model reasoning efforts; optimistic detail updates
(SessionListStore.updateDetailLocal, new) rolled forward to server truth
on error.
- Lifecycle: VisibilityReporter posts POST /api/visibility per tracked
handshake subscriptionId on scene-phase flips (404 prunes); ChatSession
exposes its subscriptionId and feeds the reporter; the global SSE pipe was
already HubSession-lifetime on iOS.
- Tests: ChatInteractorTests transcribes the Android interaction suite
against the real APIClient/AuthManager/SessionListStore/window registry
with only HTTP scripted -- canonical JSON bodies asserted byte-for-byte
(send/approve/deny/config), optimistic send happy/fail/retry, 409 resume
both id paths, cancel invoked-race, steer reconcile, edit prefill,
override lifecycle, config optimistic + rollback, drafts, abort.
HapiProtocol/Window/ — pure port of web/src/lib/message-window-store.ts +
messages.ts, mirroring the Android reference port 1:1:
- MessageWindowState: constants (400/600/800/800/200), MessagePosition,
OlderLoadOutcome, full InternalState fields, persisted v2 snapshot shape
- MessageWindowLogic: every transition (merge/trim preserving queued rows,
latest replace with request-baseline identity preservation, tail sync
begin/apply/finish, older pages + epoch-mismatch reset, enterTailMode
that deliberately keeps requiresLatestReset, activate, SSE ingest with
hidden-row cursor advance, markConsumed stamping server rows to 'sent',
optimistic lifecycle, queued reconcile, hydrate/persist, seededState)
- MessageMerge: position comparator (ASCII tie-break), localId echo
replacement preserving status/invokedAt, 10s sent-dedup fallback
- MessageRetention: calls the fixtures-green chat pipeline's
normalizeDecryptedMessage directly (no hand-mirrored tree to drift)
- WindowMessage: identity-carrying final class (web's !== baseline
classification), tri-state invokedAt, buildOptimisticMessage
HapiClient/Stores/ — async half:
- MessagesProviding seam (three-variant MessagesPageQuery; APIClient
conforms via its existing endpoints)
- MessageWindowController actor: single-flight tail controller with
trailing drain and a synchronous generation bump before the first await
(the web runs to its first suspension; Android used UNDISPATCHED),
fetchOlder with onBeforeApply veto, SSE hooks, optimistic append/status/
cancel-invoked, queued-state reconciliation in 1000-id batches, seedFrom
- WindowSnapshotStore: per-session JSON snapshots, LRU 10 (dedup TODO with
the session-list package's cache) + MessageWindowControllers registry
Tests (swift-testing):
- PaginationFixtureTests: parameterized replay of shared/fixtures/
pagination/*.json against the real controller via a scripted provider;
asserts expectedRequests (canonical JSON incl. explicit-null untils),
expectedOutcome, expectedCandidates, and the final expectedState
projection with per-op labels and first-differing-line diffs
- MessageWindowControllerTests: tail-sync coalescing + trailing drain,
concurrent-SSE preservation across a reset replace, cursor-no-advance
guard, snapshot round-trip/LRU, seedFrom
All 11 fixtures verified green against a line-by-line Python mirror of
these exact algorithms (no local Swift toolchain; CI runs the real suite).
- HapiProtocol/Pairing/BindLink: parses the companion deeplink
(hapicompanion://bind?hub=&code=) and the web direct-access QR
(?hub=&token=) with URLSearchParams form-decoding semantics, in
lockstep with the Android port (tests mirror BindLinkTest.kt).
- HapiClient/Auth/HubPairingService: normalize -> GET /health
(reachability + protocolVersion) -> POST /api/auth -> persist
Keychain + registry + active hub; unpair with fallback. Covered by
PairingLogicTests through the HTTPPerforming seam.
- App layer: AppModel (@Observable @MainActor pairing state machine:
restore, pair, switch, sign out, deep-link routing, scenePhase,
terminal-auth-failure banner) + HubSession (per-active-hub APIClient/
AuthManager/global SSEClient with suspend-resume and a connection
state for the UI; store routing is TODO(M2)).
- Pairing UI: welcome flow, VisionKit QR scanner (with Simulator/
permission fallbacks), manual entry (paste-friendly), shared confirm
sheet with per-PairingFailure error states.
- HapiApp routes hapicompanion:// through AppModel (paired hubs switch
with a notice, never log the token); RootView switches unpaired/
paired and hosts the deep-link confirm sheet; HomePlaceholderView
shows hub, connection dot, hub switcher (M2a replaces it with the
session list).
- Info.plist: NSCameraUsageDescription; README: pairing guide + manual
test pass.