Commit Graph
28 Commits
Author SHA1 Message Date
weishu 6f4aacce6b feat(android): enable default push through official relay 2026-09-12 17:52:53 +08:00
weishu 32ca6f543a feat(android): improve native chat reading and navigation 2026-09-12 16:56:41 +08:00
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
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.
2026-09-12 10:59:17 +08:00
weishu c2e3d16b7e feat(native): improve anchored chat scrolling and history loading
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.
2026-09-10 16:22:36 +08:00
weishu 4948d23669 fix(android): enforce Google Play compliance 2026-08-25 16:44:03 +08:00
weishu e5a8212f4a feat(session): validate agents and browse workspace directories 2026-08-25 16:12:29 +08:00
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
weishu 53e33bead1 merge: B-M4a Android FCM push + notification actions
# Conflicts:
#	android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt
#	android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt
#	android/app/src/main/res/values/strings.xml
2026-08-18 10:52:19 +08:00
weishu 0f3bf5ca1b merge: B-M4d Android scratchlist
# Conflicts:
#	android/app/src/main/kotlin/app/hapi/companion/Navigation.kt
#	android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt
#	android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt
#	android/core/data/src/main/kotlin/app/hapi/data/api/HapiApi.kt
2026-08-18 10:50:57 +08:00
weishu bf63d2ad7b feat(android): FCM push + notification actions (B-M4a)
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).
2026-08-18 10:50:07 +08:00
weishu 8e5ec6a3cb merge: B-M3f Android composer attachments 2026-08-18 10:47:21 +08:00
weishu 33d186f444 feat(android): scratchlist (B-M4d)
Per-session notes/drafts workbench, the native twin of the web
ScratchlistPanel + use-hub-scratchlist (tiann/hapi#893):

- wire/ScratchlistApi.kt: entry/attachment/limits DTOs mirroring
  shared/src schemas, caps (200 entries / 10k chars), typed error codes
  (scratchlist_at_cap, _attachment_too_large, _attachment_in_use, ...).
- HapiApi: entries CRUD (POST idempotent-on-entryId), limits GET,
  base64-JSON attachment upload, raw-bytes fetch over the cached image
  client, attachment delete.
- SessionStore now surfaces scratchlistUpdatedAt patches as a
  scratchlistInvalidations SharedFlow (bare refetch trigger, sse.md).
- ScratchlistStore: per-session entries StateFlow, refresh on open +
  on SSE signal (observed sessions only, 16 ms coalesced), optimistic
  create/update/delete with surgical rollback, friendly atCap state
  (local pre-check + hub 409), uploads-in-flight progress, cached
  limits with offline defaults. ScratchlistAttachmentGuard holds the
  pure pre-upload budget verdicts (fits / downscale-to-target / reject).
- feature/scratchlist: chat/{id}/scratchlist route, entry cards (text
  preview, relative age, authed Coil thumbnails), edit sheet (text +
  attachment strip with photo picker, downscale-to-JPEG import, remove,
  delete), FAB new note, full-screen viewer (generated-image pattern).
- Chat seams: top-bar notepad badge (entry count), composer overflow
  "Park draft to scratchlist" (clears only after the hub accepts), and
  per-entry "To composer" that inserts into the live ChatViewModel of
  the chat entry below the route.

Tests: store CRUD optimistic/rollback + cap 409 + invalidation-signal
refetch + upload progress/413 + attachment delete 409 (MockWebServer),
guard verdicts, ChatViewModel park/insert/badge seams (fake store).
2026-08-18 10:47:02 +08:00
weishu ac5cebf32f feat(android): composer attachments (B-M3f)
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).
2026-08-18 10:46:44 +08:00
weishu 75f830002b merge: B-M4e Android usage/storage dashboards + settings
# Conflicts:
#	android/app/src/main/kotlin/app/hapi/companion/Navigation.kt
2026-08-18 10:45:42 +08:00
weishu 769e0ff390 feat(android): usage/storage dashboards + settings scaffold (B-M4e)
- wire: UsageApi.kt (UsageSummaryResponse/buckets/totals, SqliteStorageUsageResponse)
- api: GET /api/usage/summary?range&timeZone + GET /api/storage/sqlite on HapiApi
- feature/settings: settings home (theme system/light/dark/OLED + Material You
  toggle, language persist-only until M5, About with app/protocol/hub health),
  usage dashboard (range control, 8 stat tiles, Canvas daily bars with tap
  tooltip, byAgent/byModel bar lists), storage dashboard (Canvas donut + rows)
