Compare commits

...

7 Commits

Author SHA1 Message Date
jay e5dd64a1f7 docs: T-2.10 background lifecycle done; update next-steps 2026-05-17 13:25:09 +02:00
jay 0882ef8038 docs: bring NEXT-STEPS + SYNC up to current state (2026-05-16)
Phase 2 reality vs plan:
- Done: T-2.0..T-2.6, T-2.8, T-2.9, T-2.11
- Bonus done: XCUITest target + 8/8 tests, idb automation guide
- Open: T-2.7, T-2.10, T-2.12, T-2.13
- Bug backlog: 4 Keychain entitlements failures + 4 Pairing http-scheme tests

Also documented conventions: async subagent dispatch, reviewer fan-in,
--uitest mode + idb workflow, sidecar manual restart pattern.

Replaced obsolete Phase-1 orchestration instructions with current
priorities.
2026-05-16 22:20:55 +02:00
jay 2c627ea095 Merge fix/post-sessions-response-shape: POST /sessions returns full shape 2026-05-16 22:18:54 +02:00
jay df735aa279 fix(sidecar): POST /sessions response now matches GET shape (id+name+state+lastOutputAt)
Previously POST returned only { id, name }, while GET returns
{ id, name, state, lastOutputAt }. iOS clients that share a Decodable
for both endpoints (e.g. SessionItem in pi-remote-ios) failed to decode
the POST response with 'data couldn't be read because it is missing'.

The new session always starts in 'idle' state with empty lastOutputAt.
Documented the new shape in the route header comment.
2026-05-16 22:07:54 +02:00
jay 964226847b Merge fix/ws-input-handler: WS handler processes keys/key/paste 2026-05-16 12:07:53 +02:00
jay a7c7b8f3d7 fix(sidecar): WS stream handler — process keys/key/paste messages 2026-05-16 12:07:16 +02:00
jay 0604fd7c03 Merge feat/sidecar-pair-smoke: POST /pair smoke test 2026-05-16 11:54:33 +02:00
5 changed files with 145 additions and 94 deletions

View File

