Machine cli mtimes and file/directory modified are Double on the wire
(fs.stat mtimeMs carries sub-ms precision); integer decode threw on real
hub data. Mirrors the Android LenientEpochMs fix; 461 package tests green
in the Linux harness.
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).
Port web/src/chat/** file-for-file into HapiProtocol/Chat/:
- ChatTypes: block/tool/permission/usage models; AgentEvent as a
record-backed struct (verbatim wire projection) with a typed Kind view
and exact-key-set constructors for every synthesized event shape
- Normalize/NormalizeUser/NormalizeAgent: the full decode tree (codex /
output incl. agy / event families, skip rules, stringify-never-drop)
- Tracer: sidechain grouping via parentToolUseId with prompt-match and
parentUuid-chain fallbacks (orphan reparenting included)
- ReducerEvents (pipe-format limit parsing, dedupe, api-error folding),
ReducerTools (BlockBox shared-mutation model, JS-spread-faithful
permission merging via presentKeys), ReducerCliOutput,
ReducerTimeline (tool pairing, stream coalescing, agent-run cards,
title-changed synthesis, sentinel suppression), Reducer
(pending-permission synthesis gates, latestUsage, goal filtering)
- ToolGroups + codex-exploration family (codexCommandPresentation's
grouping predicates); ToolPresentation (minimal title/subtitle,
full knownTools catalog deferred to HapiUI)
- JSInterop: JS semantics pinned once (nullish coalescing over wire
values, truthiness, safeStringify, canonical JSON serializer with
integer-preserving number formatting, JS-anchored regex helpers)
- FixtureProjection: the normative projection (projection.ts) plus
runChatFixturePipeline
- ChatFixtureTests: one swift-testing case per shared/fixtures/chat/*.json
(48), canonical-JSON byte comparison, per-fixture line-level diff on
mismatch, VERSION gate
Dictionary-order note: wherever the TS relies on JS object insertion
order (agentState requests iteration), the port imposes ascending key
order — equivalent to the reference replay because fixture inputs are
canonically key-sorted on disk and JSON.parse preserves that order.
- 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.