- owner gating: JwtPeek ns == 'default' hides Usage/Storage entries (web
  SettingsNav parity); screens map 403 to an owner-only explanation
- theme plumbing: ThemePrefs/LanguagePrefs on hapi_prefs DataStore, exposed via
  AppGraph, applied at MainActivity setContent through HapiTheme(oled/dynamic)
- tests: usage/storage wire decode, HapiApi query+403 shapes, formatting/
  hit-rate/calendar-fill/slice math, theme+language round-trips, ns gating,
  usage/storage/settings viewmodels
2026-08-18 10:43:40 +08:00
weishu 210f9b3ff7 feat(android): files/git browser + file viewer (B-M4c)
:core:protocol git/: GitStatusParser (porcelain v2 --branch: branch headers
incl. detached/initial, 1/2/u records, untracked/ignored) + NumstatParser
(counts, binary, brace + plain rename normalization) + buildGitStatusFiles
merge — behavior cross-checked against web/src/lib/gitParsers.ts by running
the same inputs through the TS implementation, quirks preserved.

Endpoints: HapiApi gains git-status, git-diff-numstat(?staged),
git-diff-file(path, staged?), file read (base64), files search (?query&limit),
directory (?path); wire types in new wire/FilesApi.kt.

feature/files: FilesScreen (chat/{id}/files) with Changes (branch header,
staged/unstaged sections, status letters + ±counts), Browse (lazy directory
tree, dirs-first sort, hidden-file toggle), Search (300ms debounced) tabs;
FileViewerScreen (chat/{id}/file?path&staged&mode&line) with diff mode
(UnifiedDiffParser → DiffView, staged/unstaged toggle) ⇄ full mode (CodeBlock
with 400-line highlight cap, markdown Source/Preview via the shared Markdown
renderer, image bitmaps), copy path, middle-ellipsis path. Chat wiring: file
citations open the viewer in full mode (cited line shown as a hint chip — no
per-line highlight inside the single-Text CodeBlock), top-bar folder icon
opens the files browser.

Tests: parser fixtures (renames, binary, detached, conflicts, untracked
dirs) + VM tests with fakes (numstat merge, degraded numstat banner, lazy
directory loading/cache, hidden filter, search debounce, diff auto-fallback,
staged reload, base64/image/binary decode).
2026-08-18 10:40:34 +08:00
weishu c164af0738 feat(android): dictation, slash commands, session ops (B-M3ce)
- Voice dictation: DictationController (seam over recorder + api, JVM-tested),
  MediaRecorder m4a/AAC recorder, provider discovery via
  GET /api/voice/transcription/providers (new HapiApi method + wire types),
  mic button + recording chip + RECORD_AUDIO flow in the composer,
  transcript appended with space separator (web appendTranscript twin).
- Slash commands: '/' at start-of-input opens a dropdown merging
  metadata.slashCommands names with GET /slash-commands (RPC wins dedupe,
  exact>prefix>contains filter); tap inserts '/name '. Skills '$' deferred.
- Session ops: SessionStore gains rename (optimistic + roll-forward),
  delete (optimistic + 409 restore), reopen (marks returned id active);
  list long-press sheet + chat top-bar overflow wire Rename/Reopen/Delete;
  chat reopen reuses the supersede path (window seed + draft move +
  ChatEvent.SessionSuperseded); 422 formatted via formatReopenError;
  inactive-session affordance bar above the composer.