@ -1,8 +1,8 @@
# Next Steps — Resume Pointer
> **Last updated:** 2026-05-15.
> **Where we are:** Phase 2 in progress. T-2.0..T-2.5 + T-2.9 done. App boots on iPhone (iOS 26, wireless).
> **Where we go next:** T-2.6 SessionSwitcher, T-2.8 StatusBar, T-2.11 Face-ID. Then T-2.7 PreConnectPool, T-2.10 Background lifecycle, T-2.12 TestFlight.
> **Last updated:** 2026-05-17.
> **Where we are:** Phase 2 mostly done. T-2.0..T-2.6, T-2.8, T-2.9, T-2.10, T-2.11 on main. App runs end-to-end on iPhone 12 mini + simulator. 130 unit tests (8 pre-existing failures), 12/12 UI tests green.
> **Where we go next:** T-2.7 PreConnectPool, T-2.12 TestFlight, T-2.13 MVP smoke. Plus: fix 8 pre-existing unit-test failures (4 Keychain entitlements + 4 Pairing http-scheme).
This document is the "where did I leave off" anchor. Read this first when
resuming work. The rest of `docs/` is reference.
@ -14,90 +14,64 @@ resuming work. The rest of `docs/` is reference.
| Item | Status |
|---|---|
| Phase 0 — Stream Spike | ✅ done. Verdict GREEN with caveat (pipe-pane unreliable). Branch `feat/spike-stream` kept. |
| Phase 0.5 — Control-Mode Spike | ✅ done. Verdict: **Path B — tmux control mode**. Branch `feat/spike-tmux-cc` kept. |
| Phase 1 plan | ✅ updated to Path B. T-1.1 now specifies control mode + `%output` parser. Architecture diagram, risks (R4 + R5) added. |
| Interface Contracts (IC-1..IC-4) | ✅ **frozen** 2026-05-15. See SYNC.md. |
| Phase 0.5 — Control-Mode Spike | ✅ done. Verdict: **Path B — tmux control mode**. |
| Phase 1 plan | ✅ updated to Path B. |
| Interface Contracts (IC-1..IC-4) | ✅ **frozen** 2026-05-15. |
| Phase 1 implementation | ✅ **done** 2026-05-15. All T-1.0..T-1.10 on main. Smoke 12/12. |
| Phase 2 iOS app — scaffold | ✅ T-2.0 done. pi-remote-ios repo, Xcode project, SwiftTerm+Starscream. |
| Phase 2 iOS app — core layers | ✅ T-2.1..T-2.5, T-2.9 done. WebSocket, Pairing, Terminal, ModifierBar, SessionConnection, APNs. 111 unit tests. |
| Phase 2 iOS app — UI + lifecycle | ⛔ T-2.6 SessionSwitcher, T-2.7 PreConnect, T-2.8 StatusBar, T-2.10 Background, T-2.11 Face-ID, T-2.12 TestFlight. |
| iOS work | blocked, untouched. |
| Phase 1 — follow-up fixes | ✅ POST /pair smoke test, WS keys/key/paste handler, POST /sessions response shape. |
| Phase 2 iOS — T-2.0 scaffold | ✅ done. |
| Phase 2 iOS — T-2.1..T-2.5, T-2.9 core | ✅ done. WebSocket, Pairing, Terminal, ModifierBar, SessionConnection, APNs. |
| Phase 2 iOS — T-2.6 SessionSwitcher | ✅ **done** 2026-05-16. SessionRegistry + SessionSwitcher + SessionRow. |
| Phase 2 iOS — T-2.7 PreConnectPool | ⛔ not started. |
| Phase 2 iOS — T-2.8 StatusBar | ✅ **done** 2026-05-16. State indicator, action buttons. |
| Phase 2 iOS — T-2.10 Background lifecycle | ✅ **done** 2026-05-17. AppState.lifecycleTransitions publisher, SessionConnection suspend/resume w/ ResumeCursor, stale-frame freeze, post-Face-ID reconnect. 22 lifecycle tests + 6 follow-up. TDD pattern: tests → impl → review → fixup (B-1 + 3 nits + 4 coverage gaps). |
| Phase 2 iOS — T-2.11 Face-ID + Settings | ✅ **done** 2026-05-16. SettingsView, FaceIDGate, LockView. |
| Phase 2 iOS — T-2.12 TestFlight | ⛔ not started. Needs Apple credentials. |
| Phase 2 iOS — T-2.13 MVP smoke | ⛔ not started. |
| Phase 2 iOS — XCUITest infrastructure (bonus) | ✅ **done** 2026-05-16. piRemoteUITests target, 8/8 tests, `--uitest` mode, accessibility IDs, idb workflow documented in `docs/SIMULATOR-AUTOMATION.md` (iOS repo). |
| Bug Backlog | ⚠️ 8 pre-existing unit-test failures (4 Keychain `-34018` entitlements + 4 Pairing http-scheme tests). Not regressions, undocumented. |
Branches on remote `git.vpsj.de/jay/pi-remote-control`:
- `main` — has all docs, no implementation changes yet.
- `main`all Phase 1 + follow-up fixes (latest: POST /sessions shape).
- `feat/spike-stream` — Phase 0 PoC, throwaway.
- `feat/spike-tmux-cc` — Phase 0.5 PoC, throwaway. Reference impl for T-1.1.
Branches on remote `git.vpsj.de/jay/pi-remote-ios`:
- `main` — T-2.0..T-2.6, T-2.8, T-2.9, T-2.11 + UI test target + idb docs.
- legacy `feat/p2-t2.*` branches kept for history; all already on main via merge commits.
---
## Orchestrator todo list (in order)
## Open work (in priority order)
These are things only the orchestrator does — not delegatable to a worker
subagent.
### Phase 2 remaining
### 1. Freeze interface contracts
- **T-2.7 PreConnectPool.** Hot WS per known session + cached last frame;
switching shows the cached frame instantly with a "syncing…" pill.
Touches: `Sources/Core/Sessions/PreConnectPool.swift` (new),
`Sources/Core/Sessions/SessionConnection.swift`. Now safe to schedule
after T-2.10's lifecycle hooks landed.
- **T-2.12 TestFlight pipeline.** Build / archive / upload scripts +
internal testers list. Verify production APNs path. Needs Apple
Developer credentials (paid program). Touches: `docs/DISTRIBUTION.md`,
`scripts/` or Fastfile.
- **T-2.13 MVP smoke.** Manual on-device checklist: pair → render → input
→ backgrounded → push → reopen <1s session-switch. Documents in
`docs/PHASE-2-report.md` (iOS repo).
Open `PHASE-1-sidecar.md` §Interface Contracts and re-read IC-1..IC-4.
For each: does it still look right after the spikes? Anything to tweak?
### Bug backlog (not regressions, pre-existing)
Things to double-check:
- **IC-1 ClientToServer**: do we want to add `{ type:"resize"; cols:int; rows:int }` so iPad-Landscape can renegotiate tmux pane size? (Spec said fixed 120×40, but if we ever want elastic, this is the place.) Recommendation: defer, fix at 120×40 for v1, revisit only if it bites.
- **IC-1 ServerToClient**: `tree` event — gruppe T is out of iOS scope, so this can be dropped from the contract OR kept as "reserved, server may emit but client ignores". Recommendation: drop to keep contract tight.
- **IC-2 REST shape**: `/sessions/:id/thumbnail` is referenced by iOS-D-01c (Phase 3). Should the endpoint return raw text/plain capture, or a structured JSON `{cols,rows,lines:[…]}`? Recommendation: raw text/plain — simpler, smaller, client parses lines itself.
- **IC-3 pairing**: `deviceToken` and `environment` are now mandatory in Phase 2. Pre-Phase-2 they're optional. Mark accordingly.
- **IC-4 TOML config**: `[apns]` section can have an `environment_default` for testing convenience? Probably no — environment is per-device. Leave as-is.
- **Keychain unit tests fail with `-34018` errSecMissingEntitlement** (4 cases).
Likely needs Keychain-Access-Group in the test target's entitlements,
or running under signed bundle. Investigate or skip with a documented reason.
- **Pairing unit tests `testParseQR_{http,https,wrongScheme}_throws` fail**
(4 cases). Either the parser silently accepts http/https now (regression
hidden by the `fp` optional change) or the tests need updating to match
the new lenient behaviour.
After review:
- Edit `SYNC.md` "Frozen Interface Contracts" table: set Status `frozen` and fill `Frozen at` date.
- Commit on main.
### Phase 3 — not yet in scope
### 2. Dispatch T-1.0 — Server Refactor
Single agent, blocking everyone else. Refactor existing
`extensions/remote-control/server.ts` (335 lines) and friends into the
modular layout from `PHASE-1-sidecar.md` §Architecture Sketch.
Prompt template (adapt with concrete contract numbers):
```
# Task: Phase 1 T-1.0 — Server Refactor
Working dir: /Users/jay/.pi/agent/git/git.vpsj.de/jay/pi-remote-control
Read first:
- docs/NEXT-STEPS.md (state of play)
- docs/PHASE-1-sidecar.md §Architecture Sketch and §Task Breakdown row T-1.0
- docs/SYNC.md (claim the task)
- existing code: extensions/remote-control/{index,server,html,messages,auth,config}.ts
Goal:
- Carve existing server.ts into the server/ + server/routes/ + server/upgrade.ts
structure from the phase doc.
- Keep the LEGACY HTML client (html.ts) working end-to-end. Add comments
tagging these legacy paths.
- No new features. No new endpoints. Just structural refactor + ensure
existing tests / smoke usage still works.
- After your refactor lands, the directory layout matches the Phase-1
Architecture Sketch.
Out of scope: anything touching tmux/, buffer/, pi/, auth/{pairing,tls}.ts,
apns/, cli/. Those are for later T-1.* tasks.
Branch: feat/p1-t1-0-server-refactor
```
After T-1.0 merges into main, the parallel fan-out becomes available.
### 3. Plan the parallel fan-out
Once T-1.0 is in main:
- 5 worker dispatches in parallel: T-1.1, T-1.2, T-1.3, T-1.4, T-1.10.
- Each gets a tight prompt referencing their row in `PHASE-1-sidecar.md`
and the relevant frozen ICs.
- T-1.1 explicitly says: use control mode, reference `spike-cc.ts` on
`feat/spike-tmux-cc`, follow R-CC-1..R-CC-5 expectations.
T-1.5, T-1.6, T-1.7 follow as their deps come in.
Slash palette, voice, themes, search, etc. See `docs/PHASE-3-ios-augmentation.md`.
---
@ -105,26 +79,18 @@ T-1.5, T-1.6, T-1.7 follow as their deps come in.
1. `docs/NEXT-STEPS.md` (this file)
2. `docs/SYNC.md` — current claims, gate, contracts
3. `docs/PHASE-1-sidecar.md` — what to build
4. `docs/reference/PHASE-0-report.md` and `PHASE-0.5-report.md` — why we're
building it this way
5. Spec only if you forget the why: `docs/reference/SPEC-ios-app.md`
3. `docs/PHASE-2-ios-mvp.md` — what's left to build for iOS
4. iOS repo: `docs/SIMULATOR-AUTOMATION.md` — how to drive the sim
5. iOS repo: `docs/BUILD.md` — build + install + launch commands
6. Spec only if you forget the why: `docs/reference/SPEC-ios-app.md`
---
## Open questions for next session
## Conventions established
- ~~**OQ-1.** Drop the `tree` event from IC-1 ServerToClient~~**resolved**: dropped. IC-1 frozen 2026-05-15.
- ~~**OQ-2.** Resize message in IC-1?~~**resolved**: deferred, fixed 120×40 for v1.
- ~~**OQ-3.** tmux control-mode connection per-server vs per-session?~~**resolved**: per-session (like the spike). Simpler, spike reference code exists, isolates parser state per session. Refactor to per-server later if scale demands it.
- ~~**OQ-4.** Worker model for T-1.0?~~**resolved**: `anthropic/claude-sonnet-4-6` with `context: fresh`. Haiku rejected: previous swarm attempt produced broken imports across `index.ts`/`html.ts`/`messages.ts` even on a stronger model (see deleted `feat/p1-t1-0-server-refactor` branch). T-1.0 is the blocker for all parallel fan-out — reliability over cost.
---
## Don't do tomorrow
- Don't merge the spike branches into main. They're reference, not code.
- Don't start T-1.1 before T-1.0 is in main.
- Don't start any iOS task until Phase 1 is at least feature-complete.
- **Async subagent dispatch.** All worker tasks via `subagent({ async: true, context: "fresh" })`. Worktree isolation for parallel-on-same-repo work; same-repo iOS+sidecar splits are inherently isolated.
- **Reviewer fan-in.** After parallel implementation, dispatch one reviewer agent with all branch summaries pre-loaded; reviewer writes `review.md` for the orchestrator to apply.
- **Sim test infra (`--uitest` mode).** Pre-fetch a fresh `/pair-qr` token per test, launch with `--reset-state --pair-with-url <url>`. `MainTerminalView` in `--uitest` mode skips the WS so XCUITest can reach app-idle within the 120 s window. Pasteboard prompt suppressed via `xcrun simctl privacy grant pasteboard de.vpsj.pi-remote`.
- **Sidecar manual restart pattern.** `tmux kill-session -t pi-sidecar; tmux new-session -d -s pi-sidecar -x 220 -y 50 "pi -nt -ne -ns -np -nc --no-session --offline -e extensions/remote-control --remote-control"`. Pre-fill 3 sessions (`main`, `work`, `logs`) for any manual E2E test.
- Don't expand scope. Stick to v3 spec; new ideas go into a v4 review
round, not into open PRs.

