Running sessions previously suppressed the ordinary unread dot, hiding
answers the user had not seen. Unread is now
manualUnread || contentRevision > readRevision on web, iOS and Android.
Web rows draw the running/background spinner beside the unread dot, and
unread outranks background status. Because session patches arrive before
the message they count, a chat following its visible tail hides its own
ordinary dot until the rendered acknowledgement lands (display-only; web
and iPad split view).
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices.
Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
Persist and backfill the last user-message timestamp, and use it for deterministic session ordering across web and native clients while preserving manual pins.
Remove the fixed-order setting and guard ordering against transcript echoes, replayed messages, and stale REST/SSE updates. Add regression coverage and regenerate the SSE fixture.
* feat(android): add scratchlist row move, copy, and delete-confirm actions
Align the scratchlist entry card with the web client's per-row actions
(ScratchlistPanel inventory):
- Move up/down: adjacent swap, disabled at the list edges, local-only
like web v2 — no protocol request; the next refetch restores hub
`createdAt DESC` order (pure `ScratchlistEntryOps.moveEntry`).
- Copy: copies only entry.text via ClipboardManager (FullTextAction
precedent), swaps the icon to a check for 1500 ms on success, single
per-list copied state, silent no-op on failure.
- Delete confirm: texts strictly longer than 100 characters (web
SCRATCHLIST_CONFIRM_DELETE_THRESHOLD, untrimmed length) confirm via
AlertDialog before delete, both from the row and the editor sheet;
shorter notes delete directly.
New en/ru/zh-rCN strings ship in the same commit.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): guard saved scratchlist notes and restart copy feedback
* test(android): uniquely select scratchlist confirmation actions
* Fix Android scratchlist editor test targeting
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): settled permission verdict details
Port the web PermissionFooter verdict classification so a settled
permission row explains HOW a request was decided, not just that it was.
Behavior vs web (web/src/components/ToolCard/PermissionFooter.tsx:11-63):
- codex family sessions (flavor family + Codex/Gemini/OpenCode/Copilot/
Cursor tool prefixes): approved + decision approved_for_session shows
"Approved for session"; denied + decision abort shows "Aborted" with
the wire reason appended when present.
- claude sessions: mode acceptEdits shows "Approved: allow all edits"
(previously the raw mode id was appended); decision approved_for_session
or an allowedTools hit — plain tool name or the `Bash(command)`
qualifier built from the tool input command/cmd — shows
"Approved for session" (previously indistinguishable from a plain
approval).
- canceled now appends the wire reason (previously dropped).
- resolved/pending/denied unchanged in shape; denied keeps the error tone
and aborted shares it.
Deliberate divergence: web's own settled footer renders only a minimal
red reason box (PermissionFooter.tsx:207-220) — its formatPermissionSummary
classification is unreachable there. Android renders the classified
verdict line in the existing subdued row style instead, as intended.
Implementation: new pure JVM classifier
feature/chat/permissions/PermissionVerdict.kt (exhaustive status ->
PermissionVerdictLabel mapping, unit-tested in PermissionVerdictTest);
ToolCallBlockView.PermissionStateRow becomes a thin renderer over it.
Strings added in en/zh-rCN/ru (ru mirrors en key order; zh values from
web locales).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): persistent permission-mode chip + inactive gating
Item 2 — persistent permission-mode chip above the composer, mirroring
the web StatusBar mode span (StatusBar.tsx:253-265, 389-393):
- New PermissionModeChip composable + PermissionModeChipRule pure object:
resolve the raw wire mode, keep it only when the flavor offers it, and
hide `default` everywhere except copilot (model=auto sessions still
surface the chip). Unknown wire ids hide (no raw fallback).
- Tone mapping follows the web PERMISSION_TONE_CLASSES (StatusBar.tsx:
43-48): neutral=onSurfaceVariant, info=primary, warning=tertiary,
danger=error. Labels stay en catalog strings — web mode labels are
hardcoded English, not i18n.
- The chip consults the unfiltered flavor catalog: the concurrent-clients
Safe-Yolo carve-out only narrows the switcher, never the chip.
- Deliberate divergence: the web span is not clickable (its switcher is
the composer settings sheet / Shift+Tab). Android has no Shift+Tab, so
tapping the chip opens the session config sheet that hosts the
switcher — same surface as the top-bar gear, no new top-bar icon
(ChatScreen keeps its two-icons-max rule).
Item 4 — permission-mode gating aligned with web (SessionChat.tsx:
2079, 2138-2163; HappyComposer.tsx:536-537, 1544-1549, 1960-1995):
- Inactive sessions stay switchable, matching web: SessionChat passes
allowSendWhenInactive unconditionally, so configurationControlsDisabled
never trips for inactive threads and the offline notice stays
informational only (the send path auto-resumes).
- The permission section now disappears for copilot sessions under local
terminal control — web withholds onPermissionModeChange there and the
whole section then hides (HappyComposer.tsx:1595). Other flavors keep
switching while terminal-controlled.
- ChatViewModel.setPermissionMode gains the matching belt-and-suspenders
guard; the controlledByUser derivation is extracted into
terminalControlled() (controlledByUser && !concurrentClients, same as
SessionChat.tsx:996) and shared with buildConfigUi.
Tests: PermissionModeChipTest (6 cases — default/codex/copilot/pi/unknown/
safe-yolo), ChatViewModelInteractionTest +3 (withheld for terminal
copilot; survives terminal claude + shared-clients copilot; inactive
stays switchable). No new user-visible strings (chip label is catalog
en, contentDescription reuses chat_config_permission_mode).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(android): derive the dsh chip expectation from the flavor catalog
The hardcoded "dsh offers no modes" assumption breaks on trees where dsh
gained the codex-style mode list — derive the expectation from the
catalog so the test pins the actual contract (the chip mirrors the
flavor's offered list).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): permission input semantics parity (editor inputType, URL confirm, grok auto gate)
Three behavior alignments with the web app's permission surfaces:
1. Editor inputType (web requestUserInput.ts:152-153, 191-195):
a required text question with `inputType === 'editor'` is always
answered and its note is pushed untrimmed (even the literal
"user_note: "); non-editor notes stay trimmed and optional.
Android: PermissionInputs.requestUserInputAnswerValues /
isRequestUserInputAnswered now take the question, gate on the
literal "editor" inputType; the footer's note field grows to
minLines = 2 to invite multi-line answers.
2. request_user_input URL confirmation (web requestUserInput.ts:46-70,
129-141; RequestUserInputFooter.tsx:142-152): the sign-in url only
survives when it parses as http/https; submitting requires the
CLI-synthesized __mcp_url_confirmation option (description === url)
to be selected, and submission opens the url before approving.
Android: parseRequestUserInput + normalizeRequestUserInputUrl +
isRequestUserInputUrlConfirmed; the footer enforces the confirm
guard, then opens the url via ACTION_VIEW. Divergences: the raw
wire string is compared (the CLI synthesizes the description from
the same value web would re-normalize, so they are equivalent for
every CLI-produced request), and the open-failure message is
platform-adapted (no popup blocker on Android).
3. Grok Auto permission gate (web NewSessionForm index.tsx + grok
models probe): when the machine reports
`autoPermissionModeSupported === false` for the chosen cwd, the Auto
option is disabled with an " (unavailable)" suffix, a hint line is
shown, and an existing Auto selection resets to Default. The gate is
grok-only (claude and other agents never gate) and tri-state — null
(unknown, probe pending or failed) never disables.
Android: GrokModelsResponse wire type (nullable field),
HapiApi.getMachineGrokModelsForCwd, NewSessionViewModel probes on
agent/cwd/machine changes (deduped, cancellable, existence-gated),
OptionItem gains `enabled`, the permission dropdown honors it and
renders the hint. In-session switching stays ungated, matching web.
New strings in en / zh-rCN / ru (KEYSYNC: en and ru key order
identical).
Tests: RequestUserInputTest covers editor/untrimmed-note/url
parsing/confirmation semantics; NewSessionViewModelTest covers gate
disable+reset+hint, tri-state never-gate cases, and agent/cwd gating;
HapiApiTest covers the cwd query param and nullable decoding.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): accept browser-valid sign-in URLs
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
Add current-proposal decoding, dedicated implementation requests and composer-only continuation on iOS and Android. Keep pending/errors across row recycling, reject stale actions and prevent duplicate submissions.
Include native regression tests and localized plan controls.
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.
* feat(shared): steer capability gates and live steered signal schemas
- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which
agents can deliver queued messages into the active turn (pi, codex,
cursor ACP; legacy stream-json cursor excluded)
- AgentState.steeringActive, DecryptedMessage.steered and
messages-consumed live signal (never persisted by the hub)
* feat(cli): queue reservations and steered messages-consumed option
- MessageQueue2 gains takeByLocalId/restoreReservation/
beginReservationDispatch/commitReservation so an async steer can reserve
a queued row without racing the main loop's turn/start drain
- emitMessagesConsumed accepts steered: true to mark mid-turn delivery
* feat(codex): mid-turn steer via app-server turn/steer (#888)
- CodexAppServerClient.steerTurn + TurnSteerParams/Response types
- CodexRemoteLauncher registers the steer-queued-message RPC handler:
reserves the queued row, validates it against the active turn (no
control commands, matching mode hash), injects via turn/steer with an
epoch guard that invalidates in-flight steers on abort/cleanup
- steeringActive agent state tracks the active-turn window
- hub syncEngine gate opens to codex; messages-consumed relays steered
* feat(web): Steered badge and steer gating for codex sessions
- HappyUserMessage shows a ↳ Steered badge fed by the live
messages-consumed steered signal, preserved across server echoes and
refetches (mergeMessages carries the optimistic marker)
- SessionChat gates canSteer via isSteeringSupportedForSession instead of
the pi-only check
- clearStaleQueuedStatus normalizes a queued status on an invoked message
- fix(web): drop duplicate showSessionSummaryInChat in markdown test
(upstream typecheck breakage)
* fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer
- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise
codex and pi only; cursor joins when its soft-steer handler lands (#1609)
- turn/steer now splits dispatch (stdin accepted) from completion (turn
finished): the hub RPC acks once dispatch succeeds — never on the
concurrent turn's completion, which can exceed the 30s RPC window
- queue row commits only after the turn settles; a rejected/aborted steer
restores the row so the message still delivers via turn/start, and a
dispatched steer is never restored (no duplicate delivery)
- steer carries clientUserMessageId (echoed as userMessage.clientId) so
ambiguous transport failures can reconcile the thread later
- client tests cover dispatch/complete split and stdin-write failure
* fix(codex): reconcile dispatched steers before restoring; align error copy
- A dispatched turn/steer whose completion fails (disconnect / protocol
error) is now reconciled via thread/read by clientUserMessageId before
the queued row is restored — the instruction is only re-delivered by
turn/start when the thread never received it
- Reconcile targets the pinned steer thread, not whichever turn is
current when completion fails
- syncEngine unsupported-flavor error now matches the capability gate
(Pi and Codex only until the cursor handler lands)
- launcher tests cover steer success (ack on dispatch), reconcile-accepted
and reconcile-rejected outcomes
* fix(codex): consume the row at dispatch; drop background reconcile
- The hub RPC acks and the queue row is consumed as soon as stdin accepts
turn/steer; completion is background-only logging. A dispatched steer is
never restored, so the same localId cannot be re-delivered via turn/start
after the caller was told the steer succeeded
- Dispatch failure (stdin write error) still restores the row and reports
failure
- steer.completed rejection is always handled (no unhandled rejection on
the dispatch-failure path)
- tests updated: completion failure after dispatch keeps the row consumed;
dispatch failure restores it
* fix(codex): distinguish definite rejection from indeterminate completion
- Transport-level failures (timeout, abort, disconnect, spawn, protocol)
carry an indeterminate marker; explicit JSON-RPC error responses do not
- After a dispatched steer, turn completion resolves → commit + consumed;
a definite app-server rejection restores the row (instruction was never
accepted, so turn/start cannot duplicate it); an indeterminate outcome
leaves the row reserved so it can never be delivered twice
- Completion handling registers before awaiting dispatch so the
dispatch-failure path cannot leak an unhandled rejection
- client/launcher tests cover explicit rejection (restore), indeterminate
outcome (row stays reserved) and dispatch failure
* fix(codex): reconcile indeterminate steers instead of a permanent reservation
- After an indeterminate completion (disconnect/protocol), reconcile the
thread by clientUserMessageId immediately: accepted → commit + consumed,
provably rejected → restore, still unreadable → keep the reservation and
retry from the main-loop top on later passes (post-reconnect)
- A row never sits in dispatching forever: the hub cannot stamp it invoked
while the instruction may never have been accepted
- tests: indeterminate keeps reserved while thread unreadable; accepted
reconciliation consumes; rejected path restores
* fix(codex): accept all thread item shapes; retry reconcile; ack through abort
- Reconcile matcher accepts userMessage/user_message with clientId/
client_id, matching the shapes the thread parser supports — an accepted
steer can no longer be misclassified as rejected
- A pending reconciliation schedules a wakeLoop retry, so a temporary
app-server outage cannot strand the reservation behind waitForTurnOrRecovery
- The success-path ACK no longer checks the steer epoch: the hub already
reported steered on dispatch, so commit + messages-consumed must reach
it even when an abort resets the queue in between
* fix(codex): reinit reconnected app-server; keep reconcile retries alive
- thread/read after a disconnect auto-connects a fresh app-server, which
must be initialized before any request — reconcile now ensures
connect + initialize (isConnected getter added to the client)
- every still-unknown loop-top reconciliation schedules the next retry,
so recovery without external traffic is eventually observed
- launcher mock gains isConnected
* fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK
- Reconciliation runs on a self-rescheduling 1s timer independent of the
main loop (wakes it too), so idle loops and waitForTurnOrRecovery still
observe app-server recovery; abort clears nothing implicitly — the ACK
path commits and consumes even when the reservation was cancelled
- Absence of a durable client id is ambiguous: unmatched reads stay
'unknown' and keep retrying instead of restoring the row
- CodexAppServerClient tracks initialized state (reset on disconnect/exit)
so ensureAppServerInitialized re-initializes a fresh process before
thread/read; initialize failures leave the flag false for the next retry
- tests: accepted reconciliation via scheduled timer, indeterminate
keeps reserved, explicit rejection restores
* fix(codex): bind reconciliation to the launcher lifecycle
- runSteerReconciliation clears any armed retry timer on entry and never
installs a second one, so loop-top and timer-driven passes cannot
multiply
- shuttingDown is set when the main loop ends: timers are cleared and the
pending map is dropped, so an unresolved steer can never respawn an
app-server after cleanup (remote-to-local switch included)
* fix(codex): report steered only after app-server acceptance
- The handler now awaits steer.completed (the inject-acceptance response):
an explicit JSON-RPC rejection surfaces as failed and restores the row
for the normal turn/start path instead of a false steered
- Transport failure after dispatch reports 'Steer outcome is being
reconciled' and keeps the row reserved while the timer-driven thread
reconciliation runs
- dispatch-failure path also swallows the paired completion rejection
* fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait
- MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching
steer reservation: the hub neither deletes the row nor stamps invoked_at
(new CancelMessageResponse 'busy' status; web restores the optimistic
row); pushIsolateAndClear and reset/close share cancelReservations so
/clear-style commands cannot have a rejected steer resurrect a discarded
prompt
- turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a
lost response is indeterminate and funnels into thread reconciliation
instead of stranding the reservation
- tests updated for the tri-state cancel contract
* fix(codex,web): busy-aware edit flow; bound reconciliation reads
- QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it
never prefills the composer when the row is inside an async steer, so a
second client cannot send a duplicate
- reconcileSteerByClientId bounds thread/read with a 5s timeout so a
connected-but-silent app-server cannot hold the reservation in-flight
indefinitely
* fix(steer): inFlight-dominated cancel acks; bounded reconciliation
- hub cancel-queued-message acks check inFlight before removed: a stale
duplicate socket reporting removed can no longer delete the durable row
while another socket is dispatching the steer
- reconciliation entries expire after 60s and mark delivered: after the
rejection window, a dispatched steer that the app-server never proved
(client ids dropped on restart) is committed instead of polling
thread/read forever
- pre-dispatch failures (abort before write included) never enter
reconciliation — they restore the row and report failure
* fix(steer): persist indeterminate outcomes without replay
* fix(steer): make ambiguous delivery restart-safe
* fix(steer): recover crash-held rows and preserve retry dedup
* fix(steer): ack retries and bound stdin dispatch
* fix(steer): reconcile indeterminate dispatches and serialize retries
* fix(codex): classify stdin callback failures as indeterminate
* fix(steer): recheck indeterminate cancels after ACK
* fix(steer): close retry and abort races
* fix(steer): serialize live retries and abort admission
* fix(steer): distinguish live dispatching from unknown
* fix(steer): keep ACK failures held and reconcile busy cancel
* fix(steer): distinguish held cancel from removal
* fix(store): combine schema v24 migrations
* fix(store): reserve schema v25 for steer delivery state
* fix(steer): keep held cancel state and notify requeue
* fix(steer): release explicitly cancelled unknown reservations
* fix(codex): reject cancelled reservations before native steer
* fix(codex): make reservation restore atomic with state
* fix(codex): terminate abandoned transport writes
* fix(steer): own abandoned app-server lifecycle and consume races
* fix(codex): confirm dispatch and recover abandoned turns
* test(codex): mock abandoned transport callback
* fix(codex): clear visible turn state on transport loss
* fix(steer): claim retries and cover native delivery state
* fix(native): preserve indeterminate state on Android hydration
* fix(steer): make retry claims single-winner
* fix(steer): serialize concurrent retry claims
* fix(socket): tolerate missing steer-state ACK callbacks
* fix(native): serialize retry operations
* docs(web): document unknown steer delivery and retry controls
* fix(steer): handle retry failures and abort-before-connect
* fix(steer): reinitialize after transport loss and finish iOS retry errors
* fix(steer): preserve indeterminate rows across reconnect gaps
* test(web): mock indeterminate queued recovery state
* fix(steer): recover consumed ACK tombstones
* fix(steer): expose consumed cancel tombstones
Gradle/Firebase: firebase-messaging + work-runtime-ktx in the catalog and
:app; com.google.gms.google-services applied CONDITIONALLY (only when
app/google-services.json exists) so the repo builds green without any
Firebase config; committed google-services.json.example + README section
(CI-injected official builds / self-build drop-in / v1.x runtime
FirebaseOptions path); real config gitignored. push/PushBinding.kt is the
availability seam: no Firebase -> every push path no-ops.
Registration (:core:data push/DeviceRegistrar): stable DataStore UUID
deviceId; POST /api/devices/register {token, platform:'phone', deviceId}
fanned out to EVERY paired hub on app start, on pairing (roster addition),
and on onNewToken; transient failures retry via a per-hub WorkManager
unique work item; best-effort DELETE on sign-out while the hub's JWT still
works.
Service (fcm/HapiFirebaseMessagingService): data-only contract v1 decoding
in :core:data push/PushPayload — channels permission_requests(HIGH) /
ready / task_notifications (created on app start), type-<sessionId>
coalescing tags, severity accent colors, notifySummary-driven ready
bodies, unknown type/contractVersion degrade to plain title/body (default
channel, no actions). Suppress-when-open: foreground + that session's
chat composed -> skip (SSE already shows it). Tap -> internal MainActivity
intent route (no public URI) -> existing navigation opens the chat.
Actions: permission-request Allow/Deny and ready/task RemoteInput Reply +
Dismiss -> NotificationActionReceiver -> expedited CoroutineWorkers
(approve/deny {} bodies, reply {text, localId}) through on-demand
HubSessions built from stored credentials (HapiWorkerFactory +
Configuration.Provider + on-demand WorkManager init — no HubGraph needed
in background); notification updates pending -> done / "Already handled"
(404 request-gone) / inactive / failed. Multi-hub: payload has no hub URL,
so workers try the ACTIVE hub first, then other paired hubs on 404
session-miss (single-hub users always hit first try).
Hub check: android.priority=HIGH is already set unconditionally for every
FCM message (hub/src/fcm/fcmService.ts) — no hub change needed.
Tests (34 new, :core:data): payload keys/severities/unknown contract
version, channel routing, suppress-when-open; registrar fan-out /
addition-only / retry-on-transient / null-token no-op via fake seams;
action wire bodies + Bearer header + multi-hub resolution through a real
HubSession against two MockWebServers. Full gate green with AND without
google-services.json (protocol 227, data 182, app 96 tests; debug +
release/R8/lintVital assemble).
Composer attachment tray wired end to end: "+" bottom sheet (photo
library / camera / files), upload-on-pick against POST upload
(JSON+base64), per-chip uploading -> ready/failed states with retry and
best-effort delete-on-remove, AttachmentMetadata riding SendMessageRequest
and the optimistic row so user bubbles thumbnail instantly, and a
UserTextBlockView upgrade decoding wire previewUrl data URLs (web-sent
messages thumbnail too).
Mobile-data compression policy (differs from web, which uploads
originals): recompressible images over 4 MB downscale to 2048 px JPEG
q85 (filename swaps to .jpg); GIF/SVG/non-images keep originals; hard
50 MB reject with a notice, plus a capped read guarding unknown-size
picks. previewUrl embeds a <=512 px JPEG thumb instead of the web's
full-size data URL to keep send bodies small.
Simplifications noted in KDoc: attachments do not persist in drafts v1
(holder onCleared discards un-sent uploads after best-effort deletes);
inactive sessions fail the upload chip until a text send auto-resumes.
Scheduled sends guard the wire constraint (scheduledAt excludes
attachments) with a loud check.
Seam: ChatSessionApi now extends AttachmentUploadApi (HapiApi methods
gain override); camera captures use a FileProvider cache scratch,
rememberSaveable across rotation.
Tests: pure policy decisions (plan/sample-size/filenames/data URLs),
controller state machine incl. mid-upload removal orphan cleanup,
VM send/retry/refusal flows with metadata assertions, MockWebServer
wire shapes for upload + delete. Full gate green (protocol/data/app
tests + assembleDebug).
Interaction layer turning the read-only chat into a working remote control:
- Composer: multiline input bar with optimistic sends (appendOptimistic ->
POST -> status settle), queue-by-default delivery with a long-press
Send&Steer intent while a turn is active, abort button during thinking,
tap-to-retry on failed rows, per-session drafts (DataStore, hub-scoped
keys), attachment chip seam for M4.
- session_inactive (409) recovery: one POST /resume then retry; a
superseding session id seeds the new window, migrates the draft and
emits SessionSuperseded for renavigation (web resolveSessionId parity).
- Queued bar: uninvoked sends with Cancel (DELETE; invoked-race ingests the
authoritative row as sent), Edit (cancel + composer prefill, draft-kept
guard) and Steer (POST steer; invoked answers reconcile a missed consume).
reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
claude {} / allowTools / mode:acceptEdits, codex-family decision:
approved / approved_for_session / abort -- plus AskUserQuestion flat
answers and request_user_input nested answers forms; optimistic
Resolving/AlreadyHandled overrides settled by the agentState patch.
- Session config sheet: catalog-driven permission-mode picker, claude
static model/effort catalogs (ported to :core:protocol catalog), codex
models via GET /codex-models (new HapiApi endpoint + wire types) with
per-model reasoning efforts; optimistic detail updates rolled back to
server truth on error.
- Lifecycle: ProcessLifecycleOwner -> SseEngine.setLifecycleForeground +
POST /api/visibility per subscription (VisibilityReporter fed by the new
SyncTargets.onHandshake hook); the global SSE pipe moved from the session
list VM to HubGraph lifetime (GlobalSsePipe) so queued/consumed
bookkeeping and list badges stay fresh while a chat is open.
- Tests: VM-level interaction suite (optimistic send/failure/retry,
inactive-resume both id paths, cancel invoked-race, steer, exact
approve/deny body JSON incl. both answers formats, config optimistic +
rollback, drafts) + GlobalSsePipe tests; full gate green (554 tests,
assembleDebug).
:core:protocol window/ — pure state machine ported function-for-function from
web/src/lib/message-window-store.ts + messages.ts: merge-by-(id|localId) with
optimistic echo reconciliation, position ordering (invokedAt ?? createdAt, seq,
ASCII id tie-break), trim-preserving-queued with the codex agent-run budget,
epoch reset handling, latest-replace with request-baseline identity
preservation, consumed/cancelled/queued-reconcile transitions, tail/history
modes, hydrate/persist shapes. MessageRetention ports the null-decision tree
of normalizeDecryptedMessage (dedup with the B-M2a pipeline port flagged).
:core:data store/ — per-session MessageWindowStore (StateFlow + Mutex,
single-flight tail sync with trailing drain, fetchOlder with epoch-reset
resync, SSE ingest hooks, optimistic sends, queued-state reconciliation,
seedFrom for resume id changes) + WindowSnapshots (atomic JSON files, LRU 10;
JsonSnapshotStore dedup TODO) + MessageWindowStores registry. Minimal
MessagesApi interface (sealed MessagesQuery) extracted over the two message
endpoints, implemented by HapiApi.
Gate: PaginationFixtureTest replays all 11 shared/fixtures/pagination scripts
against the real store — requests, older-load outcomes, reconcile candidates
and the final window projection all exact — 11/11 green; plus targeted
concurrency/snapshot/seed unit tests. :app:assembleDebug green.
:core:data app.hapi.data.sse — the /api/events transport per
docs/api/client-contract/sse.md (reference web/src/hooks/useSSE.ts):
- SseConnection: SseTransport seam (Connected/Event/Failure flow) with the
okhttp-sse implementation on a dedicated client (readTimeout=0, no cache,
own dispatcher), buildEventsUrl, Last-Event-ID header on resume, and an
acceptEncodingIdentity fallback flag for the gzip escape hatch.
- ReconnectPolicy: normative constants + pure backoff schedule (immediate
first retry, 1s..30s exponential, 300s ceiling after 8 attempts, 0..500ms
injected jitter).
- SseEngine: per-key (global / session:<id>) connection loops — handshake
gate on connection-changed{status:connected} with ok/gap resume verdict,
per-key cursors advanced only after the downstream hand-off
(at-least-once), 10s connect deadline, 90s staleness watchdog ticking 10s,
one silent 401 re-auth per cycle costing no backoff attempt, background
retry deferral + 45s foreground stale check; all timing via injected
delay/clock for virtual-time tests.
- SyncEventRouter: 13-type union fan-out to the SyncTargets seam (M2 wires
stores), handshake gap -> requestFullResync, Unknown ignored.
Tests (32): virtual-time engine suite (fake transport + turbine), policy
schedule with seeded jitter, router mapping, and MockWebServer integration
through the real okhttp transport — including proof that gzip SSE frames
surface incrementally (flush-per-event body withheld behind a throttle).
Gzip also verified against a live local hub (Content-Encoding: gzip,
handshake decoded instantly, heartbeat +30.0s mid-stream).