Android: projections, batch reconciliation, and custody
How the Android app picks the agent, reads agent limits and switches accounts, caches timeline projections, reconciles timelines in batches, holds encrypted custody of every send, chooses a delivery mode, and shows messages between sessions.
Checked against the source on 5 October 2026
On this page
You choose the agent when you create the session
The new-session dialog shows an agent picker with Codex and Claude. The agent is fixed for the life of the session, so you pick it before session.create, and the request carries it. NewSessionAgentPreference keeps your last choice in SharedPreferences. Before the first choice the picker starts on Codex.
The dialog can also continue a saved conversation of the chosen agent in the same project folder. The conversation picker loads them through run.chatExistingSessions and previews one through run.chatPreviewExistingSession, and session.create then carries its conversationId.
After that, a Claude session goes through the same projections, batches, and custody as a Codex session, with the same rows and the same run.chat* methods. The latest-load answer and each reconciled session have a closed field set that includes nativeSessionId, threadId, boundaryToken, and storageFormat. The decoder accepts a storageFormat of native or indexed and rejects anything else.
The composer is the part that differs per agent.
| Composer rule | Codex session | Claude session |
|---|---|---|
| Replace the latest sent message | Offered when no run is active and the draft is empty. | Not offered. A send in that state is rejected locally. |
| Speed mode of a message | Standard or Fast. | Always Standard. |
| Access modes, in list order | Full access, Full auto, Never ask, Read only. | Plan mode, Accept edits, Auto mode, Bypass permissions. |
| Featured model families | astra, sol, terra, luna. | fable, opus, sonnet, haiku. |
Both lists of access modes send the same four wire values: read-only, full-auto, full-access, and unrestricted. The Claude list shows them in that order under Claude’s names. A session with no stored access mode runs in full-auto.
Agent limits and account switching
- Scheduled readsystem.codexUsageLimits · system.codexAuthProfiles · system.claudeAccounts
The Projects screen reads on entry when nothing is loaded and then every 60 seconds. Settings reads once for an agent it never saw. Limits or profiles alone still apply, together with the error of the other call.
- Codex switchsystem.activateCodexAuthProfile
Key: system.activateCodexAuthProfile:<profileId>:<uuid>. The phone sends nothing when the profile is not signed in, is already active, or another switch runs. The answer holds the profiles. The limits belong to the old subscription, so the phone reads them again.
- Claude switchsystem.activateClaudeAccount
Key: system.activateClaudeAccount:<accountId>:<uuid>. The answer holds every account with its usage windows, so no second read follows. The decoder needs unique account IDs and exactly one active account.
- Desktop fenceisCurrentDesktopSelection
Each request remembers the selected desktop. An answer that arrives after you selected another desktop is dropped. Selecting a desktop also clears both account states.
AgentAccountsCoordinator reads the agent accounts of the selected desktop. For Codex it sends system.codexUsageLimits and system.codexAuthProfiles, and for Claude one system.claudeAccounts call that returns every account with its usage windows. The Projects screen reads an agent on entry when it has nothing loaded for it and then reads both agents every 60 seconds. The Agents group in Settings reads once for an agent that it never saw.
A Codex read with one good answer still applies. The phone keeps the other value that it already had and shows the error of the failed call. The Claude decoder is strict: it rejects the whole answer when two accounts share an ID, when the number of active accounts is not exactly one, or when a usage window has a kind other than session or weekly.
A tile or row shows one of three states: usage windows, a short note such as Not set up or Unavailable, or Checking. Checking means that a read runs or that no answer has arrived yet.
| Account switch | Refused on the phone when | After the answer |
|---|---|---|
| Codex subscription | The profile is not signed in, is already active, or another switch runs. | The profiles apply, the limits become unknown, and a new read of limits and profiles starts. |
| Claude account | The account is not signed in, is already active, or another switch runs. | The answer holds every account with its usage, and it applies at once. |
A Codex switch sends system.activateCodexAuthProfile with the key system.activateCodexAuthProfile:<profileId>:<uuid>. A Claude switch sends system.activateClaudeAccount with the key system.activateClaudeAccount:<accountId>:<uuid>. The UUID is new for every tap.
Every request remembers the desktop that was selected when it left. An answer that arrives after you selected another desktop is dropped. Selecting a desktop also clears both account states, so the tiles start again with Checking.
An agent’s screen loads its usage history through system.agentUsageHistory with the agent name. The answer holds recordingSince and the weekly samples of the active account. For Codex it can also hold tokens per day and a token summary with lifetime tokens, the peak day, and the current and longest streaks. A reload that fails keeps the history that is already on screen.
A cache of timeline projections
A TimelineTarget is an app session, a project directory, an optional thread for a subagent, and a desktop. Projections for parent sessions and subagent threads live in the ViewModel’s memory, keyed by target, and the cache is pruned after every reducer action. The visible parent, the selected thread, and targets that are pending or running in reconciliation are protected. Of the rest, a projection idle for 12 hours is evicted, and beyond 64 projections the least recently accessed go first, with ties broken by session, desktop, project, and thread.
A snapshot requested while its target was visible may finish after you navigated away. It still updates that target’s cached projection, and it cannot touch the visible timeline or schedule more work.
One gate for content-bearing requests
WorkspaceTimelineSyncGate decides whether timeline work may run at all. Its scope is the Workspace destination with a session, a project directory, a desktop, and the selected thread, and it exists only while the Activity is visible. Every change of that scope, including hiding and showing the Activity, increments a generation. A request captures the generation and the live event revision, and a latest-window request also takes a number from a global snapshot admission sequence. A result applies only while its request still owns the visible scope, which also rejects a response that comes back after you went from chat A to chat B and back to A.
One or two targets per request, split on failure
- Batchrun.chatReconcileSessions
Up to 16 targets from one project directory, answered all or nothing. One failing session, or results that pass 8 MiB together, fail the whole request.
- SplitisolateBatchFailure
A failed batch is halved and each half runs again, recursively. Only a target that fails alone turns stale, and its rows stay visible. When the desktop doesn’t answer at all, every target ends up stale.
- Smaller pageTIMELINE_SNAPSHOT_TOO_LARGE
A single target that is still too large retries with 12 rows instead of 100, up to 3 times, after about 0.75 s, 1.5 s, and 3 s (±20%, never above 3 s).
- GateWorkspaceTimelineSyncGate
Only the visible parent timeline and its selected thread are sent. Other queued targets stay in the queue.
Stale targets queue in the reconciliation coordinator and drain through run.chatReconcileSessions. The coordinator takes up to 16 targets of one project directory per request, but only after the gate filtered the queue. The gate admits the visible parent and its selected thread, so a batch has one or two targets. A request asks for 100 rows per target. Targets that no longer own the visible scope are cancelled before the request goes out. The response must contain exactly one session for each requested target, matched by exact identity. Each session also carries outboxEntries, the desktop’s queue for that session, and the phone applies them together with the page.
A failed request, a decoding error, or a response that does not match splits the batch in half and runs each half again, recursively, until a single target fails alone. That target is marked stale with its error while its rows stay visible. A TIMELINE_SNAPSHOT_TOO_LARGE error splits the batch the same way, and a single target then retries up to 3 times with 12 rows instead of 100, after 750 milliseconds doubling to at most 3 seconds, with 20 percent jitter.
Live overlays and latest windows
A blocks event applies only when its settlement epoch equals the current one and its revision is at least the current revision, and an equal revision must carry identical blocks. A newer epoch, or an equal revision with different blocks, marks the target stale and queues reconciliation, and anything older is dropped. The parent overlay and the selected thread’s overlay are tracked separately.
A new latest window keeps the older rows the phone already loaded when their exact identities line up with the new window. The first page holds 12 rows with a 115-second timeout and up to 3 retries, and older pages hold 100 rows.
LazyColumn keys, anchors, and the live edge
Timeline rows use the key type::id and a content type per row kind, next to fixed items for the error state, a conversation anchor, and the load-older row. Before older rows are inserted, the list records the first visible index, its offset, and its key. Afterwards it restores by key, or shifts by the number of inserted rows when the key is gone. Loading older rows needs a real scroll toward the top, starts within an 8-item band, and has to re-arm after each load.
The list follows the live edge while you are within 72 pixels of the bottom. Following moves at the remaining distance divided by 1.5 seconds, clamped between 80 and 2,200 pixels per second, and settles after 2 stable frames within 1 pixel.
MarkdownPreparationWorker parses Markdown on Dispatchers.Default. Composables waiting for the same text share one job, and the job is cancelled when the last of them leaves. Results are cached, up to 256 documents and 16 MiB, and a document of 64 KiB or more is split into at most 24 top-level chunks that render lazily. Compose reads the cache through volatile snapshots without waiting for the worker’s lock, and when a streaming row is replaced by its settled row the prepared frame carries over, so the text does not flash.
A row that a link opens moves into view in one step. WorkspaceTimelineRowPreparation makes the list compose and measure the target row and the rows before and after it, until each side fills one height of the timeline. The rows in view do not change and take taps meanwhile. When the Markdown of these rows is ready, the row moves once: its middle goes to the middle of the list, and the list stops at its ends. After 1 second the row moves also when the preparation is not complete. The row of a link pulses once.
The floating controls rest on one bottom line in the main timeline and in the thread viewer: the menu, scroll to latest, and Stop. Each control is 52 dp, 16 dp from the screen edge, and 10 dp from its neighbor. Its bottom edge is 57 dp above the navigation bar, and the last row of the timeline rests 125 dp above the navigation bar. Modifier.floatingSurface gives each floating surface the same tone, surfaceBright. The menu button, the scroll button, and the notice card are faces: the tone at 94 percent, a raised shadow, and a sheen in dark mode. The connection banner and the pending steer row are cards: the full tone, a low shadow, and no sheen. The menu button shows one badge on its top end corner: the count of queued messages while the queue has some, else the count of active sub-agents. Its content description names both counts.
Encrypted custody of every send
- Encrypted intentSharedPreferencesWorkspaceOutboxStore
One AES-GCM blob for every waiting intent, bound to the SHA-256 of region and principal, and written with commit() before the RPC. A wrong owner or an unreadable blob makes writes fail instead of replacing it.
- Receiptrun.chatOutboxSubmit
Submits for one session run one at a time. A receipt that echoes the queue and operation IDs removes the intent at once. With no receipt within 60 s, the entry is queued again.
- Reconcilerun.chatOutboxLoad
Runs for every target on the selected desktop once it answers system.ping. Intents the desktop lists or the timeline shows are removed, and a removal the desktop still lists is sent again with its stored key.
- Operation IDandroid-<uuid>
Every resend reuses it, and the desktop matches it before storing anything. Editing a queued entry creates a new operation ID. Send now keeps it and renews only the idempotency key.
Every send, edit, and removal is written to the outbox store before an RPC leaves the phone. The store keeps one encrypted blob for all waiting intents, each with its desktop, project, and session, the queue and operation IDs, the idempotency key, the prompt, attachments with their retained local path, the model and reasoning snapshot, the delivery mode, and a removal mutation ID when one is pending. Writes use commit() under a mutex and return only after the data is on disk.
The blob is bound to its owner, the SHA-256 of the region and the principal, where the principal is the JWT subject or else the lowercased email. When the owner does not match or the blob cannot be decrypted, the store reports unreadable custody, and writes fail instead of replacing it. A late submission write also cannot replace a removal that was already recorded.
An operation ID looks like android-<uuid>, and the queue ID is queue-<operation ID>. Send now keeps both and renews only the submit idempotency key, run.chatOutboxSubmit:<uuid>. An edit keeps the queue ID and creates a new operation ID, a plain UUID, and a new key, because the edited text is a new message. Submits for one session run in a lane, one at a time. Each waits for pending chat-settings saves, stops when the entry was removed in the meantime, and requires the receipt to echo the same queue ID and operation ID. A submit can carry expectedChatSettings with model, reasoningEffort, and revision, taken from the entry’s selection snapshot or else from the session’s current settings.
Remove, reorder, run cancel, and stop and send now each use a key of the form outbox-<kind>-<uuid>, new for every tap. The desktop deduplicates delivery by the queue and operation IDs inside the request. Reorder sends run.chatOutboxReorder with the full list of orderedQueueIds.
When the desktop is ready again, the waiting coordinator reads run.chatOutboxLoad for each target. Intents the desktop already has are finished. Intents it lacks, which are not visible in the timeline either, are submitted again with the same operation ID, and a removal the desktop still lists is sent again with its stored key.
Attachments come from the system document picker and are copied into the app cache, up to 1 GiB. A PNG of at most 16 megapixels is re-encoded as JPEG at quality 88. The upload runs begin, chunks at the desktop’s chunk size capped at 1 MiB and base64-encoded, then finish, with deterministic keys of the form files.chatAttachmentUpload:<session>:<name UUID> followed by :begin, :chunk:<n>, :finish, or :cancel. Cancel is sent only after a failure that must not be retried.
Delivery modes, steering, and Stop and send now
Every entry has one of four delivery modes. The mode decides the two wire fields intent and sendMode of run.chatOutboxSubmit.
| Delivery mode, with intent and sendMode | Chosen when |
|---|---|
| Front | You send and the session has no run and no queued or sending entry. Send now on a queued entry also uses it. |
| Queue: enqueue, queue | You send, no run is active, and the session already has a queued or sending entry. |
| Steer: automatic, steer-running | You send while a run is active, in a Codex session and in a Claude session. The request names that run in expected |
| Interrupt: replace-latest, interrupt-running | You replace your latest sent message in a Codex session. |
The request builder enforces the pairing: steer-running without expectedActiveRunId throws, and so does expectedActiveRunId with any other sendMode. The outbox decoder applies the same rule to entries that the desktop returns. A steer that is requested when no run is active becomes a FrontQueue entry.
- Front queueintent automatic · sendMode queue
The primary send when the session has no run and no active outbox row. With an active row and no run, the send is a plain queue entry: enqueue with queue.
- Steersteer-running · expectedActiveRunId
The primary send during a run. The request must name the active run, and no other mode may carry expectedActiveRunId. The agent applies an accepted steer after its current step.
- Accepted proofaccepted { runId, threadId, turnId, acceptedAt }
The desktop adds it to the outbox entry. All four fields are required. Stop and send now shows only for a Sending entry with proof for the active run and no handoff.
- Stop and send nowrun.chatOutboxStopAndSendNow
One request per operation with the key outbox-stop-and-send-now-<uuid>. The entry then carries handoff.phase: requested, replay-authorized, dedicated. A failed request shows “Not confirmed”.
The agent applies an accepted steer only after its current step: Codex after the current model step and its tool calls, and Claude between two tool rounds. Until the user row lands, the timeline shows the message with the caption “Sends after the current step”. The Stop and send now action shows next to it under four conditions: the entry is Sending, it carries an accepted proof, the proof names the active run, and the entry has no handoff. When the phone knows the active turn from a running command or tool row, the proof must name that turn too.
The action sends run.chatOutboxStopAndSendNow with the session, the queue ID, the operation ID, and the expected run, thread, and turn from the proof. The desktop checks the three again. The phone sends at most one such request per operation. While it runs, the action is hidden. When it fails, the caption changes to “Not confirmed” and the action does not come back. After a success the entry carries handoff.phase, one of requested, replay-authorized, and dedicated, and an entry with a handoff never offers the action again.
Messages between sessions
- Message fact on a rowmessage { direction, kind, from, to, peer, delivery, link }
A user row or an activity row can carry it. The desktop makes the words of the label line. A fact that breaks a rule rejects the row. A tap on the name of the other end opens that end at the row of the message.
- Queue entrymessage · holdReason
The card shows the label line of the message fact above the text and the hold reason below it. The phone shows and edits userText only. The promptText holds the desktop envelope.
- Send nowintent automatic · sendMode queue
Keeps the queue ID and the operation ID and renews the idempotency key. The desktop reads a send with any intent other than enqueue as your approval.
- Edit in the queuenew operation ID
The edited text is a new message from you. The entry keeps its queue ID, gets a new operation ID and a new key, and loses the message fact and the hold reason.
A user row or an activity row can carry message, the typed fact of a message between two sessions or between two agents. The band shows its label line in the words of the desktop, for example “This session → Session B” or “Claude → This agent”, the signal Sent, Needs approval, or Not sent, and the text as Markdown. The decoder is strict: a fact with a value that is not a wire value, or with a blank word, rejects the row. The phone makes no words of its own.
When the fact has a link, the name of the other end in the label line is the link: strong teal text with the soft underline of a Markdown link, and the agent mark inside the tap target. The link is its own node for a screen reader, with the action “Go to the message in <peer>”, and the platform gives it the minimum touch area. A tap on another part of the band opens the message detail: the title, the label line with the same link and the signal, the summary, the failure reason, and the text as Markdown. The detail comes only from the band, because a message row has no detail on the wire.
A tap on the link starts one anchored load, MobileScreenState.anchoredLoad. The phone opens the session of link.appSessionId, else this session, and the thread of link.threadId, else the main timeline. Then it loads that timeline with a link anchor. When the answer lands, the row of anchorRow moves to the middle and pulses once. anchorNotice shows as a small card at the top of the timeline, and a tap closes it. There is no Back control: the way back is the link on the row at the other end. The load ends when you go to another place or send a message.
When a gap recovery runs while the timeline shows a page in the past, the page stays. The timeline names the first saved row in view and its offset. The phone loads the page again with a row anchor at that row. The row comes back to the same offset with no pulse, in the first frame that the timeline draws.
A message that still waits is an outbox entry of the recipient session. The desktop sends it with message, the fact of the user row that it becomes, and, when it holds delivery, with holdReason. The queue card shows the label line above the text and the hold reason below it. The card shows and edits userText only, because promptText holds the desktop’s envelope around the message.
Send now submits the entry with its queue ID and operation ID unchanged, as FrontQueue. An edit replaces the operation ID and clears the message fact and the hold reason, so the entry becomes your own message.
Tests that pin this behavior
- WorkspaceChatReconciliationRetentionTest: exactly 64 retained targets, idle eviction before the cap, and visible, pending, and running targets kept beyond it.
- WorkspaceChatReconciliationAdmissionTest and WorkspaceChatReconciliationProjectionTest: a failing target turns stale without losing its rows, and repeated prompts with the same text are never merged by text or timestamp.
- HBCodeAndroidWorkspaceReconciliationCoordinatorTest: the parent and thread in one request, bisection of a failed batch, and the bounded retries of an oversized timeline.
- TimelineLiveGrowthTest, an instrumented parity test: a sent message and rows that grow later move the list forward in bounded steps.
- WorkspaceAnchoredLoadReducerTest: with the shared past-window answers, a link lands on the exact row, between two sessions and between the main timeline and an agent thread.
- WorkspaceMessageLinkRenderingTest, an instrumented test: a landed row has its middle at the middle of the list, and after a gap recovery the row of the reader is at its place from the first frame.
./gradlew :app:testDebugUnitTest
./gradlew :app:pixel2api35DebugAndroidTest