View File

@ -38,8 +38,8 @@ The point: no central scheduler is required. A short structured edit on
|---|---|---|
| Phase 0 — Spike Stream | done | ✅ GREEN LIGHT with caveat: pipe-pane unreliable. See `reference/PHASE-0-report.md`. |
| Phase 0.5 — Spike tmux Control Mode | done | ✅ VERDICT: Path B (control mode) recommended. See `reference/PHASE-0.5-report.md`. |
| Phase 1 — Sidecar | **done** | All T-1.0..T-1.10 implemented. Smoke 12/12 green. |
| Phase 2 — iOS MVP | **in progress** | T-2.0..T-2.5, T-2.9 done. App runs on device (iOS 26, wireless). Repo: `pi-remote-ios`. |
| Phase 1 — Sidecar | **done** | All T-1.0..T-1.10 implemented. Smoke 22/22 green (incl. POST /pair). Follow-up fixes on main: WS keys handler, POST /sessions shape, control-mode encoding. |
| Phase 2 — iOS MVP | **in progress** | T-2.0..T-2.6, T-2.8, T-2.9, T-2.10, T-2.11 on main. Bonus: XCUITest target + idb automation docs. Open: T-2.7, T-2.12, T-2.13. Repo: `pi-remote-ios`. |
| Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. |
Update the **Status** column when a phase transitions. Allowed states:

