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).
12 KiB
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 = 240per 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
authGatecovers all five (src/server.ts:432-474). Unauthed withAccept: text/html⇒ 302 →/login; otherwise401 {"error":"authentication required"}. Both clients already froze the "Acceptmust not containtext/html" rule forPOST /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.
- "Join" is not a route. Opening an orphan is
attach(<id>)on the ordinary WS — Case 3.5 (src/session/manager.ts:162-170) runstmux new-session -A -s web_<id>, re-adopts the shell, and it becomes a normalLiveSessionInfo. No new protocol, no new endpoint. FE precedent:public/launcher.ts:161-171. - Preview returns
cols: 0, rows: 0literally (src/server.ts:645) — the shape matches/live-sessions/:id/previewonly so one FE code path renders both. Size the offscreen terminal fromOrphanSessionInfo.cols/rows, never from the preview body. This is the single most likely silent bug on both clients (iOSSessionThumbnailRenderer, AndroidThumbnailPipeline— both currently size from the preview response). - 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 returnsnullfor failure (public/preview-grid.ts:216-231); this was a MEDIUM review finding. attached: true≠ "this server is streaming it." It is tmux's own client count — a plaintmux attachor a second server process counts (src/session/tmux.ts:64-70). "Untracked here" does not mean "abandoned". Bulk cleanup skips attached sessions on purpose.- Never count cards for the destructive confirmation. The web grid caps at
MAX_ORPHAN_CARDS = 24while 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 inf6ef19e).count-idleexists solely to answer this; if it fails, refuse to proceed (public/launcher.ts:245-256). lastActivityAtismax(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 fromcreatedAtor fromlastOutputAtsemantics.- 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 anorphanstatus. - The whole surface is inert when
USE_TMUXis off —listOrphansreturns[],mayActOnOrphanreturns false (src/session/manager.ts:437, 449). There is no capability endpoint (GET /config/uiexposes onlyallowAutoMode/costBudgetUsd), so an empty list is the only signal — hide the section, never error. Same discovery holew5-android-parity.mddocuments forworktreeEnabled. - 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). - 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 (iOSEndpoints.swift:81, AndroidApiRoute.toHttpRequest) — a route-shape test asserting both directions is mandatory, matching the existingGitRouteShapeTest/gitOpsRoutesplit. - 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 existingValidationbefore 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 listbelow, 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.datais rawcapture-pane -p -eoutput — attacker-influenced bytes with escape sequences preserved. Render it only through the existing offscreen-terminal path that already treats/live-sessions/:id/previewas 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.
Acceptmust not containtext/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 (same shape — server ahead, client behind), ios-completion.md (frozen token contract, Origin-iff-G rule).