Mobile has no way to show whether a live agent is thinking, streaming, running hooks, or acting.
SessionStatus models only running | waiting_input | idle (src/types.ts:9), so the whole phase axis inside running is missing.
Verified state
Checked 2026-08-12 against origin/main @ 0eb2c53.
The first attempt (tb-mobile #647, now closed unmerged) derived this on the client by scraping mobile's own terminal emulator.
It never worked: mobile mis-handles absolute cursor positioning, so the status line it searched for was never where it looked. See threadbase-mobile#652.
This server already has everything the derive needs, and no equivalent exists yet:
- A real
@xterm/headless screen per session at the geometry the TUI paints against — src/pty-manager.ts:42-43, 120x40.
- A throttled screen-scrape already running on every chunk —
detectLivePrompts at src/pty-manager.ts:857/:881, SCRAPE_THROTTLE_MS = 300 at :80.
- An established precedent for scraping this exact footer and shipping the result —
src/services/questions/parseStatusLine.ts extracts model/effort/permissionMode.
- Codex already classifies its own phase —
src/codex-pty-runner.ts:46/:50 export CODEX_PROMPT_READY_TEXT and CODEX_BUSY_STATUS_RE, and :362 maintains a turnBusy set.
subStatus appears nowhere in src/.
What the plan settles
Full plan and review log: #537 -> docs/plans/2026-08-12-agent-sub-status-server-side.md.
- The phase is cleared server-side at
markReady (src/pty-manager.ts:1168). Without this it latches on any session that stops emitting, which is the exact bug #647 shipped. src/pty-manager.ts:1103-1108 documents why a stop can go undetected.
- A bespoke scoped frame, not a
SessionResponse copy. managedToResponse recomputes elapsedMs on every call (src/session-store.ts:277), so a session-copy frame would differ every tick and re-render the client roughly three times a second for a whole turn.
- Always emit the field,
null when there is no phase — absence must never carry meaning, because the client cache merge cannot express it.
- Unconditional key on the GET path.
managedToResponse guards optionals with != null in 19 places, which drops explicit nulls; use the completedAt: … ?? null precedent at :280 instead.
- Rejected: riding
onStatusChange. It needs no new plumbing but routes a cosmetic field through recordStatus, whose update statement has no same-status guard.
- Codex ships first as the transport proof, with the full enum defined up front so a two-valued provider does not fix the field's shape.
Scope
Streamer: the derive module, the field, a dedicated callback, a scoped WS frame, a pty-host protocol event with a PTY_HOST_PROTOCOL_VERSION bump, and the clear at markReady.
Mobile's half is tracked separately; the clearing contract is defined here because the client cannot work around its absence.
Mobile has no way to show whether a live agent is thinking, streaming, running hooks, or acting.
SessionStatusmodels onlyrunning | waiting_input | idle(src/types.ts:9), so the whole phase axis insiderunningis missing.Verified state
Checked 2026-08-12 against
origin/main@0eb2c53.The first attempt (tb-mobile #647, now closed unmerged) derived this on the client by scraping mobile's own terminal emulator.
It never worked: mobile mis-handles absolute cursor positioning, so the status line it searched for was never where it looked. See threadbase-mobile#652.
This server already has everything the derive needs, and no equivalent exists yet:
@xterm/headlessscreen per session at the geometry the TUI paints against —src/pty-manager.ts:42-43, 120x40.detectLivePromptsatsrc/pty-manager.ts:857/:881,SCRAPE_THROTTLE_MS = 300at:80.src/services/questions/parseStatusLine.tsextractsmodel/effort/permissionMode.src/codex-pty-runner.ts:46/:50exportCODEX_PROMPT_READY_TEXTandCODEX_BUSY_STATUS_RE, and:362maintains aturnBusyset.subStatusappears nowhere insrc/.What the plan settles
Full plan and review log: #537 ->
docs/plans/2026-08-12-agent-sub-status-server-side.md.markReady(src/pty-manager.ts:1168). Without this it latches on any session that stops emitting, which is the exact bug #647 shipped.src/pty-manager.ts:1103-1108documents why a stop can go undetected.SessionResponsecopy.managedToResponserecomputeselapsedMson every call (src/session-store.ts:277), so a session-copy frame would differ every tick and re-render the client roughly three times a second for a whole turn.nullwhen there is no phase — absence must never carry meaning, because the client cache merge cannot express it.managedToResponseguards optionals with!= nullin 19 places, which drops explicit nulls; use thecompletedAt: … ?? nullprecedent at:280instead.onStatusChange. It needs no new plumbing but routes a cosmetic field throughrecordStatus, whose update statement has no same-status guard.Scope
Streamer: the derive module, the field, a dedicated callback, a scoped WS frame, a pty-host protocol event with a
PTY_HOST_PROTOCOL_VERSIONbump, and the clear atmarkReady.Mobile's half is tracked separately; the clearing contract is defined here because the client cannot work around its absence.