View File

@ -1,7 +1,7 @@
/**
* S-09 multi-session CRUD routes.
*
* POST /sessions { id, name }
* POST /sessions { id, name, state, lastOutputAt }
* GET /sessions [{ id, name, description, state, lastOutputAt }]
* PATCH /sessions/:id updates @description
* DELETE /sessions/:id kills tmux session, optionally clears buffer
@ -110,7 +110,9 @@ async function handleCreate(
try {
const id = await spawnSession({ name });
sendJson(res, 201, { id, name });
// Include state + lastOutputAt to match the GET /sessions response shape
// so iOS clients can decode the response with the same type.
sendJson(res, 201, { id, name, state: "idle", lastOutputAt: "" });
} catch (err) {
sendJson(res, 500, { error: "internal_error", message: String(err) });
}

View File

@ -22,6 +22,7 @@ import { readChunks } from "../../buffer/reader.js";
import type { StateEvent } from "../../pi/events.js";
import { SequenceCounter } from "../../sequence.js";
import { ControlClient } from "../../tmux/control.js";
import { sendKey, sendKeys, sendPaste } from "../../tmux/input.js";
import { resizeSession } from "../../tmux/manager.js";
import { capturePane } from "../../tmux/snapshot.js";
import type { WsClient, WsServer } from "../types.js";
@ -162,6 +163,54 @@ function handleStreamConnection(
const cols = typeof m.cols === "number" ? m.cols : 80;
const rows = typeof m.rows === "number" ? m.rows : 24;
resizeSession(sessionId, cols, rows).catch(() => {});
} else if (m.type === "keys") {
if (typeof m.data !== "string") {
sendJson(ws, {
type: "error",
code: "bad_input",
message: "keys.data must be a string",
});
return;
}
sendKeys(sessionId, m.data).catch((err) => {
sendJson(ws, {
type: "error",
code: "bad_input",
message: `Failed to send keys: ${String(err)}`,
});
});
} else if (m.type === "key") {
if (typeof m.name !== "string") {
sendJson(ws, {
type: "error",
code: "bad_input",
message: "key.name must be a string",
});
return;
}
sendKey(sessionId, m.name).catch((err) => {
sendJson(ws, {
type: "error",
code: "bad_input",
message: `Failed to send key: ${String(err)}`,
});
});
} else if (m.type === "paste") {
if (typeof m.data !== "string") {
sendJson(ws, {
type: "error",
code: "bad_input",
message: "paste.data must be a string",
});
return;
}
sendPaste(sessionId, m.data).catch((err) => {
sendJson(ws, {
type: "error",
code: "bad_input",
message: `Failed to send paste: ${String(err)}`,
});
});
} else if (m.type === "snapshot-request") {
capturePane({ session: sessionId, escapes: true })
.then((text) => {

View File

@ -173,6 +173,40 @@ describe("T-1.8 stream integration", () => {
assert.ok(found, `Should observe marker "${marker}" in stream output`);
});
it("WS stream: send keys via WS, observe in stream output", async () => {
const ws = await openWebSocket(
`${WS_BASE}/sessions/${sessionId}/stream${AUTH}`,
);
ws.send(JSON.stringify({ type: "resume", lastSeq: null }));
const marker = `ws-smoke-${Date.now()}`;
// Wait a tick to let resume complete, then send keys via WS
await new Promise((r) => setTimeout(r, 200));
ws.send(JSON.stringify({ type: "keys", data: `echo ${marker}` }));
ws.send(JSON.stringify({ type: "key", name: "enter" }));
const found = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(false), 6000);
let accumulated = "";
ws.on("message", (data) => {
if (Buffer.isBuffer(data) && data.length > 8) {
accumulated += data.slice(8).toString("utf8");
if (accumulated.includes(marker)) {
clearTimeout(timeout);
resolve(true);
}
}
});
});
await closeWebSocket(ws);
assert.ok(
found,
`Should observe marker "${marker}" sent via WS keys message in stream output`,
);
});
it("WS stream: reconnect with lastSeq → receives only delta", async () => {
// First pass: collect frames and note highest seq
const ws1 = await openWebSocket(