HAPI iOS (native companion)
Native SwiftUI client for HAPI. Fully independent of web/ at the code level;
it shares only the protocol contract (docs/api/) and the golden fixtures
(shared/fixtures/, produced by the consistency track).
Requirements
- Xcode 16 or newer (iOS 17 SDK).
- Deployment target: iOS 17.0.
- Runtime dependencies (SPM, declared in
Packages/HapiKit/Package.swift, used only by theHapiUItarget):swiftlang/swift-markdown(GFM parsing for the custom renderer) andraspu/Highlightr(code highlighting, kept behind a protocol so it is swappable).HapiProtocol/HapiClientstay dependency-free.
Build
Native chat scrolling architecture and acceptance checklist: Native transcript scrolling.
Open ios/Hapi.xcodeproj in Xcode and run the shared Hapi scheme, or from
the command line:
# App (simulator, no signing)
xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi \
-destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
# Package tests (macOS; protocol/client plus UI package tests)
swift test --package-path ios/Packages/HapiKit
CI runs package tests, the simulator build, and app-hosted transcript tests
on macos-15 via .github/workflows/ios.yml (triggered by changes under
ios/** and shared/fixtures/**).
Localization catalog
Hapi/Resources/Localizable.xcstrings is manually managed, including every
entry's "extractionState" : "manual". Keep SWIFT_EMIT_LOC_STRINGS = NO for the
app/extension in Debug and Release. That setting disables compiler extraction;
it does not prevent the Xcode editor from synchronizing or saving a catalog.
Unspecified ownership can still turn entries into stale during synchronization.
Use Xcode's native catalog formatting (key order, spacing, escaping), not a generic JSON formatter. After adding or editing translations, run from repo root:
python3 ios/scripts/localizations.py --check # read-only ownership check; no Xcode needed
python3 ios/scripts/localizations.py --fix # macOS/Xcode: mark manual, normalize native format
The normalizer uses xcstringstool on a temporary copy, verifies that all keys,
translations, plural variations and comments survive, and writes only if needed.
It never deletes real entries: review/remove unwanted auto-extracted additions
before --fix, rather than silently making them permanent. Keep dynamic values
and decorative text verbatim in SwiftUI where they are not localization keys.
CI checks manual ownership, tests normalization idempotence, and verifies that
building/testing does not rewrite the catalog. Formatting is delegated to the
installed Xcode rather than reimplementing its ordering in Python; --check
does not enforce one Xcode version's byte-level formatting on another version.
After migrating an existing checkout, use Product → Clean Build Folder if
Xcode still synchronizes old extraction results, then rebuild/reopen and inspect
the diff. Do not hide the file with .gitignore, skip-worktree, or restore it
unconditionally: real translation edits must remain visible and committed.
Reading typography
HapiUI separates color palettes (HapiTheme) from resolved Dynamic Type
metrics (HapiTypography). Install .hapiTypography() at a presentation root,
outside AnchoredTranscriptList; hosted rows inherit those metrics. Do not
scale the resolved values again. Body/user/composer text starts at 16pt,
inline code at 15pt, code/diffs/terminal at 14pt, and captions at 12pt.
Body and code add 3pt and 2pt of scaled inter-line spacing respectively.
The transcript and composer share a centered, at-most-720pt reading column with 16pt minimum side margins. Font, Bold Text, locale, and effective width changes invalidate height measurements while preserving the reading anchor. Ordinary streaming updates retain unchanged hosting roots and measurements.
User messages stay fully expanded through 8,000 characters and 120 source lines, even when they span multiple screens. Only larger payloads fold to a preview bounded to 2,000 characters / 24 lines. The folding threshold is separate from the preview budget; both bound the actual text passed to layout. View full message opens a screen-owned reader with one 4,000-character / 80-source-line part mounted at a time, previous/next navigation, and exact full-content copy. Paging preserves Unicode and whitespace without scanning the entire payload on open. The reader survives cell recycling and pauses hidden history/tail following; closing it preserves the reading position. Stored/sent messages are never truncated.
The UIKit transcript suite covers typography changes, recycling, shrinking text, tablet/phone widths, and tail following. Optional deterministic visual specimens cover light/dark/OLED, mixed Chinese/English, code, tables, diffs, approvals, and the actual composer. They use a non-networked test interactor; they are not live conversations or App Store screenshots. Capture into a new temporary directory, never over the release gallery:
TEST_RUNNER_HAPI_TYPOGRAPHY_CAPTURE=/tmp/hapi-typography-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/TypographySnapshotTests
Tool inspection
Plan proposals (ExitPlanMode / exit_plan_mode) are reading documents, not
activity summaries: their complete input.plan Markdown stays visible in the
conversation, before any approval controls. The same renderer is used in the
inspector; null output does not show a misleading "No output" placeholder.
Plans are prewarmed in the chat Markdown cache and never use the ordinary
tool-output preview/paging budget. The inspector retains raw fields under Source.
Tool summaries open a native large sheet instead of expanding their output inside the conversation. Tool groups also stay as one summary row: tapping one opens a native lazy list in the same inspector. Calls remain chronological, with each new presentation initially positioned at the latest tool. Streaming never scrolls the list; Latest tool explicitly returns to its end. Details push inside the same sheet, retaining the list position on Back. Both levels keep a toolbar Close action and native swipe dismissal. The inspector resolves stable group/tool IDs from live data; output-only changes do not reconfigure unchanged group summaries in the transcript. Group headers prioritize total calls over category counts. File/image summaries show the action and basename; commands use a bounded preview. Success is quiet, while running/errors remain visible; every row keeps a 44pt target. Edits show their recorded input, with a separate View current file action. Task/Agent sidechains open a process page; approvals and question answering remain in the conversation/process, not in the read-only inspector.
Question inspectors show recorded selections, custom answers and notes with
Markdown questions/options. request_user_input also restores answers from
historical results; live permission answers take precedence. Answered cards
avoid duplicate results, but retain errors and the full input/result/answers
under Source. Answer submission remains in the conversation.
Synchronous questions use a dedicated inline card, not the orange approval footer. Only one question is shown at a time: single-selection taps advance to the next question, while multiple-selection and text questions use Next question. Previous question retains all choices and notes; the last step always requires Submit answer. Recommended labels are display-only badges, never default selections or rewritten wire values. Other-answer/note fields expand on demand; text-only questions and prefilled drafts show them immediately. All form state survives transcript-cell recycling for the retained request. Successful records collapse to answer summaries; missing recorded answers are shown as handled, not inferred from local drafts. Ordinary approvals and the asynchronous question path are unchanged.
Question tests include pure navigation/answer-building checks and app-hosted layout/recycling specimens (fake data; no live agent or saved credentials):
TEST_RUNNER_HAPI_QUESTION_CAPTURE=/tmp/hapi-question-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/QuestionAnswerDraftTests \
-only-testing:HapiTests/QuestionCardPresentationTests
Inspection pauses transcript tail-following and hidden history paging, without opening another SSE subscription. Closing returns to the reading anchor; Back to latest explicitly resumes following. Trimmed records remain visible as labeled, read-only snapshots; missing groups retain their last membership, without switching to another group. Incomplete history is labeled and can be loaded from the conversation after closing the inspector. Large text is loaded in 20,000-character parts and can be copied in full; large diffs use paged source instead of eager rows.
The inspector recognizes namespaced command/script/patch calls. File reads use source-language highlighting; web/agent prose uses Markdown (large documents fall back to paged source). Common nested result envelopes are unwrapped, with command exit/status metadata kept visible. Source reveals the original input/result, including fields not shown in the preview; mixed text/media results stay JSON instead of losing non-text blocks.
The app-hosted suite covers selection, live updates, native sheet presentation, 2/42/240-call lists, initial positioning, detail navigation, surface handoffs, Unicode paging, and reading-position preservation. Native swipe gestures and release-device animation smoothness still need manual acceptance. Transcript specimens run the real ChatModel/ChatTranscriptView with fake HTTP and closed loopback SSE; sheet specimens are non-networked. Both use deterministic test records, not live sessions or App Store screenshots. Capture into a fresh directory:
TEST_RUNNER_HAPI_TOOL_CAPTURE=/tmp/hapi-tool-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/ToolInspectionPresentationTests \
-only-testing:HapiTests/ToolTranscriptPresentationTests
Home filtering
Home keeps a fixed Sessions title: hub switching on the leading edge, Filters and New Session on the trailing edge. The native menu currently offers machine single-selection; only applied filters add a summary line. Filters are transient per home/hub and never select a new session's machine. Options/counts come from all session summaries, including historical machines; the online roster supplies names only. Missing names use a labeled short ID. Session-count updates do not reorder options or reset the list's scroll position.
App-hosted filter tests use observable in-memory stores (no network or pairing). Optional layout captures are test specimens, not live or App Store screenshots:
TEST_RUNNER_HAPI_HOME_CAPTURE=/tmp/hapi-home-review \
ios/scripts/test-transcript.sh -only-testing:HapiTests/SessionListFilterTests \
-only-testing:HapiTests/HomeFilterPresentationTests
Linux verification (no Mac needed)
ios/scripts/linux-test.sh compiles and tests the non-UI targets
(HapiProtocol + HapiClient and their test suites, including the
shared/fixtures golden suites) in a Swift Linux container:
ios/scripts/linux-test.sh # full swift test in docker
ios/scripts/linux-test.sh --filter Chat # extra args pass through to swift test
Requires docker; defaults to the swift:6.1-noble image
(HAPI_SWIFT_IMAGE overrides). The script rsyncs the package and the
fixtures into a repo-depth staging dir (HAPI_LINUX_STAGE overrides;
kept between runs so builds are incremental) and mounts it at /work,
so the tests' #filePath-relative fixture resolution works unchanged.
Always edit the real worktree files — every run re-stages them.
HapiUI (SwiftUI + swift-markdown + Highlightr) cannot build on Linux;
Package.swift drops the UI product/targets and their dependencies
under #if os(Linux), and the script leaves those sources out of the
staging copy. The few Darwin-only APIs in HapiClient are conditionally
compiled (#if canImport(Security) for the Keychain store — Linux tests
use InMemoryCredentialStore through the CredentialStoring seam;
#if canImport(CryptoKit) for snapshot-file digests with an FNV-1a
fallback; #if canImport(FoundationNetworking) for URLSession types and
a delegate-based SSE transport where URLSession.bytes is unavailable).
Layout
ios/
Hapi.xcodeproj/ Hand-rolled minimal project (objectVersion 77).
Hapi/ App target sources. This is an Xcode 16 "synchronized
folder": add files here and they join the target
without touching project.pbxproj. Main areas:
Models/ AppModel (pairing state machine, hub
switching, deep-link routing, scene
phase) + HubSession (per-active-hub
APIClient/AuthManager/global SSEClient,
connection state, the M2a stores —
session list / machines / last-seen —
the SyncEventRouter feeding them, the
M2f message-window registry, and the
per-chat session factory) + ChatSession
(M2f per-open-chat wiring: session-scope
SSEClient with kept resume cursor,
window open/activate/tail-sync, ordered
event routing — one consume task,
every event awaited to the window
actor before the next — gap-handshake
full resync + detail refetch + catch-up
tail sync, scene-phase suspend/resume).
Features/ Pairing/ (welcome, VisionKit QR scan,
manual entry, shared confirm + error
states), Home/ (session list host with
leading hub switcher, native filter
menu + new-session action; one degraded
connection notice below navigation),
Sessions/ (SessionListView:
status dot with thinking pulse, title
cascade, flavor·machine·worktree meta,
pending/todo badges, unread dots,
pinned section, applied-filter summary,
pull-to-refresh, long-press
pin/archive; row taps push the chat),
Chat/ (interactive chat: ChatModel —
window state + session detail →
ChatPipeline off-main, ~100 ms
coalesced, last-seen stamping, header
cascade; ChatView — bottom-anchored
UICollectionView with cached heights,
ID/offset anchoring, viewport paging,
explicit return-to-latest, degraded
banners; Blocks/ — user bubble, agent
markdown, reasoning, tool cards with
per-tool bodies + knownTools-parity
presentation, tool groups, event rows,
cli output, generated images with
full-screen viewer, codex review;
M3ab upgrades: pending permission cards
grow the approval footer — Allow/Deny,
codex Allow/Abort, overflow
allow-for-session / allow-all-edits,
AskUserQuestion option cards + Other
free text, request_user_input fields
with user_note — and failed user rows
become tap-to-retry; Composer/ —
multiline input with long-press
"Send & steer" while thinking, abort
button, the A-M3f attachment tray
("+" → photo library / camera / files
pickers; upload-on-pick chips with
spinner / thumbnail / tap-to-retry,
✕ removes; attachments-only sends
allowed) and mic dictation (recording
chip with elapsed + cancel,
AVAudioRecorder m4a/AAC → hub
transcription, transcript appended to
the draft), and the queued-messages
bar with Steer/Edit/Cancel;
Attachments/ — pick preparation
(capped reads, ImageIO downscale of
>4 MB images to 2048 px JPEG, 512 px
previewUrl thumbs), camera capture,
and user-bubble previewUrl thumbnails
(off-main decode, also for web-sent
attachments); SessionConfigView —
the toolbar-gear sheet for permission
mode / model / effort per flavor),
Scratchlist/ (A-M4b per-session parked
notes, a sheet off the chat toolbar's
note icon with entry-count badge: entry
cards — 4-line preview, relative age,
authed attachment thumbnails/filename
chips — edit sheet with PhotosPicker →
guard → JPEG downscale → upload spinner
tile, full-screen attachment viewer,
per-entry "To composer" insertion, and
"Park current draft" in the screen
header — a deliberate placement
divergence from Android's composer
button, since the composer UI is owned
by the attachments package; the store +
guard live in HapiKit),
Links/ (app-wide \.hapiOpenURL handler:
https/http → SFSafariViewController,
confirm-first schemes → alert,
hapi-file:// → the session file viewer
when a chat installed its opener, else
an explanatory alert),
Files/ (A-M4a session files browser off
the chat toolbar folder icon:
Changes/Browse/Search segmented tabs —
branch header incl. detached, staged/
unstaged sections with status letters +
±counts and degraded-numstat banner;
lazy directory outline with hidden
toggle and dirs-first sort; debounced
ripgrep search — and the single-file
viewer: diff ⇄ full toggle with
staged/unstaged sides and web-parity
auto-fallback, markdown Source/Preview,
images, copy path/contents, citation
"Line N" hint chip; chat hapi-file://
citations push it in full mode),
NewSession/ (A-M3c create form, a sheet
off the session list "+": machine picker
with last-used preselect + health line,
directory field with debounced 250 ms
list-directory autocomplete (per-parent
cache) + per-machine recent-path chips +
exists probe (worktree-blocking /
simple two-tap-create), flavor picker
over CREATABLE, per-flavor options —
claude model/effort/yolo; codex machine
catalog (rpc_target_missing hides it),
reasoning effort, native permission,
collaboration + fast tier when
advertised; copilot agent mode; pi
managed note — session type simple/
worktree + name, UserDefaults draft +
prefs per hub; success dismisses the
sheet and pushes the chat),
Settings/ (A-M4de sheet off the home
hub menu: Appearance — theme mode
system/light/dark/OLED persisted via
HapiClient ThemePrefs and applied in
RootView (HapiUI palette +
preferredColorScheme; system follows
the OS, explicit modes override, OLED
= dark on pure black); Language —
system/English/简体中文; applies on relaunch;
owner-gated (JWT ns == "default",
fail closed) Usage dashboard — range
7d/30d/all, stat tiles, Swift Charts
daily BarMark chart with calendar-
filled days + tap/drag day selection,
byAgent/byModel top-8 bar rows — and
Storage dashboard — SectorMark donut
of db/wal/shm with the total in the
center, legend rows with bytes +
percents, path, refresh; About — app/
protocol versions + hub health probe
with retry).
HapiNotificationService/
Notification Service Extension target (P3):
decrypts the E2E push envelope on device with the
shared-Keychain push key and rewrites the generic
alert — dependency-free, see "Push notifications"
below. Also an Xcode 16 synchronized folder.
Packages/HapiKit/ Local SPM package with the real logic:
HapiProtocol Pure-Foundation protocol layer. As of M2a:
Models/ wire types mirroring shared/src/schemas.ts
(Session, SessionPatch + VersionedValue,
AgentState, DecryptedMessage, SessionSummary,
Machine, SyncEvent union, messages page),
plus the summary-side pure logic:
SummaryPatching (sessionSummary.ts
derivations + the useSSE list-patch rules
with their deliberate `>=` version gate —
vs the detail path's strict `>` — and the
keep-alive render-irrelevance filter) and
SessionSorting (the exact list order:
globalPinned > pinned > active > pending
desc among active > recency, stable)
Catalog/ permission-mode / flavor tables ported from
shared/src/{modes,flavors,copilotModes}.ts,
plus the static create-form option lists
(claude models/efforts, codex reasoning-
effort fallback — NewSessionCatalogs)
Patch/ versioned session-patch application ported
from web/src/lib/sessionPatch.ts
Pairing/ BindLink — parses both pairing QR forms
(hapicompanion://bind?hub=&code= and the
web /?hub=&token= URL), form-decoding in
lockstep with the Android port
Git/ A-M4a raw-git-stdout parsers ported from
web/src/lib/gitParsers.ts via the tested
Android twins: GitStatusParser
(porcelain-v2 records + branch headers,
buildGitStatusFiles merge, quirks
preserved — rename tab order, UU on both
sides, untracked dirs dropped) and
NumstatParser (binary -\t- markers,
brace + plain rename normalization,
multi-spelling stats map)
Chat/ the chat normalization/reduction pipeline
ported file-for-file from web/src/chat/**
(M2b+M2c): Normalize/NormalizeUser/
NormalizeAgent (decode tree incl. agy),
Tracer (sidechain grouping), Reducer*
(timeline, tool pairing, stream coalescing,
agent-run cards, events dedupe/folding,
cli-output merge), ToolGroups (+ codex
exploration family), the normative fixture
projection (FixtureProjection), and the
JS-semantics interop layer (JSInterop:
nullish coalescing, truthiness, canonical
JSON serializer). Validated block-for-block
against shared/fixtures/chat/** by
ChatFixtureTests (one parameterized test
per fixture, line-level diff on mismatch).
Window/ the message window state machine ported
from web/src/lib/message-window-store.ts +
messages.ts (M2d): MessageWindowState
(cursors/epoch/generations + persisted v2
snapshot shape), MessageWindowLogic (pure
transitions: tail sync, older pages +
epoch-mismatch reset, trims that never
drop queued rows, SSE ingest, optimistic
lifecycle, queued-state reconcile),
MessageMerge (position order, localId echo
replacement, 10 s dedup fallback) and
WindowMessage (wire row + client status,
tri-state invokedAt, identity-carrying
class — reset preservation compares rows
by instance like the web's `!==`).
Retention calls the chat pipeline's
normalize directly, so the two cannot
drift.
HapiClient Transport layer. As of M1b+M1c+M1d:
APIClient typed REST client (Endpoints/*):
Bearer auth, 401 -> refresh ->
retry-once, {error, code} parsing
(APIError), 256 MB URLCache for
generated images. HTTP goes through
the HTTPPerforming seam, so tests
inject a recording performer.
Auth/ JWT payload decoding, AuthManager
(actor; single-flight refresh via
POST /api/auth, proactive refresh
10 min before exp, terminal
authFailed state), Keychain
credential store (per-hub records
under run.hapi.companion),
HubRegistry (multi-hub + active
hub in UserDefaults),
HubPairingService (normalize ->
/health + protocolVersion check ->
/api/auth -> persist; unpair with
fallback), tested through the
HTTPPerforming seam.
SSE/ actor SSEClient — handshake-gated
connect (resume ok/gap surfaced),
sticky per-subscription cursor with
at-least-once replay, 10 s connect
timeout + 90 s staleness watchdog,
backoff per sse.md (1 s ×2 → 30 s,
300 s after 8 attempts, 0–500 ms
jitter), suspend/resume with the
45 s foreground staleness check,
NWPath change → immediate reconnect.
SSELineParser, ReconnectPolicy/
SSETimings, URLSessionSSETransport
(gzip streaming-decompression
verification TODO — fallback flag
`acceptEncodingIdentity`).
MultipartEncoder for the voice-transcription
endpoint (M4c).
Stores/ M2a @MainActor @Observable stores
mirroring the Android/web
semantics: SessionListStore
(sorted summaries + per-id detail
cache; full-session upsert
preserving hub-computed scheduled
fields, strict-> detail vs >=
summary patch gates, keep-alive
identity preservation, REST
fallback for unparseable data,
16 ms coalesced refresh,
optimistic pin/archive),
MachineStore (the machine-updated
decision tree), LastSeenStore
(unread watermarks + per-scope
baseline), SyncEventRouter
(SyncEvent fan-out + gap-handshake
full resync), DiskCache (500 ms
debounced atomic JSON snapshots
per hub for instant cold start).
Plus MessageWindowController
(M2d): per-session actor driving
the HapiProtocol window logic —
single-flight tail sync with
trailing drain, older-page loads,
SSE ingest hooks, optimistic
send/cancel, queued-state
reconciliation (≤1000-id batches)
— behind the MessagesProviding
seam (APIClient conforms; the
fixture harness scripts it);
WindowSnapshotStore (per-session
Caches snapshots, LRU 10);
MessageWindowControllers registry
(hydrate on open, seed across
resume/reopen id changes).
Chat/ ChatPipeline (M2f): the actor the
app's chat screen runs its
reduction on — queued-row filter,
normalize memoized by row instance
identity, reduce + toolGroups with
previousGroups-stable group ids.
NewSession/ pure create-form logic (A-M3c),
tested against the Android/web
reference: NewSessionForm (typed
draft, tolerant decode) +
NewSessionLogic (the exact spawn
body per SpawnSessionRequestSchema
incl. the per-flavor yolo/
permissionMode matrix, parent-path
derivation + suggestion filtering,
recent-path LRU(8), worktree-name
validation, codex catalog helpers,
draft sanitization).
Since M3ab: ChatInteractor — the
per-session interaction engine
(optimistic composer sends with
queue/steer delivery + retry,
session_inactive resume→retry with
superseding-id window seed + draft
migration, queued-bar
cancel/edit/steer with the
invoked-race reconcile,
flavor-exact permission
approve/deny bodies with
optimistic Resolving/
AlreadyHandled overrides settled
by the agentState patch, config
switches with optimistic detail
update + reload-on-error, codex
model catalog); ChatDrafts
(UserDefaults per hub+session,
debounced) and PermissionInputs
(AskUserQuestion /
request_user_input parsers).
SSE/ adds VisibilityReporter:
POST /api/visibility per tracked
handshake subscriptionId on
scene-phase flips, 404 pruning.
Settings/ A-M4de pure settings logic,
transcribed (with tests) from
the Android reference: UsageMath
(formatTokens/formatBytes web
thresholds, cache hit rate,
calendar-filled daily bars via
pure Gregorian day math),
StorageMath (donut slices +
half-up percents, web-geometry
test-locked), OwnerGate (JWT
ns == "default", fail closed),
ThemePrefs/LanguagePrefs
(@Observable UserDefaults
persistence). Endpoints/ adds
the owner-only GET
/api/usage/summary and GET
/api/storage/sqlite.
Since A-M3f: Attachments/ —
AttachmentPolicy (the pure
compress/reject/preview policy,
constants shared verbatim with
the Android port: 4 MB image
compress threshold → 2048 px
JPEG q85, 50 MB hard cap, 512 px
previewUrl data-URL thumbs) and
ComposerAttachments (the
upload-on-pick tray:
uploading/ready/failed chips,
retained payloads for retry,
best-effort deletes on remove /
mid-upload removal / discard,
consume() → the send body's
AttachmentMetadata); Voice/ —
DictationController
(idle/starting/recording/
transcribing over recorder +
transport seams, provider
memoized from GET providers,
first standard-capable entry
wins) with VoiceEndpoints
(GET /api/voice/transcription/
providers + the one multipart
endpoint POST
/api/voice/transcription).
Files/ A-M4a testable core of the files
feature behind the FilesRequesting
seam (Endpoints/FileEndpoints.swift
adds the six git/files REST calls;
wire types in HapiProtocol
Models/FilesApi.swift): FilesModel
(Changes = status + parallel
numstat sides merged through
GitStatusParser with per-side
degraded banners; Browse = cached
lazy tree flattening with hidden
toggle + dirs-first sort; Search =
300 ms debounce, limit 200) and
FileViewerModel (parallel diff +
base64 file loads, staged toggle
reload, image/markdown/binary
classification, web-parity
auto-fallback to full mode; the
unified-diff emptiness probe is
injected since the parser lives in
HapiUI).
Since A-M4b: ScratchlistStore —
the per-session scratchlist cache
(open/release observation,
16 ms-coalesced refetch on the
scratchlistUpdatedAt SSE signal
via SessionListStore.
onScratchlistInvalidation,
optimistic create/update/delete
with surgical entryId reconcile +
rollback, idempotent create via
client entryId, 200-entry cap
pre-check + hub 409 verdict,
base64 attachment upload with
in-flight names, cached limits
with offline defaults) and the
pure ScratchlistAttachmentGuard
(Fits/Downscale/Reject budget
verdicts) + Endpoints/
ScratchlistEndpoints and the
HapiProtocol wire models
(ScratchlistApi.swift); the
ChatInteractor grows the
scratchlist badge count and the
insertComposerText /
parkComposerDraft seams.
HapiUI Rendering foundation (M2e). SwiftUI, no app coupling:
Markdown/ MarkdownTransforms (string-level ports of
the web remark plugins: table repair,
indented-code disable, CJK autolink strip,
file-path + bare-URL detection, HrefPolicy)
and MarkdownRenderer (swift-markdown
visitor -> block tree -> SwiftUI views;
links flow through the \.hapiOpenURL
environment action, workspace files use
hapi-file://?path=&line= URLs)
Code/ CodeBlockView + SyntaxHighlighting
protocol with the Highlightr engine
(off-main, cached, 400-line cap)
Diff/ UnifiedDiffParser + DiffTextView
(hunks, +/- gutters, compact/expand)
Theme/ HapiTheme palettes (light/dark/OLED)
via the \.hapiTheme environment
Since M2f the app target links HapiUI and renders
chat prose/code/diffs through it; RootView injects
the palette and the \.hapiOpenURL link handler.
The app target stays thin; features live in HapiKit so they are testable
with swift test and free of UI concerns.
Fixtures
HapiProtocolTests reads the golden fixtures from the repo-root
shared/fixtures/ directory, resolved from the test file's own #filePath
(package root ios/Packages/HapiKit -> ../../../shared/fixtures), so the
suite needs a full repo checkout. Since M1a, FixtureDecodingTests decodes
every chat/*.json input as [DecryptedMessage] (+ AgentState), and
CatalogTests verifies the ported mode tables against
catalogs/modes.json. Since M2b/M2c, ChatFixtureTests is the pipeline
gate: for every chat fixture it runs the ported normalize → reduce → group
pipeline over the stored input, applies the normative projection, and
compares canonical JSON byte-for-byte against the stored expected —
failures are per-fixture and print the first differing line with context.
Since M2d, HapiClientTests/PaginationFixtureTests replays every
pagination/*.json op script against the real MessageWindowController
driven by a scripted MessagesProviding: it asserts the exact GET /messages
query objects (expectedRequests, including the explicit-null
untilAt/untilSeq of the first catch-up request), the older-load outcomes,
the queued-state reconcile candidates, and the final window projection
(expectedState) — all canonical-JSON compares with the same per-op labels
and first-differing-line diffs. Since M2f, HapiClientTests/ChatPipelineTests
drives the app-facing ChatPipeline runner with fixture-derived window rows:
non-empty unique stable ids, memo-stable recomputes, the queued-row filter,
and group-id stability across an older-page arrival (previousGroups).
Since M3ab, HapiClientTests/Chat/ChatInteractorTests transcribes the
Android interaction suite against the real client stack (only HTTP is
scripted): canonical approve/deny/send/config wire bodies asserted
byte-for-byte, optimistic send happy/fail/retry, 409 → resume → retry (same
and superseding id), queued cancel invoked-race, steer reconcile, edit
prefill, permission override lifecycle, and config optimistic + rollback.
Since A-M3f, Attachments/AttachmentPolicyTests ports the Android policy
matrix, Attachments/ComposerAttachmentsTests drives the upload tray over
the real client (exact base64 upload bodies, consume/retry/remove,
mid-upload removal orphan delete, detached discard),
Voice/DictationControllerTests transcribes the Android dictation suite
(fake recorder + transport), EndpointRequestTests covers the voice
endpoints (multipart shape included), and ChatInteractorTests gains the
attachment-send matrix (metadata on the wire byte-for-byte, refuse while
uploading, attachments-only sends, retry with identical attachments,
remove/discard deletes).
Since A-M4a, HapiProtocolTests/Git/* transcribes the Android git-parser
suites (expectations produced by running the exact inputs through
web/src/lib/gitParsers.ts), and HapiClientTests/Files/* transcribes the
files/viewer model suites against a fake gateway (numstat merge + degraded
banners, lazy tree cache, search debounce, staged-toggle reload, the
auto-fallback rules, base64/image/binary classification) plus asserts the
six git/files endpoint URLs against the recording performer.
Since A-M4b, Stores/ScratchlistStoreTests transcribes the Android
scratchlist store suite (optimistic CRUD reconcile/rollback, at-cap 409 +
local short-circuit, SSE-invalidation refetch for observed sessions, upload
in-flight progress + typed 413, attachment delete 409/ok mapping, limits
cache/offline defaults — canonical wire bodies asserted) plus the
SessionListStore invalidation-seam test; ScratchlistAttachmentGuardTests
ports the nine budget-verdict cases; and
Chat/ChatInteractorScratchlistTests covers the park/insert/badge seams
against a fake store.
Pairing (M1d)
How to pair the app with a hub (docs/api/client-contract/auth.md is the
contract; the app accepts multiple hubs and keeps one active):
- Local hub, manual entry — the everyday dev loop:
- Start the stack from the repo root:
bun run dev(or just the hub). The hub prints its URL and the access token (CLI_API_TOKEN, auto-generated into the hub'ssettings.jsonon first run). - In the app: Enter Manually → hub URL (e.g.
http://192.168.1.20:3006— the phone must reach the hub's LAN address, notlocalhost; a typed address without a scheme getshttp://prefixed) → paste the token → Continue → Pair. - The app checks
GET /health(reachability +protocolVersion), then exchanges the token viaPOST /api/authand stores it in the Keychain.
- Start the stack from the repo root:
- QR scan — start the hub with
--relay: the terminal prints two QR codes. The scanner accepts both — the companion deeplink (hapicompanion://bind?hub=…&code=…, canonical) and the web direct-access URL (https://<web>/?hub=…&token=…). The web app's Settings → Companion Pairing screen renders the deeplink QR too. - Deep link — opening a
hapicompanion://bindlink routes through the same confirm sheet; a link for an already-paired hub just switches to it. - Simulator: camera scanning is unavailable (
DataScannerViewControllerunsupported) — the scanner screen says so; use manual entry. Plain-HTTP LAN hubs work becauseNSAllowsLocalNetworkingstays enabled (ATS default otherwise). - Sign out (home → hub menu) deletes the stored token for that hub and
falls back to the next paired hub, or to pairing. A hub that terminally
rejects its stored token (rotated/revoked →
POST /api/auth401) is signed out automatically with a banner.
Manual test pass for the app layer (the pairing sequence itself is covered by
PairingLogicTests via injected HTTP fakes; AppModel/views are UI-bound):
pair via manual entry against a local hub → kill + relaunch (restores paired
state, SSE reconnects) → background/foreground (connection dot pauses and
resumes) → pair a second hub and switch between them → sign out of both →
scan both --relay QR forms → open a hapicompanion://bind link from Notes
(unpaired: confirm; paired: "already paired" notice) → rotate
CLI_API_TOKEN on the hub and watch the auto sign-out banner.
Push notifications (P3)
End-to-end encrypted APNs push, mirroring the Android FCM stack
(docs/api/native-companion-contract.md + the iOS extension below).
How it works
- Registration. After the first successful pairing the app asks for
notification permission (standard alert/sound/badge prompt — the Android
timing: never on a pristine unpaired install), registers with APNs, and
sends
POST /api/devices/register{token: <hex APNs token>, platform: "ios", deviceId: <stable UUID>, pushKey: <base64 32-byte key>}to every paired hub — each hub pushes independently for its own namespace. Registration re-runs on every app start and token rotation (cheap upsert; heals reinstalls and hub-side pruning), and Settings → Notifications has a manual Re-register push. Sign-out sends a best-effortDELETE /api/devices/register {token}from a credentials snapshot taken before the Keychain wipe. There is no background retry queue (the Android WorkManager part has no iOS equivalent) — the next trigger heals transient failures. - E2E envelope. The hub never sends plaintext through APNs. The wire
notification is
{aps: {mutable-content: 1, alert: <generic>}, hapi: {v: 1, e: <envelope>}}whereeisbase64(nonce[12] || AES-256-GCM ciphertext || tag[16])over the FCM data-contract JSON, keyed by this install'spushKeywith AADhapi-push-v1. Whether the hub delivers via direct APNs or a relay is entirely hub-side — the app only ever registers and decrypts. - Notification Service Extension. The
HapiNotificationServiceappex decrypts on device: reads the push key from the shared Keychain access group ($(AppIdentifierPrefix)run.hapi.companion.push, after-first-unlock so lock-screen pushes decrypt), swaps in the real title/body, stamps the action category, and stores the decrypted fields inuserInfofor the tap/action handlers. Undecryptable payloads deliver the generic alert unchanged. The appex links nothing beyond the SDK — it carries a ~60-line copy of the HapiKitPushEnvelopedecrypt, kept honest by the shared test vector. - Actions.
permission-request→ Allow / Deny;readyandtask-notification→ inline Reply. Handlers run in the notification delegate's async completion and resolve the owning hub Android-style (active hub first, then the roster; 404 "Session not found" / 403 = try the next hub) — approve/deny post{}, reply posts{text, localId}. Failures surface as a local notice instead of vanishing. A tap deep-links to the session chat; a push for the chat currently on screen is suppressed (the in-app SSE stream is already showing it).
Layer map: HapiClient/Push/ (envelope + payload + key, Linux-tested),
Endpoints/DeviceEndpoints.swift (register/unregister),
Hapi/Models/PushCoordinator.swift (registrar + delegate + action runner),
HapiNotificationService/ (the appex). The AES-GCM decrypt and the contract
test vector are verified by HapiClientTests/Push/* — structural checks run
in the Linux container, the CryptoKit vector/tamper tests on Darwin CI.
First build with push (Xcode-side, once): the project now has two
targets. Signing is Automatic, but a personal/team identity is required
for push entitlements:
- Select your team under Signing & Capabilities for both the
Hapiapp target and theHapiNotificationServiceextension target. - The app target ships
Hapi/Hapi.entitlements(aps-environment: development— Xcode/App Store flips it toproductionat distribution) plus the shared keychain group; the extension ships the keychain group only. If Xcode prompts to register the capability, accept — free personal teams cannot sign push entitlements, a paid/organization team is needed. - Simulators cannot receive APNs: expect
didFailToRegister…there (the Settings row shows the error). Everything else — decrypt, categories, actions — can be exercised withxcrun simctl pushusing a payload whosehapi.ewas produced with the device's registered key.
Milestone history (track A of the native-clients plan)
- M0 — initial scaffold: project, HapiKit package, CI, one passing test.
- M1 — foundations: HapiProtocol wire models + catalogs; APIClient + auth
(Keychain, single-flight 401 refresh); SSEClient + reconnect state machine +
versioned patch application (incl. gzip streaming check); pairing flow
(VisionKit scan +
hapicompanion://binddeep link + multi-hub). - M2 — read-only chat: session list; chat pipeline port (normalize/reducer/toolGroups, fixtures green is the gate); message window store; Markdown/code/diff renderers; read-only ChatView with paging.
- M3 — interaction: composer (optimistic send, queue/steer, drafts, reopen migration, slash commands); permission UX; new session; attachments.
- M4 — secondary features: files/git; Scratchlist; dictation; usage/storage (Swift Charts); settings.
- M5 — polish: zh-CN localization, Dynamic Type/VoiceOver, long-session memory profiling, App Store material.
Notes
- The
hapicompanion://URL scheme is registered viaHapi/Info.plist, alongside the camera (QR pairing + attachment capture) and microphone (dictation) usage strings; everything else is generated throughGENERATE_INFOPLIST_FILE+INFOPLIST_KEY_*build settings. The modern out-of-processPhotosPickerneeds no photo-library permission, so there is noNSPhotoLibraryUsageDescription. run.hapi.appis the bundle id; signing isAutomaticand CI builds withCODE_SIGNING_ALLOWED=NO.- CI uses the runner's default Xcode; each job prints
xcodebuild -versionfirst so failures are attributable to a toolchain bump.