Skip to content

P2: derive agent sub-status server-side and emit it as an additive field #538

Description

@RonenMars

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Soon, but does not gate a releaseenhancementNew feature or requestproviderClaude Code / Codex provider integration

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions