# CTM Mobile Live Streaming

## Goal

The phone app must behave like a live CTM client, not a periodically refreshed
report. A user should be able to open `/m/`, tap a session, and see new session
output or waiting-for-input state without waiting for the HTTP conversation
history or the fallback polling timer.

## Local Host Path

For local, LAN, and tailnet access, `/m/` connects directly to the CTM host
WebSocket:

1. Restore a bounded local session snapshot immediately, when one is available.
2. Apply hash deep links before any startup HTTP call finishes.
3. Open one foreground WebSocket to the same origin.
4. Load auth and `/api/sessions/standup` in the background.
5. Load remote relay capability state in a separate background request.
6. Use WebSocket lifecycle messages as the primary status feed.
7. Use `subscribe-stream` for the active session detail view.
8. Keep `/api/sessions/standup` polling only as a safety net.

The phone composer is on the critical path; relay status, push settings,
project lists, and service-worker registration are not. Opening
`/m/#agent/<session-id>` must create a placeholder detail session if the initial
standup snapshot has not arrived yet. The placeholder can send
`session.send_message` and run skill autocomplete using global skills. When the
standup response eventually arrives, the placeholder is replaced by the real
session card and subsequent skill lookups include the session's agent and cwd.

The mobile shell keeps a capped localStorage snapshot for instant paint. The cap
is intentionally high enough for normal CTM dashboards, so users with hundreds
of sessions still get incremental refreshes. A fresh complete snapshot sends
`?since=<revision>` and the server may return an incremental delta. A stale but
not expired snapshot still paints immediately as a preview, but intentionally
drops the revision so the server returns a full standup response. If the cached
snapshot was capped, the phone also drops the revision so the server fills in
the omitted sessions.

The snapshot is not an auth source and is not current status by itself. If the
phone boots with a cached snapshot but CTM reports missing/expired device auth,
the UI must label the rows as a cached preview, show a pairing action, and avoid
presenting cached running/waiting lanes as authoritative. When a phone pairing
claim completes, the claim page clears the cached snapshot and writes a
short-lived "force full standup" marker. The next `/m/#status` load consumes
that marker and requests `/api/sessions/standup` without `since`, so a re-paired
phone cannot keep stale status data through an incremental no-op response.

The standup response contract is:

- no `since` or an unknown revision returns a full snapshot with `sessions`;
- current revision returns `incremental: true`, `unchanged: true`, and an empty
  `sessions` array;
- known older revision returns `sessionOrder`, `removedSessionIds`, and only the
  changed or newly added session cards;
- revisions must ignore volatile relative-age fields so a phone poll does not
  download every session just because "last activity 1m ago" became "2m ago".
- session cards use `worktreeStatus` as the canonical local-work summary. The
  phone cache may read legacy `worktree` cards, but it normalizes them back to
  `worktreeStatus` before rendering or persisting so changed-file/commit chips
  survive a reload before the next HTTP refresh finishes.

The key lifecycle messages are:

| Message | Mobile use |
|---------|------------|
| `session-activity` | Patch running or waiting state without polling. |
| `session.status` | Apply hook or telemetry authoritative running/idle state. |
| `waiting-for-input` | Move the session to Needs you and update the visible action copy. |
| `session-resumed` | Move the session back to Running after a reply or output resumes. |
| `sessions` / `data-changed` | Schedule a short debounced HTTP refresh for richer metadata. |
| `stream-init` | Hydrate the detail view from the hot ring buffer. |
| `stream-event` | Append or update live detail messages. |

## Detail View Contract

The detail view is stream-first:

1. Set the active CTM session ID.
2. Subscribe to `subscribe-stream` immediately.
3. Start `/api/session/messages` in the background.
4. Render hot stream events as soon as they arrive.
5. When HTTP history finishes, render the history and replay the live tail.

This avoids blocking resume or watch flows on large JSONL files. The HTTP
history path is still valuable for older transcript context, but it must not own
freshness.

The detail view also keeps a small, browser-local transcript tail per session.
That tail is a non-authoritative preview only: opening a session can show the
last known durable messages before WebSocket or HTTP history finishes, then the
canonical `/api/session/messages` response replaces cached rows while preserving
new live events that arrived during hydration. The cache is capped by session
count, message count, message text length, and age, and it is cleared when the
phone consumes a forced full-refresh marker after re-pairing.

Live events are deduplicated by the strongest available key:

1. `eventId`
2. assistant `parentUuid`
3. role/type + timestamp + text prefix

Streaming updates with the same key replace the existing row. This preserves
Claude-style partial assistant updates and avoids duplicate rows after history
hydration.

## Detail Sheet And Composer Contract

The detail view is a modal sheet over the Status/Agents pages. While it is open,
the background page is locked and inert: body scroll is preserved, background
touch/scroll events do not leak through, and only the detail timeline plus
composer should receive interaction. Closing the sheet restores the original
page scroll position.

The phone reply surface intentionally uses a plain-text `contenteditable`
editor instead of a native `<textarea>`. iOS Safari reserves browser UI for
form controls, which can crowd the composer and make typing harder. The mobile
app owns a small text/selection adapter so send, clear, prompt history, draft
recovery, paste, multiline input, and `$`/`/` skill autocomplete all use the
same composer contract without depending on native textarea behavior.

## Fallback Polling

Polling remains for recovery:

- When the WebSocket is live, fallback refresh runs infrequently.
- When the WebSocket is unavailable, fallback refresh runs at the normal short
  interval.
- `pageshow`, `focus`, `visibilitychange`, and `online` reconnect the socket and
  refresh state without clearing the reply draft.
- Relay status refresh never blocks sending a low-risk prompt. The local CTM
  server derives the live relay instance when `/api/remote/messages` arrives, so
  the phone does not need to wait for `/api/remote/status` before posting a
  reply.

This makes status reads side-effect free while still allowing iOS foreground
recovery after sleep, radio changes, and background tab suspension.

## Hosted Relay Boundary

The hosted Wall-E Remote relay is currently a typed request/response channel. It
can safely carry commands such as `session.send_message` and
`session.cancel_stream`, but it is not yet the primary live status stream.

Do not enable `sessions.subscribe` in the relay protocol until the relay has a
real streaming path:

```
phone WebSocket -> relay -> CTM outbound WebSocket -> CTM SessionStateBus
```

That stream needs:

- per-device auth and step-up enforcement for mutations;
- typed subscription commands, not raw terminal control;
- backpressure and bounded ring buffers;
- resume cursor or last-event-id semantics;
- heartbeat, reconnect, and stale-state indicators;
- encryption of session IDs, prompt text, and command payloads before relay
  storage or forwarding.

Until that exists, the hosted phone UI should continue to show relay capability
honestly and use HTTP-style request/response for actions only.

## Security Rules

- The phone WebSocket uses the same-origin CTM auth and origin checks.
- Mobile terminal controls remain typed operations. The phone UI must never send
  arbitrary PTY bytes.
- Read subscriptions require read scope. Mutations still require the appropriate
  scope and step-up.
- Push notifications are for background attention only, not foreground state
  synchronization.

## Regression Coverage

`tests/rendering/scenarios/mobile-pwa.spec.js` covers:

- provider badges and normal phone flows;
- foreground reconnect preserving drafts;
- hot stream events rendering before slow HTTP history;
- live event dedupe/update after history hydration;
- WebSocket lifecycle status changing the Status tab without polling;
- deep-linked session composer and skill picker working before startup
  hydration finishes;
- cached mobile standup snapshots hydrating incrementally only when the cache is
  complete;
- detail sheet scroll locking and the plain-text composer editor contract.
