Files
web-terminal/docs/plans/w7-orphan-session-clients.md
Yaojia Wang f000a9d979 docs(plan): schedule the orphan tmux session client parity work
The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and
both native clients are at zero. This is NOT a regression — OrphanSessionInfo is
deliberately a separate type rather than a flag on LiveSessionInfo, and
src/server.ts:600 names the reason: the clients decode /live-sessions, so a
variant shape there would have broken them.

It is worth planning anyway because SessionManager.shutdown only detaches the
tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart
or a desktop-app quit turns every session into an orphan. The web launcher lists
them and re-adopts on tap; the phone still shows an empty list — which is the
walk-away case this product exists for.

The plan doc carries the eleven traps a client implementer will hit, each one a
shipped review finding or a documented server subtlety — notably that preview
returns cols/rows of literally 0 (both clients currently size thumbnails from
that response), that a 429 must not collapse to "no orphans", and that the
destructive confirmation must come from count-idle rather than card count (that
one nearly ended 33 shells while the dialog said 24).

Also corrects two now-false claims found while cross-referencing: the ROADMAP
row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume
list still listed POST /projects/worktree (both clients consume it now) and
GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues
for building it, not against).
2026-07-30 18:25:09 +02:00

12 KiB
Raw Blame History

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/html302 → /login; otherwise 401 {"error":"authentication required"}. Both clients already froze the "Accept must not contain text/html" rule for POST /auth (ios-completion.md §1.1) — same trap, same fix.
// src/types.ts:339-350 — every field required on the wire
export interface OrphanSessionInfo {
  id: string;               // bare UUID v4; the tmux name is web_<id>
  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(<id>) on the ordinary WS — Case 3.5 (src/session/manager.ts:162-170) runs tmux new-session -A -s web_<id>, 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 offlistOrphans 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 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_<uuid> 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 §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 §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 (~34 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 (same shape — server ahead, client behind), ios-completion.md (frozen token contract, Origin-iff-G rule).

RED test list (appended by each task owner at start-of-work)