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).
This commit is contained in:
Yaojia Wang
2026-07-30 18:25:09 +02:00
parent 97e39949a8
commit f000a9d979
4 changed files with 119 additions and 1 deletions

View File

@@ -232,6 +232,14 @@ Client-side frame max message size raised to **16 MiB** (guard with a client-sid
`GET /sessions`, `POST /hook`, `POST /hook/permission`, `POST /hook/status`, `POST /open-in-editor`, `POST /projects/worktree`, `DELETE /live-sessions` (kill-all). Push web-only: `GET /push/vapid-key`, `POST/DELETE /push/subscribe` (Android uses FCM, not VAPID).
> **Amendment (2026-07-30).** This list is out of date on two entries — verified on disk, not assumed:
> - **`POST /projects/worktree`** is consumed by **both** clients now (`android/app/.../viewmodels/WorktreeViewModel.kt`, `ios/App/WebTerm/Screens/WorktreeSheet.swift`). Remove it from this list.
> - **`GET /sessions`** (`claude --resume` history) is consumed by **iOS only** (`ios/Packages/APIClient/Sources/APIClient/History.swift:71`); Android has zero references. So the section's premise — "match iOS" — now *argues for* building it on Android, rather than against. That is an open parity gap, not a prohibition.
>
> Both changes landed in the `ios-completion` wave — see [`plans/ios-completion.md`](./plans/ios-completion.md).
>
> **Planned scope expansion, not yet built:** the five `/orphan-sessions*` routes (feature **A**, shipped server-side 2026-07-30) are a *deliberate* future addition for both clients — plan in [`plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md). They are **not** listed in §4.2/§4.3 yet, on purpose: this doc must never claim a capability that does not exist. Move them there when the work lands, not before.
### 4.5 The ONE additive server change Android requires (P1 push)
APNs routes (`POST/DELETE /push/apns-token`) do not transfer to FCM. Add, mirroring `src/push/apns.ts` behind the existing `combineNotifyServices(...)` seam (zero new event paths — `notify(session, cls, token?)` already carries needs-input/done):

View File

@@ -598,6 +598,18 @@ W5 验收(report-only, 并行)
Android instrumented 腿(`TinkAccessTokenStoreTest` 是令牌静态加密的唯一证明需真机或模拟器CI 上暂设为
`workflow_dispatch` 触发——理由写在 `android.yml` 文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿)。
### 7.2 已排期未开工 —— 同样不新编 `T-iOS-*` 号
- **`[ ]` 孤儿 tmux 会话feature A 的客户端半边)** · **Effort: M** · 计划见
[`docs/plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md)。
服务端 2026-07-30 已上线五条路由(`4892fa7`/`f6ef19e`/`d39a0ab`**iOS 与 Android 均为零接入**。
**不是回归**`OrphanSessionInfo` 被刻意做成独立类型而非 `LiveSessionInfo` 上的 flag`src/server.ts:600`
写明理由就是"两个原生客户端解码 `/live-sessions`,在那里加变体形状会破坏它们"——既有解码器不受影响。
**但值得做**`SessionManager.shutdown` 对 tmux 会话只 `pty.kill()` 断开客户端、不杀 shell
`src/session/manager.ts:410-417`),所以**服务器一重启 / 桌面 app 一退出,所有会话都变成孤儿**。
网页 launcher 已能列出并一键重新接管,**手机上却是一个空列表**——正是本项目"走开后从手机上看"的核心用例。
开工前须按 §7 的 P2 门槛先扩写 RED 清单(写进该计划文档的 `## RED test list` 节),未扩写不得分派。
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
#### W0 · 基础(串行)

View File

@@ -152,7 +152,7 @@ Consciously punted in the planning docs; several become cheap once the two unloc
| Cross-session cost rollup / trends | `FEATURE_WALKAWAY_WORKBENCH.md §B2.6` | Bounded ring beside the latest-only field + `GET /telemetry/summary` + dashboard panel. Overlaps the budget-guard quick win. **M.** |
| Mission Control: cross-session feed + durable run log | (no cockpit persistence today) | Merged reverse-chron feed + append-only on-disk tail (reuse `subscription-store.ts` JSONL/atomic write, byte-capped) so overnight runs survive restart. The "while you were away" digest is its first slice. **ML.** |
| Per-project task backlog | task tracking unbuilt | Per-repo TODO store mirroring `prefs-store.ts`; one tap spawns Claude pre-filled. Local state, **not** a GitHub Issues sync (YAGNI). **M.** |
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | We already run as a tmux client on the user's default tmux server (sessions `web_<id>`), so `tmux ls` already sees manual/iTerm tmux sessions. Add a discovery list + "attach external tmux session" entry in the launcher → spawn a client PTY `tmux attach -t <name>`, wrapped in the session model. **SM.** |
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | **Half shipped 2026-07-30** as feature **A** (`4892fa7`/`f6ef19e`/`d39a0ab`): the server enumerates its own untracked `web_<id>` tmux sessions (`GET /orphan-sessions` + 4 routes) and the web launcher shows them in a "可恢复" section — one tap re-adopts via the existing `attach(id)` path. **Remaining (a) — iOS + Android consume it:** see [`plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md). **M.** **Remaining (b) — the original ask, still unbuilt:** *foreign* tmux sessions (manual/iTerm, not named `web_*`) are still invisible; feature A deliberately only catalogues our own. **SM.** |
---

View File

@@ -0,0 +1,98 @@
# 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_<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 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_<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`](../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** (~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`](./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)
<!-- empty until dispatched — see the dispatch gate above -->