Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6a45d1c
XERK-246: put the local-model failover controls in the Android client
xerhab Aug 11, 2026
a8c5cc4
XERK-246: fix the switch memo dying on navigation, and match the web …
xerhab Aug 11, 2026
ec11ce1
XERK-246: rebase onto main, and close the escapes the second QA pass …
xerhab Aug 11, 2026
96428d8
XERK-246: name every field in the anti-truncation test, and coerce lo…
xerhab Aug 11, 2026
b210712
XERK-246: surrogate-safe name cut, target-host lookup, honest coercio…
xerhab Aug 11, 2026
364be07
XERK-246: state the coercion rule truthfully, strip input surrogates,…
xerhab Aug 11, 2026
994ff9a
XERK-246: coerce on the state restore too, close the XML-illegal clas…
xerhab Aug 12, 2026
505026c
XERK-246: make the restore actually run, and coerce the fields this b…
xerhab Aug 12, 2026
a39b757
XERK-246: bound the model name before spreading it — one beat was OOM…
xerhab Aug 12, 2026
f9e85a2
XERK-246: coerce past the size gate, and make the OOM guard determini…
xerhab Aug 12, 2026
65d1a05
XERK-246: gate the record on BOTH its raw and its coerced size
xerhab Aug 12, 2026
822ecf9
XERK-246: close the three defects the eleventh QA pass found
xerhab Aug 12, 2026
aecf74c
XERK-259: hold the whole agent record to the shape clients type
xerhab Aug 12, 2026
f982aed
Merge origin/main into XERK-246-android
xerhab Aug 12, 2026
e4817ef
XERK-259: close the limits hole this sweep had certified as safe
xerhab Aug 12, 2026
a3a9fb1
XERK-246: fix the class, not the instance — the twelfth pass's four
xerhab Aug 12, 2026
d60cdb0
XERK-259: close the blind spots in the guards themselves
xerhab Aug 12, 2026
50874f9
XERK-259: teach both guards what a string literal is
xerhab Aug 12, 2026
723497e
XERK-259: a quote in a regex class is not the start of a string
xerhab Aug 12, 2026
5181931
Merge origin/main into XERK-246-android
xerhab Aug 12, 2026
b9b0ed8
XERK-259: lex regex literals, the last shape that faked a comment
xerhab Aug 12, 2026
c5fcad4
Merge remote-tracking branch 'origin/XERK-246-android' into XERK-259
xerhab Aug 12, 2026
364237b
Merge pull request #435 from xerktech/XERK-259
xerhab Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/rules/turma-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ Split out of `.claude/rules/turma.md` (which covers the rest of the hub UI) to k
session is marked (🏠, warn colour) — it is a weaker model, and nobody should have to wonder which
one wrote a turn. Like the mode switch it paints from a MEMO, never an optimistic write onto
`sess`, so a stale beat can't flash the old value back; the memo ages out so a switch that never
lands doesn't pin the chip. Tests: the `model source:` cases in `chat.test.js`.
lands doesn't pin the chip. **`normalizeLocalModel` coerces the block at ingest** — the block is
typed on Android and `/api/agents` decodes atomically there, so one host's `available:"yes"` hid
the whole fleet from the phone; see CLAUDE.md's heartbeat contract. Tests: the `model source:`
cases in `chat.test.js`, `normalizeLocalModel` in `server.test.js`.
- The compose footer's agent-mode / model selectors are joined by a compact **PR status chip**
(`prFooterChip`) when it has one, and a `jira-chip` when the session has a ticket.
- The **model selector is accurate** (XERK-33) — never a hardcoded menu, and never rewriting the
Expand Down
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,33 @@ Rules spanning more than one component, so no `paths:`-scoped file can carry the
it every client). A field older agents don't send must degrade, never break: clients gate on the
capability flag the agent reports (`inputMaxChars`, `uploadMaxBytes`, `github.available`,
`capacity`), and an absent flag means "that agent can't do it", not "unlimited".
- **A full `/api/agents` decode is ATOMIC on Android**, so one host's wrong-typed field throws for
the whole array — the poll fails silently while the app keeps its last snapshot and the tile
still says "N / N online". Per-agent SSE events decode individually, so the bad host is simply
missing from the list while SSE is healthy; with SSE down too, the raw decoder exception
replaces the screen.
- **A field becomes decode-fatal the moment a client TYPES it** — until then `ignoreUnknownKeys`
skips it and any value is harmless. So typing one on `SessionInfo`/`AgentInfo` and adding its
hub-side coercion are the SAME change; `normalizeRecord` is where it goes, and it runs on both
the heartbeat ingest and the `state.json` restore (a restart is when a coercion ships, and the
restore is the first thing it serves). Coerce to the "can't tell you" value every client already
handles, never to a plausible default.
- **The whole record is held to that shape by `turma/wire-shape.js`** (XERK-259), a table mirroring
`Models.kt` — per-block `normalize*`s covered only what someone had got to, and `repoUsage:[null]`
from one host stopped the phone signing in at all. A LIST's ELEMENTS are as fatal as its type,
and `typeof [] === "object"`, so element tests go through `isPlainObject`.
- It coerces **IN PLACE, touching only the keys it names**, which is what keeps it from being a
whitelist: a sub-key a newer agent adds rides through untouched, where rebuilding an object
drops it fleet-wide until the table catches up (`normalizeLimits`/`normalizeLocalModel` DO
rebuild, so a new sub-key of theirs must be added to them).
- **A block those two rebuild is in the table anyway** — a rebuild is only as good as its own
gates, and `limits` shipped one gating its epoch fields on `Number.isFinite`, so a fractional
`resetsAt` (Kotlin `Long` takes no fraction) went out raw. Nothing agent-authored is exempt.
- Its own module because the restore runs at `server.js` module init: a `const` declared below
that point is in its temporal dead zone, and the ReferenceError dies in the restore's own
`catch {}`, leaving records half-coerced with nothing logged.
- Typing a field in `Models.kt` without adding it there **fails the hub's suite** — the test
parses `Models.kt` and walks it, so this pairing is enforced, not remembered.
- **A hub refusal must reach the operator, in the hub's own words** (XERK-264). The hub refuses
commands with a status and a JSON `{error}` body (409 org mismatch / unsupported agent, 503 host
offline, 404 stale attachment, 413 too long, 429 queue full); a client that reads the body and
Expand Down
60 changes: 47 additions & 13 deletions android/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ are recorded under "Deliberate differences" below, not left to look like gaps.
- **Hub-URL field on Login.** The web is same-origin; a phone app must point at any hub, so Login has
an extra Hub-URL field.
- **Voice dictation** into the spawn/compose fields — a phone-only addition.
- **No hover tooltips on the two compose-bar model chips** (XERK-246); a phone has no hover, so the
web's `title=` text goes to the accessibility layer or nowhere.
- The **"run against"** chip: nowhere. Its tooltip only names the self-hosted model, which is
already the chip's own text.
- The **fixed-model** chip on a local session: it carries the web's wording as a
`contentDescription` instead. That tooltip is not redundant — it explains why the chip is inert
and names the way out — so dropping it entirely would leave a dead-looking control beside two
live ones with no account of itself.
- **Ticket-detail fields tap-to-change** (XERK-138 follow-up). The web detail panel shows each editable
field's value beside a separate "Change" link/control that swaps in a `<select>`. Android instead
renders the value itself as the control — a pill with a trailing ▾ (`SelectableValue` in
Expand Down Expand Up @@ -315,6 +323,40 @@ are recorded under "Deliberate differences" below, not left to look like gaps.
the drop case. Sharing INTO the app is not wired up (no `ACTION_SEND` intent filter yet) — a
reasonable follow-up, tracked below.

## Done (XERK-246 — local-model failover controls)

- **Switch a running session between the subscription and the host's self-hosted model.** A third
compose-bar chip beside model and mode ("run: subscription" / "run: <model>") POSTing
`.../sessions/<id>/model-source`; the agent relaunches with `--resume`, so the conversation,
worktree and branch carry over. The chip paints from a memo until the heartbeat agrees — the
relaunch takes several beats, and without it the value springs back and reads as a dead control —
and the memo ages out (`ModelSource.SWITCH_SETTLE_MS`) so a switch that never lands can't pin it
on a lie. A refused switch (the hub 409s a host with no local model) drops the memo at once and
says why.
- **Start NEW work on the local model**: a "Run against" row in the spawn composer, the web's
`sessions.html` field. Without it you could fail existing sessions over from the phone but not
begin anything once usage was gone — which is exactly when you need to.
- Both follow the **host's** `localModel.available`, exactly as the 📎 follows `uploadMaxBytes`; an
agent reporting nothing cannot do it, so the control is hidden rather than offered and refused.
The compose-bar chip is also shown when the session is already `local`, so one whose host later
lost its configuration keeps a visible way back.
- **The Claude model picker is hidden on a live local session** (a static chip states the model
instead), matching the web's `cc-model-fixed`: every alias it could offer — "default" included,
since that resolves to the shared login's default — is one the self-hosted endpoint refuses. The
**spawn composer keeps its Model row**, also matching the web (`sessions.html` renders and sends
it whatever the source): the agent drops `--model` for a local session itself, and the alias is
what that session goes back to if it is later switched to the subscription, so discarding it
would give an Android-spawned session a different model from a web-spawned one.
- The memo lives in `AppContainer.modelSwitches`, not the chat ViewModel, for the same reason
`drafts` does — the VM is scoped to the chat's nav entry, so a memo kept there died the moment
you walked back to the session list, mid-switch, which is when it is doing its job.
- Pure half in `core/ModelSource.kt` (`ModelSourceTest.kt`), the state reads in `ChatUiStateTest.kt`;
the wire block is locked in `AgentDecodeTest.kt`, including the all-nulls shape an unconfigured
host reports and the null `modelSourceAt` every unmoved session carries.
- **Still open:** the 🏠 mark on live and ended session CARDS (see below) — inside a session the
compose-bar chip already names the model. The chip's missing tooltip is a deliberate difference,
recorded above.

## Open (subsequent installments), by screen and priority

Many of these need Android's wire model (`model/Models.kt`) to decode fields the web already renders;
Expand All @@ -338,19 +380,11 @@ those are marked `[MODEL]`.
- P1 Composer base-branch dropdown + per-repo option persistence.

### Sessions + Chat (`sessions.html` + `chat.js` → `SessionsScreen`/`ChatScreen`)
- **P1 Local-model failover control + chip (XERK-246).** The web compose bar carries a third
selector beside agent-mode and model — "Run against: Claude subscription / <self-hosted model>" —
which POSTs `/api/agents/<host>/sessions/<id>/model-source` and relaunches the session on the
local model, keeping its conversation. A session on the local model is marked (🏠 + warn colour)
so nobody has to wonder which model wrote a turn. Android shows neither the control nor the mark.
Gate it on the host's `localModel.available`, exactly as the 📎 gates on `uploadMaxBytes` — an
agent that reports nothing cannot do it. Field: `session.modelSource`
(`"subscription"`/`"local"`). This matters on a phone precisely when it matters most: usage runs
out while you are away from a desk. Three parts, all missing: the compose-bar selector, the mark
on live AND ended session cards, and a **"Run against" option in the spawn composer** (without the
last one you can fail existing sessions over from the phone but cannot start new work once usage
is gone). Note the web hides the model picker entirely for a local session — every alias it could
offer is one the gateway refuses.
- **P2 Local-model mark on session CARDS (XERK-246 remainder).** The two controls are done (see Done
above); what's left is the web's 🏠 + warn-colour mark on live and ended session cards, so a
glance at the list says which sessions are on the weaker model without opening each one. Read
`session.modelSource == "local"`, titled with `modelSourceAt`. Both fields already decode onto
`SessionInfo`.
- ~~P0 Jump-to-latest pill + stick-bottom scroll.~~ Done (XERK-78, see Done above).
- ~~P0 Ended sessions: stopped + `repo.resumable` channels + live-list exclusion.~~ Done (XERK-78,
see Done above; the read-only review itself was XERK-70).
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/java/com/xerktech/turma/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.xerktech.turma
import android.content.Context
import com.xerktech.turma.data.Config
import com.xerktech.turma.data.DraftStore
import com.xerktech.turma.data.ModelSwitchStore
import com.xerktech.turma.data.OrgFilter
import com.xerktech.turma.data.TextSizePref
import com.xerktech.turma.net.Dictation
Expand Down Expand Up @@ -40,6 +41,14 @@ class AppContainer(context: Context) {
*/
val drafts = DraftStore()

/**
* In-flight model-source switches (XERK-246). Here for the same reason as
* [drafts]: the chat ViewModel is scoped to its nav entry, so a memo kept
* there died the moment you walked back to the session list — mid-switch,
* which is exactly when the memo is doing its job.
*/
val modelSwitches = ModelSwitchStore()

/**
* The in-session chat text size (XERK-144). Here for the same reason as [org]:
* it is ONE value shared by every chat — set from any session's settings menu,
Expand Down
Loading
Loading