- Additive wire/API: TranscriptionProvidersResponse/TranscriptionProviderInfo,
  HapiApi.getTranscriptionProviders, ChatSessionApi.getSlashCommands,
  SessionListStore rename/delete/reopen.
- Manifest: RECORD_AUDIO + microphone uses-feature required=false. README blurb.
- Tests: DictationController (happy/no-provider/errors/cancel), slash
  merge/filter/trigger, store rename/delete/reopen (MockWebServer),
  VM reopen-supersede/delete/rename/slash suggestions; full gate green
  (protocol 227, data 148, app 96 tests; assembleDebug OK).
2026-08-18 10:16:05 +08:00
weishu ca138c912c merge: B-M3ab Android composer + permission actions + session config 2026-08-17 21:02:33 +08:00
weishu 78a5ff9961 feat(android): composer, permission actions, session config (B-M3ab)
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).
2026-08-17 21:01:32 +08:00
weishu e640aa42fd feat(android): new session flow (B-M3d)
Machine -> directory -> agent/options -> spawn, web NewSession parity:

- feature/newsession: NewSessionScreen + NewSessionViewModel (plain class
  over fakeable seams), NewSessionForm/Logic (exact SpawnSessionRequest
  mapping), NewSessionGateway, DataStore-backed prefs (last machine,
  per-machine recent paths LRU cap 8, form draft so backing out keeps input)
- directory: server-side autocomplete (list-directory on the parent path,
  debounced 250ms with per-parent cache), recent-path chips, paths/exists
  probe with the web's missing-dir affordances (worktree = error, simple =
  two-tap create-and-make-directory)
- options per flavor: claude static models+effort; codex machine catalog
  (hidden on rpc_target_missing) + reasoning effort + collaboration mode +
  fast tier gate; grok/codex-family native permission modes; claude/agy/
  cursor YOLO toggle with native-mode hint; pi managed note; copilot agent
  mode; worktree name validation; startingMode omitted (remote default)
- wire/api: CodexModelsResponse + GET /machines/:id/codex-models (flagged
  addition), MockWebServer coverage
- nav: newSession route (optional machineId), FAB on the session list,
  spawn success navigate-replaces to chat/{id}
- tests: spawn-body exactness (4 configs), debounce/parent derivation/
  listing cache, recent LRU, worktree validation, two-tap missing dir,
  draft restore + codex catalog reconcile (21 new app tests green)
2026-08-17 20:40:36 +08:00
weishu b23afe24fe feat(android): read-only chat screen + store wiring (B-M2d2) 2026-08-17 17:02:37 +08:00
weishu dfcf9849b9 merge: B-M2c Android message window store, pagination fixtures green 2026-08-17 15:51:34 +08:00
weishu 99df45e99a feat(android): message window store port, pagination fixtures green (B-M2c)
: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.
2026-08-17 15:50:47 +08:00
weishu f702cbf844 feat(android): session/machine stores + session list (B-M2b)
:core:protocol — summary-side patch path ported from the web reference:
- wire/SummaryPatching.kt: patchSessionSummary port with the summary path's
  >= versioned gates (replicated web divergence vs the detail path's strict >,
  documented), derived-field recompute (pendingRequestsCount/Kinds/Requests
  capped 5, todoProgress), render-irrelevance filter (activeAt-only keep-alive
  suppression), toSessionSummary/toSessionSummaryMetadata projection, and the
  deprecated canApplyVersionedSummaryPatch legacy gate.
- wire/SessionSorting.kt: exact sortSessionSummaries comparator
  (globalPinned > pinned > active > pendingRequestsCount among active >
  updatedAt desc; Long-safe, stable).
- SessionMetadata grows the per-flavor agent session id fields the
  agentSessionId projection needs.

