* refactor(web): route create-form permission control through one native-select predicate Extract usesNativePermissionSelect(flavor) (grok || codex-family, matching the existing iOS/Android predicate of the same name) and route PermissionField's select-vs-toggle gate through it instead of an inline condition. Rename the codex-family-only state codexFamilyPermissionMode to nativePermissionMode since it now backs a shared predicate, not just the codex family. Behavior is unchanged: any stale sessionStorage draft written under the old codexFamilyPermissionMode key has no value under the new key and falls back to 'default', which only matters within a single browser tab's lifetime. * feat(web): let Claude pick a permission mode when creating a session Claude was the only create-form flavor still on the global HAPI YOLO toggle while grok and the codex family got the native permission select, so there was no way to start a session in Plan Mode without creating it first and switching the mode from the composer. usesNativePermissionSelect now gates the control for claude as well, and the spawn body carries permissionMode (including 'default') instead of yolo, which is the shape the other native-select flavors already send. The stored hapi:newSession:yolo preference is bridged into the select rather than dropped, but only for the flavors that have actually moved onto it (LEGACY_YOLO_BRIDGE_AGENTS = codex, claude). copilot, gemini, kimi and opencode moved earlier and settled on 'default'; re-enabling Yolo for them now would widen permissions rather than migrate a preference. This narrows the sessionStorage draft bridge too, which until now fired for the whole codex family with no allow-list, so their draft restores yield 'default' instead of 'yolo' — same-tab-lifetime state only. Claude and the codex family share one nativePermissionMode state and their mode sets do not overlap, so the existing agent-change reset plus the flavor filters in the draft loader and the stored launch settings are what keep a codex mode out of a Claude spawn. Adds the regression test that pins it: pick a mode under codex, switch to Claude, create, assert the payload carries 'default'. * feat(ios): let Claude pick a permission mode when creating a session Extend usesNativePermissionSelect to include claude alongside grok and the codex family, matching the web change. buildSpawnRequest now derives both yolo and permissionMode from that single predicate instead of two separate local flags, so claude sends permissionMode (including 'default') and no longer sends yolo. Unlike web, iOS carries no persistent YOLO preference across sessions to migrate — the toggle only lives in the in-memory form or a draft deleted on success — so there is no bridging logic to add here. * feat(android): let Claude pick a permission mode when creating a session Extend usesNativePermissionSelect to include claude alongside grok and the codex family, matching the web and iOS changes. buildSpawnRequest derives both yolo and permissionMode from that single predicate, so claude sends permissionMode (including 'default') and no longer sends yolo. Unlike web, Android has no persistent YOLO preference to migrate: the toggle only lives in the form draft, which is deleted once a session is created. The agent-switch test asserted claude renders the YOLO toggle; it now checks the native select for claude and keeps the toggle assertion on cursor, which still carries it.
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:
- 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=…). - Deep link — scanning the companion QR with the system camera opens the
app directly with a confirm screen (
hapicompanion://bindintent filter). - Manual entry — hub URL + access token, for hubs started without
--relay.
Pairing against a development hub
# Start a hub with the built-in HTTPS relay (prints the access token + QR codes).
hapi hub --relay
# Use the printed https:// URL. For a source-tree `bun run dev` hub, put an
# HTTPS reverse proxy or tunnel in front of localhost:3006 first.
adb shell am start -a android.intent.action.VIEW \
-d "hapicompanion://bind?hub=https%3A%2F%2Fhub.example.com&code=<accessToken>" # optional: exercises the deep link
The app rejects plain-http hub URLs in manual entry, deep links, QR codes,
and restored hub state. The manifest also sets
android:usesCleartextTraffic="false"; there is no debug or LAN exemption.
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);SseEnginereconnect state machine + versioned patches (gzip streaming verified); pairing UI +hapicompanion://binddeep link. - M2 — read-only chat: chat pipeline port gated on fixtures all-green; session list;
MessageWindowStoreport; 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_AUDIOrequested at first use),MediaRecorder→ m4a/AAC, provider discovery viaGET /api/voice/transcription/providers(firststandard-capable provider; a hub without one shows a notice), upload through the multipartPOST /api/voice/transcription, transcript appended at the composer text with a space separator;DictationControlleris a plain seam over recorder + API, JVM-tested with fakes. Slash commands: typing a lone/tokenopens a dropdown merging the session'smetadata.slashCommandsnames with theGET /slash-commandsRPC 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").
- B-M3ce landed — voice dictation: mic button in the composer (
- M4 — FCM push (register → notification actions via expedited WorkManager) + files/git viewer, Scratchlist, usage/storage stats.
- B-M4a landed — FCM push + notification actions.
:core:datapush/:PushPayload(data-only contract v1 decoding: type/severity/notifySummaryparsing, 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-persisteddeviceIdUUID, WorkManager retry seam, best-effort unregister on sign-out before credentials are wiped),PushHubAccess+PushActionRunner(workers build aHubSessionon demand from stored credentials — noHubGraphneeded in background — and resolve the owning hub: active hub first, other paired hubs on 404 session-miss).:app:push/PushBinding(Firebase availability gate — nogoogle-services.json→ all push paths no-op),fcm/(HapiFirebaseMessagingService,NotificationChannels—permission_requestsHIGH /ready/task_notifications,PushNotificationsbuilder with severity accents + suppress-when-open rule,NotificationActionReceiver→ expeditedPermissionActionWorker(Allow/Deny → approve/deny{}) andSendMessageWorker(RemoteInput reply →{text, localId}) with pending → done/"Already handled"/failed notification states), WorkManager on-demand init +HapiWorkerFactory, notification tap → internalMainActivityintent route → chat.
- B-M4a landed — FCM push + notification actions.
- 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
- Add it to
app/src/main/res/values/strings.xmlwith 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/_manykeys (the deliberate house style — no<plurals>). - Add the zh-CN twin to
values-zh-rCN/strings.xml. Terminology source of truth is the web corpusweb/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. - 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:
MainActivityextendsAppCompatActivity(theme parentTheme.AppCompat.DayNight.NoActionBar) so per-app locales work back to API 26; on API 33+ the frameworkLocaleManagertakes over (the app also declaresandroid:localeConfigfor the system App-languages screen).- The manifest opts into appcompat's
autoStoreLocales(AppLocalesMetadataHolderServicemeta-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:
- Official builds: CI injects the default Firebase project's
google-services.jsonbefore assembling (the file is gitignored;app/google-services.json.exampledocuments the expected shape). - Self-builds: create your own Firebase project, add an Android app
with your
applicationId(defaultrun.hapi.companion), downloadgoogle-services.jsonintoandroid/app/, and rebuild. - Hub side: point the hub at the same Firebase project —
FCM_SERVICE_ACCOUNT_PATH(orfcmServiceAccountPathin~/.hapi/settings.json; the project id comes from the JSON itself, seedocs/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.
Release signing
Same philosophy as Firebase: the repo carries no secrets and builds green
without them. :app:bundleRelease produces an unsigned AAB unless an
upload key is configured via gradle properties (user-global
~/.gradle/gradle.properties), environment variables (CI secrets), or
android/local.properties (gitignored; same property names — the
conventional machine-local home, loaded explicitly since it is not part
of gradle's own property chain):
| gradle property | env | meaning |
|---|---|---|
hapiUploadKeystore |
HAPI_UPLOAD_KEYSTORE |
keystore path (~ ok) |
hapiUploadKeystorePassword |
HAPI_UPLOAD_KEYSTORE_PASSWORD |
store password |
hapiUploadKeyAlias |
HAPI_UPLOAD_KEY_ALIAS |
default upload |
hapiUploadKeyPassword |
HAPI_UPLOAD_KEY_PASSWORD |
default: store password |
This is an upload key for Play App Signing (Google holds the actual distribution key, so a lost upload key is resettable in Play Console). Generate one with:
keytool -genkeypair -v -keystore ~/.hapi/upload.keystore -alias upload \
-keyalg RSA -keysize 2048 -validity 10950
Keystores never live in the repo (*.keystore / *.jks are gitignored).