# Orphan tmux sessions on iOS + Android (client parity) > **Feature id:** `w7-orphan-session-clients` · **Branch:** `develop` · **Effort: M** · **Server changes:** ZERO (all five routes shipped 2026-07-30 as feature **A**). ## Why this exists (and why it is not a regression) The server half shipped on `develop` in three commits — `4892fa7` (feature), `f6ef19e` (review round 1, 10 findings), `d39a0ab` (round 2). Both native clients are at **zero**: `grep -rn "orphan-sessions" ios/ android/` returns nothing. **This is a new gap, not a regression.** `OrphanSessionInfo` is deliberately a *separate* type rather than a flag on `LiveSessionInfo`, and `src/server.ts:600` names the reason outright — the Android/iOS clients decode `/live-sessions`, so a variant shape there would have broken them. The existing decoders are untouched and remain correct. **Why it still matters.** `SessionManager.shutdown` calls `pty.kill()` for tmux-backed sessions (`src/session/manager.ts:410-417`) — that detaches the tmux *client*, it does not kill the shell. So **every session survives a server restart or a desktop-app quit as an untracked tmux session**. Before feature A those were unreachable forever. Today the web launcher shows them in a "可恢复" section and one tap re-adopts them; **the phone still shows an empty session list**. That is precisely the walk-away case this product exists for (`CLAUDE.md` → "What This Is"), which is why this is worth a plan rather than a backlog line. ## Contract (routes / types / semantics) All five routes live in `src/server.ts:592-670`, in their **own namespace** — not under `/live-sessions`. | Method / path | Origin | Rate-limited | Success | Failure | |---|---|---|---|---| | `GET /orphan-sessions` | **no** | yes | `200` bare `OrphanSessionInfo[]`, server-sorted most-recently-active first | `429` empty body. **Never 500** — tmux failures return `[]` (`src/session/tmux.ts:237-239`) | | `GET /orphan-sessions/count-idle?idleDays=N` | **no** | yes | `200 {"count": number}` | `400 {"error":"idleDays must be a number >= 1"}`, `429` | | `GET /orphan-sessions/:id/preview` | **no** | yes | `200 {id, cols: 0, rows: 0, data}` | `400 {"error":"invalid session id"}`, `404` empty, `429` | | `DELETE /orphan-sessions?idleDays=N` | **YES** | no | `200 {"killed": number, "ids": string[]}` | `403` empty, `400` | | `DELETE /orphan-sessions/:id` | **YES** | no | `204` empty | `403`, `404` empty, `400` | - **Rate limit**: `ORPHAN_PREVIEW_RATE_MAX = 240` per 60 s per IP, **one shared bucket across all three GETs** (`src/server.ts:98-100, 260`). A polling client previewing N cards burns N+1 tokens per tick — budget it explicitly. - **Access token**: ordinary remote routes, so `authGate` covers all five (`src/server.ts:432-474`). Unauthed with `Accept: text/html` ⇒ **302 → `/login`**; otherwise `401 {"error":"authentication required"}`. Both clients already froze the "`Accept` must not contain `text/html`" rule for `POST /auth` ([`ios-completion.md`](./ios-completion.md) §1.1) — same trap, same fix. ```ts // src/types.ts:339-350 — every field required on the wire export interface OrphanSessionInfo { id: string; // bare UUID v4; the tmux name is web_ createdAt: number; // epoch ms lastActivityAt: number; // tmux's OWN clock — survives server restarts attached: boolean; // tmux client count > 0 cols: number; rows: number; } ``` **Web reference implementation** — mirror it: `public/launcher.ts` (the section, cards, polling, cleanup flow, copy) and `public/preview-grid.ts` (shared card factory, the five fetch wrappers, `orphanAsLive`). ## The eleven things a client implementer will get wrong Each of these is either a shipped review finding or a documented server subtlety. They are the reason this is a plan and not a one-line ticket. 1. **"Join" is not a route.** Opening an orphan is `attach()` on the ordinary WS — Case 3.5 (`src/session/manager.ts:162-170`) runs `tmux new-session -A -s web_`, re-adopts the shell, and it becomes a normal `LiveSessionInfo`. No new protocol, no new endpoint. FE precedent: `public/launcher.ts:161-171`. 2. **Preview returns `cols: 0, rows: 0` literally** (`src/server.ts:645`) — the shape matches `/live-sessions/:id/preview` only so one FE code path renders both. Size the offscreen terminal from `OrphanSessionInfo.cols/rows`, never from the preview body. **This is the single most likely silent bug on both clients** (iOS `SessionThumbnailRenderer`, Android `ThumbnailPipeline` — both currently size from the preview response). 3. **Failure must never collapse to empty.** `[]` means "no orphans"; a 429/500/network error must leave the section untouched. One 429 rendering as "all your recovered sessions are gone" would tear down every mounted card. Web returns `null` for failure (`public/preview-grid.ts:216-231`); this was a MEDIUM review finding. 4. **`attached: true` ≠ "this server is streaming it."** It is tmux's own client count — a plain `tmux attach` or a second server process counts (`src/session/tmux.ts:64-70`). "Untracked here" does not mean "abandoned". Bulk cleanup skips attached sessions on purpose. 5. **Never count cards for the destructive confirmation.** The web grid caps at `MAX_ORPHAN_CARDS = 24` while the bulk DELETE matches everything the server enumerates — on the real host the dialog said "24" and would have ended 33 shells (HIGH finding, fixed in `f6ef19e`). `count-idle` exists solely to answer this; **if it fails, refuse to proceed** (`public/launcher.ts:245-256`). 6. **`lastActivityAt` is `max(window_activity, session_activity)`** (`src/session/tmux.ts:196-206`). Reading the client clock alone was the CRITICAL round-1 bug: it only advances on *client* activity, so a detached session printing output forever looks idle, and idle-cleanup would kill exactly the long-running unattended sessions this app exists for (3 of 69 real sessions were one click from deletion). Do not re-derive idleness from `createdAt` or from `lastOutputAt` semantics. 7. **Status is unknowable.** The web adapter maps to `status: 'unknown'`, `cwd: null`, `clientCount: attached ? 1 : 0`, `lastOutputAt: lastActivityAt` (`public/preview-grid.ts:256-277`), noting that claiming `'idle'` would assert something never looked at. Both clients' status decoders already map unknown → `.unknown`; **reuse it, do not invent an `orphan` status.** 8. **The whole surface is inert when `USE_TMUX` is off** — `listOrphans` returns `[]`, `mayActOnOrphan` returns false (`src/session/manager.ts:437, 449`). There is **no capability endpoint** (`GET /config/ui` exposes only `allowAutoMode`/`costBudgetUsd`), so an empty list is the only signal — hide the section, never error. Same discovery hole [`w5-android-parity.md`](./w5-android-parity.md) documents for `worktreeEnabled`. 9. **Preview once per card, latched on success** — each call spawns a `tmux capture-pane` (~58 ms measured). Latch on "preview succeeded", not "card created", or a card that loses its one request stays blank forever (`public/launcher.ts:213-227`). 10. **Origin-iff-guarded, both directions.** The three GETs must **not** carry `Origin`; both DELETEs must carry it byte-equal. Both clients already enforce this structurally (iOS `Endpoints.swift:81`, Android `ApiRoute.toHttpRequest`) — a route-shape test asserting **both** directions is mandatory, matching the existing `GitRouteShapeTest` / `gitOpsRoute` split. 11. **404 is overloaded three ways** on preview and single-delete: tmux off / id not a `web_*` session / the id is already tracked live. The client cannot distinguish them — surface it inertly, never as "session died". Ids are UUID v4 (`SESSION_ID_RE`); pre-validate with the existing `Validation` before any request. ## Files to change | Path | Concrete change | |---|---| | `ios/Packages/APIClient/Sources/APIClient/**` | `OrphanSession.swift`: model + the five route builders (3 RO, 2 G) + `count-idle`; lossy decode; reuse `pathId`/`percentEncode` choke points | | `ios/App/WebTerm/Screens/SessionListScreen.swift` + a new section view | The "可恢复" section, hidden when the list is empty; tap → `attach(id)` through the existing controller | | `ios/App/WebTerm/Components/SessionThumbnail.swift` | Accept an explicit size rather than reading `cols/rows` from the preview body (see trap #2) | | `android/api-client/src/main/kotlin/.../{models,routes}` | `OrphanSession.kt` + the five routes on `Endpoints.kt`; all five `ACCESS_TOKEN_GATE` (no route-defined 401 exists here) | | `android/app/src/main/java/.../{viewmodels,screens}` | Same section + adopt wiring; same thumbnail sizing fix in `ThumbnailPipeline` | | `docs/ANDROID_CLIENT_PLAN.md` §4.2/§4.3 | Move `/orphan-sessions*` out of §4.4's do-not-consume list into the consumed tables **when the work lands**, not before | | `docs/PLAN_IOS_CLIENT.md` §7.1 | Append the delivered capability (no new `T-iOS-*` id — §7.1's stated rule) | | `docs/PROGRESS_LOG.md` | Orchestrator appends the entry (not the builder) | ## TDD steps (ordered) Both clients are independent lanes after the contract is frozen; within a lane, API layer before UI. **Phase A — API layer (parallel: iOS ∥ Android).** RED the route shapes first: Origin-iff-guarded in both directions; `count-idle` query encoding and the `400` mapping; the `429`-is-not-empty rule (#3); `cols/rows: 0` decoded but *not* used for sizing (#2); UUID pre-validation. **Phase B — presentation logic.** Sorting is server-side — assert the client preserves order and does not re-sort by `createdAt` (#6). Status maps to `.unknown` (#7). Empty list hides the section (#8). **Phase C — destructive flow.** `count-idle` drives the confirmation number; a failed `count-idle` blocks the action (#5); attached sessions are never offered for bulk cleanup (#4). **Phase D — adopt.** Tapping an orphan goes through the existing `attach(id)` path and the card transitions to a live session (#1); no new endpoint is introduced. **Phase E — end-to-end.** iOS `IntegrationTests` has a real-tmux-capable harness already; add a leg that creates a `web_` tmux session, sees it listed, previews it, adopts it, and kills it. Mirror the discipline of `test/integration/orphan-sessions.test.ts`, which **deliberately never issues the bulk DELETE** because it runs against the developer's real tmux server (`:7-11`) — the client leg must be equally careful. > **Dispatch gate (house rule, [`PLAN_IOS_CLIENT.md`](../PLAN_IOS_CLIENT.md) §7):** the phases above are dispatch metadata only. The full RED test list is expanded by each task owner at start-of-work into `## RED test list` below, **before** any implementation. Do not dispatch from the phase descriptions alone. ## Security - Origin-iff-guarded on all five, asserted both directions (#10). The token is additive and never a substitute — same rule as [`ios-completion.md`](./ios-completion.md) §1.1. - `preview.data` is raw `capture-pane -p -e` output — **attacker-influenced bytes with escape sequences preserved**. Render it only through the existing offscreen-terminal path that already treats `/live-sessions/:id/preview` as untrusted; never into a text view, never into a log. - The destructive confirmation must state a **true** number (#5). A dialog that undercounts is a data-loss bug, not a UX nit — it already nearly was one. - `Accept` must not contain `text/html`, or the auth gate answers with a login page a JSON decoder will mis-parse. ## Effort & dependencies **Effort: M** (~3–4 days across both clients). Zero server change. No external dependency. Depends on nothing in flight; the API layers of both clients are stable after `ios-completion`. Related: [`w5-android-parity.md`](./w5-android-parity.md) (same shape — server ahead, client behind), [`ios-completion.md`](./ios-completion.md) (frozen token contract, Origin-iff-G rule). ## RED test list (appended by each task owner at start-of-work)