:core:data store/ — StateFlow stores with per-hub JSON snapshots:
- JsonSnapshotStore: debounced (500 ms) atomic tmp+fsync+rename snapshots,
  synchronous cold-start load, corrupt files degrade to null.
- SessionStore: sorted summary list + per-id detail cache (strict-> detail
  patching via SessionPatching), full-session upsert preserving hub-computed
  scheduled fields, REST fallback for unparseable payloads, coalesced refresh
  (16 ms batch like the web), optimistic pin/archive.
- MachineStore: full/patch/null machine-updated decision tree per sse.md.
- LastSeenStore: per-session last-seen watermarks + once-per-scope baseline
  seeding + unread derivation (sessionLastSeen.ts/sessionAttention.ts port).
- StoreSyncTargets: SyncEventRouter fan-in — global-scope message-stream
  events refresh the list, gap handshakes full-resync.

:app feature/sessions/ — standalone screen + plain-constructor ViewModel:
- SessionListScreen: pinned section, status dot with thinking pulse, flavor/
  machine/worktree meta line, pending badge, todo chip, unread dot, machine
  filter chips, PullToRefreshBox, offline banner, empty states, long-press
  pin/archive sheet; taps emit onOpenSession only (no navigation).
- SessionListViewModel: store combine -> UiState, owns the global SSE
  subscription while started (subscribe after collector registration),
  entry refresh, error SharedFlow for snackbars.

Tests: SummaryPatching/SessionSorting JVM tests (cases mined from
useSSE.test.ts), store tests (stale/equal/newer patches, full replace,
keep-alive identity, snapshot round-trips, optimistic rollback), ViewModel
combine + handshake tests with fake stores. All of :core:protocol:test,
:core:data:test, :app:testDebugUnitTest, :app:assembleDebug green.
2026-08-17 15:47:05 +08:00
weishu f275bae4cb merge: B-M1c Android SSE engine + reconnect state machine
# Conflicts:
#	android/README.md
#	android/core/data/build.gradle.kts
#	android/gradle/libs.versions.toml
2026-08-17 15:16:38 +08:00
weishu 483155ece3 feat(android): SSE engine + reconnect state machine (B-M1c)
: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).
2026-08-17 15:14:55 +08:00
weishu 6f1dc23d77 feat(android): auth + HapiApi transport (B-M1b)
:core:data auth + API transport for track B M1:

- auth/: JwtPeek (unverified {uid,ns,exp} peek for proactive refresh),
  CredentialStore interface + EncryptedPrefsCredentialStore
  (security-crypto) + in-memory impl, HubUrls origin normalization,
  HubRegistry (ordered multi-hub roster + active hub behind a storage
  seam), AuthInterceptor + single-flight TokenAuthenticator (401 ->
  re-exchange -> retry once, Mutex single-flight, terminal AuthEvents,
  ensureFreshToken() for SSE pre-connect)
- api/: HapiApi (plain OkHttp + kotlinx.serialization, one suspend fun
  per v1 endpoint incl. generated-image bytes + multipart transcription
  helper), ApiError with (status, code) per errors.md
- HubSession: per-hub factory wiring clients (timeouts, auth decoration,
  256 MB image cache), DI-free
- :core:protocol wire/: request DTOs (ApiRequests.kt) + response DTOs
  (auth, health, resume/reopen, cancel/steer/queued-state, spawn,
  slash-commands/skills, machine list-directory/paths-exists, uploads,
  transcription)
- tests: 45 unit tests (MockWebServer) — refresh/retry-once, concurrent
  single-flight, terminal 401 paths, ApiError code mapping, request
  shapes (cursors+epoch, deliveryMode, nested answers, explicit nulls),
  JwtPeek/HubUrls/HubRegistry
2026-08-17 13:07:44 +08:00
weishu 83bd25aa3b feat(android): scaffold Compose app + core modules + CI (B-M0) 2026-08-17 11:22:35 +08:00