Files
hapi/android
SSU-WEI HUANGandGitHub f0e5ba9c0f feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)
* 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
2026-08-19 20:07:39 +08:00
..

HAPI Android Companion

Native Android client (Kotlin + Jetpack Compose) for the HAPI hub. Fully independent from the web app; shares only the protocol contract (docs/api/) and the golden fixtures (shared/fixtures/).

  • applicationId: run.hapi.companion · minSdk 26 · target/compileSdk 36
  • Toolchain: Gradle 8.14.2 (wrapper) · AGP 8.11.1 · Kotlin 2.1.21 · Compose BOM 2025.05.00 · JDK 17+ (CI uses 21)

Modules

Module Type Responsibility
:core:protocol pure Kotlin/JVM (no Android) Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, BindLink pairing-link parsing. M1a landed: wire/ (HapiJson, Session/SessionPatch/SessionSummary, DecryptedMessage, AgentState, Machine, 13-type SyncEvent union via SyncEvents.parse, MessagesResponse), catalog/ (flavors + permission/collaboration modes), patch/SessionPatching.kt (exact port of web/src/lib/sessionPatch.ts), all fixture-verified.
:core:data Android library Transport + persistence. M1b landed — auth/ (JwtPeek, CredentialStore interface + EncryptedPrefsCredentialStore/in-memory, HubUrls origin normalization, HubRegistry roster behind a storage seam, AuthInterceptor + single-flight TokenAuthenticator with ensureFreshToken() and terminal AuthEvents), api/ (HapiApi — plain OkHttp + kotlinx.serialization, one suspend fun per v1 endpoint incl. generated-image bytes via a 256 MB OkHttp cache and the multipart transcription helper; ApiError with (status, code)), HubSession per-hub factory; MockWebServer-tested. M1c landed — sse/: SseEngine (per-key global/session:<id> loops, connection-changed handshake gate with ok/gap resume verdict, per-key Last-Event-ID cursors advanced only after downstream hand-off (at-least-once), 10 s connect deadline, 90 s watchdog, 1 s→30 s→300 s backoff + jitter, background retry deferral + 45 s foreground stale check, one silent 401 re-auth per cycle), OkHttpSseTransport (dedicated client, readTimeout=0, incremental gzip decoding pinned by test, acceptEncodingIdentity fallback), SyncEventRouter → SyncTargets seam; virtual-time tested. Still to come: StateFlow stores + AtomicFile JSON snapshots (M2), FCM registration + WorkManager workers (M4).
:app Android application Compose UI, navigation, deep links (hapicompanion://bind), FCM service (M4), hand-rolled DI (AppGraph, no Hilt). M1d landed — di/ (AppGraph process singletons: Preferences DataStore-backed HubRegistryStorage, EncryptedPrefsCredentialStore, HubRegistry, auth-terminal fan-out; HubGraph per active hub: HubSession + SseEngine wired to ensureFreshToken, recreated on hub switch; LocalAppGraph CompositionLocal + viewModelFactory helper), feature/pairing/ (landing / zxing ScanContract QR scan / manual entry sharing one PairingViewModel: health + protocol check → POST /api/auth → persist + activate), feature/home/ placeholder (hub switcher + sign-out), Navigation.kt (pairing ⇄ home, auth-terminal → pairing with banner), bind deep-link handling in MainActivity.

Dependency direction: :app → :core:data → :core:protocol.

Protocol conformance fixtures

:core:protocol is the porting target for web/src/chat/ and is verified against golden fixtures generated from the web implementation (track K). The test task already passes the fixtures location as a system property:

// core/protocol/build.gradle.kts
tasks.test {
    systemProperty("hapi.fixtures.dir", rootDir.parentFile.resolve("shared/fixtures").absolutePath)
}

Fixture-driven tests (M2) read System.getProperty("hapi.fixtures.dir") — no further build changes are needed when shared/fixtures/** lands. CI re-runs this suite whenever android/** or shared/fixtures/** change.

Building

Requires an Android SDK for :app/:core:data (set ANDROID_HOME or android/local.properties with sdk.dir=...). :core:protocol alone needs only a JDK.

cd android
./gradlew :core:protocol:test        # pure JVM protocol tests (fast)
./gradlew :app:assembleDebug         # debug APK
./gradlew :app:installDebug          # install on a connected device

Without an Android SDK you can still run the protocol suite by configuring only the needed projects:

./gradlew --no-configuration-cache --configure-on-demand :core:protocol:test

CI (.github/workflows/android.yml) runs the protocol tests and :app:assembleDebug on every PR touching android/** or shared/fixtures/**.

Pairing

HAPI is self-hosted: the app talks to a hub you run. Pairing = giving the app a hub URL plus that hub's access token; the app verifies the hub (GET /health, protocol version), exchanges the token for a JWT (POST /api/auth), stores the credentials in EncryptedSharedPreferences (keyed per hub — multiple hubs can be paired, one active at a time), and lands on the session UI. Three entry points:

  1. QR scan — the hub prints two QR codes when started with --relay (also under web Settings → Companion pairing). The in-app scanner accepts both: the companion deeplink (hapicompanion://bind?hub=…&code=…) and the web direct-access URL (…?hub=…&token=…).
  2. Deep link — scanning the companion QR with the system camera opens the app directly with a confirm screen (hapicompanion://bind intent filter).
  3. Manual entry — hub URL + access token, for hubs started without --relay.

Pairing against a local dev hub

# repo root: start the hub (prints the access token + QR codes)
bun run dev

# emulator: the host machine is 10.0.2.2
#   Hub URL:       http://10.0.2.2:3006
#   Access token:  from the hub terminal / hub settings.json (CLI_API_TOKEN)
# physical device: use the machine's LAN IP, e.g. http://192.168.1.10:3006
adb shell am start -a android.intent.action.VIEW \
  -d "hapicompanion://bind?hub=http%3A%2F%2F10.0.2.2%3A3006&code=<accessToken>"  # optional: exercises the deep link

Plain-http LAN/emulator hubs work in all build types: the manifest opts in to cleartext traffic (android:usesCleartextTraffic="true"), because self-hosted LAN hubs are the primary pairing target and Android cannot scope the exemption to local addresses only. Sign-out (home → Sign out) deletes the stored credentials for that hub and drops it from the roster.

Milestones (track B of the native-clients plan)

  • M0 — this scaffold: modules, version catalog, CI, placeholder screen.
  • M1 — foundations: wire types + modes catalog; auth + HapiApi (MockWebServer-tested); SseEngine reconnect state machine + versioned patches (gzip streaming verified); pairing UI + hapicompanion://bind deep link.
  • M2 — read-only chat: chat pipeline port gated on fixtures all-green; session list; MessageWindowStore port; Markdown renderer; read-only chat screen (LazyColumn(reverseLayout = true)).
  • M3 — interaction: composer (optimistic send/queue/steer/drafts), permission approvals UX, session controls (mode/model/abort/resume/rename/archive), new session, dictation.
    • B-M3ce landed — voice dictation: mic button in the composer (RECORD_AUDIO requested at first use), MediaRecorder → m4a/AAC, provider discovery via GET /api/voice/transcription/providers (first standard-capable provider; a hub without one shows a notice), upload through the multipart POST /api/voice/transcription, transcript appended at the composer text with a space separator; DictationController is a plain seam over recorder + API, JVM-tested with fakes. Slash commands: typing a lone /token opens a dropdown merging the session's metadata.slashCommands names with the GET /slash-commands RPC list (RPC entries win dedupe; exact → prefix → contains filtering), tap inserts /name (the skills $ trigger is deferred). Session ops: list long-press sheet and chat top-bar overflow gain Rename (PATCH /sessions/:id, optimistic name with roll-forward on failure), Delete (confirm; 409-while-active surfaced), and Reopen for inactive sessions (POST /reopen; a superseding id reuses the supersede path — window seed + draft move + navigate-replace; 422 missing-metadata formatted); chat shows an inactive-session bar ("send to resume, or Reopen").
  • M4 — FCM push (register → notification actions via expedited WorkManager) + files/git viewer, Scratchlist, usage/storage stats.
    • B-M4a landed — FCM push + notification actions. :core:data push/: PushPayload (data-only contract v1 decoding: type/severity/notifySummary parsing, channel routing, type-<sessionId> coalescing tags, unknown type/contractVersion degrade to plain title/body), DeviceRegistrar (registers the FCM token with every paired hub on start/pairing/onNewToken, DataStore-persisted deviceId UUID, WorkManager retry seam, best-effort unregister on sign-out before credentials are wiped), PushHubAccess + PushActionRunner (workers build a HubSession on demand from stored credentials — no HubGraph needed in background — and resolve the owning hub: active hub first, other paired hubs on 404 session-miss). :app: push/PushBinding (Firebase availability gate — no google-services.json → all push paths no-op), fcm/ (HapiFirebaseMessagingService, NotificationChannels — permission_requests HIGH / ready / task_notifications, PushNotifications builder with severity accents + suppress-when-open rule, NotificationActionReceiver → expedited PermissionActionWorker (Allow/Deny → approve/deny {}) and SendMessageWorker (RemoteInput reply → {text, localId}) with pending → done/"Already handled"/failed notification states), WorkManager on-demand init + HapiWorkerFactory, notification tap → internal MainActivity intent route → chat.
  • M5 — polish: zh-CN i18n, OLED/Material You theming, predictive back, LeakCanary pass, Play listing + self-build docs.
    • B-M5a landed — zh-CN localization + in-app language switching (see "Internationalization" below).

Internationalization (B-M5a)

The app ships English (default) and Simplified Chinese (app/src/main/res/values-zh-rCN/strings.xml). Every user-visible string lives in resources; both files carry the same key set (lint MissingTranslation is the gate).

Adding a string

  1. Add it to app/src/main/res/values/strings.xml with a feature-prefixed key matching the existing convention (chat_, sessions_, files_, scratchlist_, pairing_, settings_, new_session_, notif_, tool_ for tool-card titles). Dynamic values use positional format args (%1$s, %2$d); count-dependent copy uses explicit _one/_many keys (the deliberate house style — no <plurals>).
  2. Add the zh-CN twin to values-zh-rCN/strings.xml. Terminology source of truth is the web corpus web/src/lib/locales/zh-CN.ts — reuse its product terms (会话 session, 机器 machine, 权限模式 permission mode, 工作树 worktree, 草稿夹 scratchlist, 语音输入 dictation, 用量 usage, 智能体/代理 agent). Technical identifiers (model ids, flavor names like Claude/Codex, permission-mode catalog labels, CLI flags) stay untranslated, matching the web's choices.
  3. Reference it: composables via stringResource(R.string...). ViewModels stay string-free — transient notices are semantic sealed types (ChatNotice, ScratchlistNotice, PairingError, DictationErrorKind) resolved at the UI layer; where a ViewModel genuinely composes display text it takes a small Strings seam (FilesStrings, FileViewerStrings, NewSessionStrings) whose defaults are the English values (JVM tests construct without arguments) and whose production instance is resource-resolved in the Navigation holders. Server-provided error text passes through verbatim.

Language switching

Settings → App language offers Follow system (default) / English / 简体中文. The choice persists in LanguagePrefs (DataStore) and applies immediately via AppCompatDelegate.setApplicationLocales:

  • MainActivity extends AppCompatActivity (theme parent Theme.AppCompat.DayNight.NoActionBar) so per-app locales work back to API 26; on API 33+ the framework LocaleManager takes over (the app also declares android:localeConfig for the system App-languages screen).
  • The manifest opts into appcompat's autoStoreLocales (AppLocalesMetadataHolderService meta-data), which re-applies the stored choice synchronously on cold start.
  • Surfaces that resolve strings from the application context — FCM notifications, WorkManager result updates, notification-action receivers — wrap their context with localizedForAppLanguage(AppGraph.appLanguage) (di/LocaleContexts.kt), since per-app locales only retarget activity contexts below API 33.

Out of scope on purpose: :core:protocol presentation strings (getEventPresentation, tool-group activity titles) stay English — the web does not translate them either, and terminology parity with the web wins.

Firebase / push

FCM needs a Firebase project binding, which is deliberately optional: the com.google.gms.google-services plugin is applied conditionally (only when app/google-services.json exists — see app/build.gradle.kts), so the repo always builds green without any Firebase config. Without one, FirebaseApp never initializes, PushBinding.isAvailable reports false, and every push code path (registration, FCM service, workers, the notification-permission prompt) no-ops — the app behaves like pre-M4a.

To enable push:

  1. Official builds: CI injects the default Firebase project's google-services.json before assembling (the file is gitignored; app/google-services.json.example documents the expected shape).
  2. Self-builds: create your own Firebase project, add an Android app with your applicationId (default run.hapi.companion), download google-services.json into android/app/, and rebuild.
  3. Hub side: point the hub at the same Firebase project — FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID (docs/api/native-companion-contract.md). The device registers itself with every paired hub (POST /api/devices/register) on pairing, app start, and token rotation, and unregisters on sign-out.

Multi-hub note: the FCM payload does not name the sending hub (contract v1), so notification actions resolve it — the workers try the active hub first, then the other paired hubs when a hub answers 404 for the session. Single-hub setups always hit on the first try. Tapping a notification opens the session against the active hub.

Planned for v1.x: runtime FirebaseOptions handed out by the hub, so self-builds get push without baking a config into the APK. That lands entirely behind the existing app/.../push/PushBinding.kt seam.