Compare commits

..

5 Commits

Author SHA1 Message Date
jay f6cbf17078 docs: reorganise — implementation plans + sync, archive spec to reference/
- docs/ now holds only implementation drivers:
  - PHASE-0-spike-stream.md (single agent, ~1 day PoC)
  - PHASE-1-sidecar.md (multi-agent, sidecar production-ready)
  - PHASE-2-ios-mvp.md (multi-agent, iOS app MVP)
  - PHASE-3-ios-augmentation.md (multi-agent, iOS polish)
  - SYNC.md (live coordination: claims, ownership, CCRs)
  - README.md (folder guide)

- docs/reference/ holds background:
  - SPEC-ios-app.md (final v3 spec)
  - EXTENSION-API-AUDIT.md (audit result)
  - SPEC-ios-app-review-v1.md (archived review thread)
  - ARCHITECTURE.md (original extension architecture)
  - README.md (folder guide)

Each phase plan defines: goal, acceptance criteria, file layout,
explicit task table with parallelisable tasks, interface contracts,
branching strategy, risks, exit criteria.

SYNC.md provides the multi-agent coordination protocol: active claims
table, file ownership map, frozen contracts, and contract change
requests (CCR) workflow.
2026-05-15 03:33:51 +02:00
jay 0f946d56ea docs: spec v3 — close Q-A (pi -p flags) and Q-C (APNs details)
- S-09a: concrete pi CLI invocation verified, ~2s with haiku-4-5
- iOS-C-02: full APNs setup, .p8 key, sandbox/production routing
  per device-token (Xcode-debug → sandbox, TestFlight → production),
  collapse-id, JWT caching, auto-cleanup on 410
2026-05-15 01:48:23 +02:00
jay cf61b2ba1b docs: spec v3 — drop Tree-Nav from iOS, audit closed
- Audit confirmed S-07, S-08 work out-of-the-box → upgraded from PENDING to firm SHOULD
- Tree-Write blocked by ExtensionAPI; Gruppe T removed entirely
- Tree-Navigation explicitly out of scope (native pi only)
- Spike-0a closed, EXTENSION-API-AUDIT.md is referenced artifact
2026-05-15 01:35:20 +02:00
jay 36938a66c4 docs: spec v2 — incorporate review feedback, archive v1 thread 2026-05-15 01:20:17 +02:00
jay 7c0e94cb57 docs: add iOS app spec with inline review thread 2026-05-15 01:09:20 +02:00
11 changed files with 3117 additions and 0 deletions

View File

@ -0,0 +1,128 @@
# Phase 0 — Spike: tmux Stream PoC
> **Status:** ready to start.
> **Owner:** single agent, end-to-end (too small to parallelise).
> **Branch:** `feat/spike-stream`.
> **Estimated effort:** ~1 day.
## Goal
Verify the foundational assumption of the entire spec: that we can run `pi`
inside `tmux`, tee the pane output via `pipe-pane`, push it as a binary
WebSocket stream, and consume it from a client without rendering artefacts
or unacceptable latency.
Output is a decision: green light for Phase 1, or list of blockers that need
spec revision.
## Acceptance Criteria
- A new branch `feat/spike-stream` in `pi-remote-control`.
- A CLI invocation (e.g. `pi-remote spike`) that:
- Spawns a tmux session `pi-spike` running `pi`.
- Pipes the pane via `pipe-pane` to a WS endpoint on `ws://127.0.0.1:7799/spike`.
- Attaches the local terminal to the same tmux session.
- A test client (raw `wscat` script or a tiny throwaway HTML page) that
connects, dumps incoming binary frames to stdout (or a hex viewer) and
optionally re-renders them via `xterm.js`.
- A written PoC report `docs/reference/PHASE-0-report.md` answering:
- **R-1.** Does pi run cleanly inside tmux? (Ink redraws OK, no escape
sequence loss, no crashes during 10min uptime.)
- **R-2.** Does alternate-screen-buffer (`\e[?1049h`) work? Is the stream
parseable on the other side?
- **R-3.** Is per-chunk latency acceptable (< 50ms localhost,
< 200ms WAN)?
- **R-4.** Does the SSH session attached to the same tmux pane stay in
sync with the WS stream byte-for-byte?
- **R-5.** Edge cases observed (mouse mode, title sequences, very wide
output, etc.).
## Out of Scope for Spike-0
- No authentication, no TLS — bind to 127.0.0.1 only.
- No reconnect, sequence numbers, snapshot or buffer.
- No `send-keys` direction (read-only stream is enough to verify rendering).
- No multi-session — one fixed `pi-spike` session.
- No iOS code.
## Task Breakdown
### T-0.1 — Branch + skeleton
Create `feat/spike-stream`. Add a new file
`extensions/remote-control/spike.ts` and a CLI entry (a new flag
`--spike` on the existing extension or a separate npm-script —
whichever is faster).
### T-0.2 — tmux helper
Spawn tmux session, attach pipe-pane to a Unix FIFO or a pseudo-stream we
can read from Node. Reference command:
```bash
tmux new-session -d -s pi-spike 'pi'
mkfifo /tmp/pi-spike.fifo
tmux pipe-pane -t pi-spike -o "cat > /tmp/pi-spike.fifo"
```
Node opens the FIFO read-side (`fs.createReadStream`) and exposes the byte
stream.
### T-0.3 — WS server
Stand up a minimal `ws` server on port 7799, route `/spike`, send the FIFO
bytes as binary frames. No backpressure handling, no permessage-deflate yet.
### T-0.4 — Test client
Two options, pick whichever is faster:
- **(a)** `wscat -b ws://127.0.0.1:7799/spike` and pipe through `od -c` for
raw inspection.
- **(b)** A 50-line HTML page with `xterm.js`, plain WebSocket, no styling.
### T-0.5 — Attach + dual-render test
Open a second terminal, run `tmux attach -t pi-spike`. Type into pi. Verify
that what you see in the SSH attach is identical to what arrives on the WS
client.
### T-0.6 — Stress / edge cases
Briefly try:
- Resize the SSH terminal — see how tmux/pi react.
- Run a slash command that opens a full-screen menu (alternate screen).
- Paste a multi-line block.
- Let pi do a long tool call.
### T-0.7 — Report
Write `docs/reference/PHASE-0-report.md`. One paragraph per R-question,
plus a "go / no-go for Phase 1" verdict.
## File Plan
- New: `extensions/remote-control/spike.ts`
- New: `docs/reference/PHASE-0-report.md`
- Modified: `extensions/remote-control/index.ts` (add `--spike` flag or
separate entry).
- No changes to existing server.ts / html.ts / messages.ts.
## Dependencies
- `tmux` installed on the dev host. macOS: already present or
`brew install tmux`.
- `ws` library: already in `package.json`.
- `mkfifo` shell command (POSIX): already present on macOS/Linux.
## Risks
- **R-A.** Ink may refuse to run inside tmux due to TTY detection. If so,
set `FORCE_COLOR=1`, `TERM=xterm-256color`, pass `-tt` to tmux. Fall back
to spawning pi via `unbuffer` if necessary.
- **R-B.** FIFO can have buffering issues. If line buffering causes
visible lag, switch to a Unix domain socket and have Node read directly
from the socket.
- **R-C.** tmux's `pipe-pane` reproduces ANSI but may drop sequences during
bursts. If lossy, the alternative is to run pi inside our own `node-pty`
(a much larger change, but a fallback option).
## Exit / Handover
When Phase 0 closes:
- Merge `feat/spike-stream` into `main` only if PoC code is reusable for
Phase 1; otherwise close the branch and keep the report.
- Update `SYNC.md` with the verdict and any spec revisions needed.
- Trigger Phase 1.

223
docs/PHASE-1-sidecar.md Normal file
View File

@ -0,0 +1,223 @@
# Phase 1 — Sidecar Production-Ready
> **Status:** blocked on Phase 0 verdict.
> **Owners:** parallelisable across multiple agents — see task table.
> **Branch base:** `main` after Phase 0 merge. Feature branches per work
> stream (see `SYNC.md`).
> **Spec reference:** [`reference/SPEC-ios-app.md`](./reference/SPEC-ios-app.md) §4.
## Goal
The `pi-remote-control` extension is extended into a full sidecar that can
serve the iOS app. End state: a single Node process, started alongside pi
(or as a system service), that exposes a WebSocket API for:
- Stream attach/detach with reconnect.
- Send-keys input.
- Multi-session lifecycle (spawn, list, rename, kill).
- Snapshot, disk-buffered replay.
- State, slash-command-registry side-channel.
- QR-based pairing, bearer-token auth, self-signed TLS with pinning.
- Health endpoint.
After Phase 1 we can drive everything from `wscat` or a small Web UI.
The iOS app is **not** required to validate Phase 1.
## Acceptance Criteria
For each S-feature listed below: implemented, manually exercised, basic
test (smoke test minimum). Plus:
- `pi-remote pair` prints a working QR.
- Two parallel sessions can be spawned, switched between, and one can be
killed without disturbing the other.
- WebSocket-level integration smoke test: a script that opens a stream,
sends keys, receives output, drops the connection, reconnects with
`lastSeq`, observes a clean delta.
- `wss://` works against the self-signed cert; the fingerprint matches the
QR contents.
- Sidecar survives restart and reattaches to all existing tmux sessions
without losing state.
## Architecture Sketch
```
extensions/remote-control/
├── index.ts — extension entry point (existing, extended)
├── server/ — NEW: HTTP/WS server, split into route modules
│ ├── server.ts — bootstrap, TLS, middleware
│ ├── routes/
│ │ ├── stream.ts — S-02 binary stream + S-04 sequence + S-05 snapshot
│ │ ├── input.ts — S-03 send-keys
│ │ ├── sessions.ts — S-09 multi-session CRUD
│ │ ├── commands.ts — S-08 slash-command registry
│ │ ├── side.ts — S-07 state side-channel
│ │ └── health.ts — S-12 health
│ └── upgrade.ts — WS upgrade routing per session/topic
├── tmux/ — NEW: tmux wrapper
│ ├── manager.ts — spawn/list/kill, metadata via @options
│ ├── pipe.ts — pipe-pane, FIFO read, byte streaming
│ ├── input.ts — send-keys translation
│ └── snapshot.ts — capture-pane wrapper
├── buffer/ — NEW: disk ringbuffer per session
│ ├── writer.ts — append, cap enforcement, watchdog
│ └── reader.ts — range read for snapshot fallback
├── sequence.ts — NEW: monotonic chunk numbering shared by stream + buffer
├── auth/ — auth/pairing module
│ ├── tokens.ts — bearer-token CRUD (extends existing auth.ts)
│ ├── pairing.ts — pi-remote pair, QR rendering, exchange
│ └── tls.ts — self-signed cert generation + fingerprint
├── pi/ — adapter to pi ExtensionAPI
│ ├── events.ts — subscribe agent_start/end, tool_*, session_*
│ ├── commands.ts — pi.getCommands() wrapper
│ └── autoname.ts — S-09a, spawn pi -p subprocess
└── cli/ — CLI entrypoints (pi-remote attach/pair/auth/health)
└── index.ts
```
`html.ts`, `messages.ts`, the existing `server.ts` and `config.ts` remain
for the legacy HTML client during the transition; they are tagged as
*legacy* in code comments. They will be retired after Phase 2 ships.
## Task Breakdown
Tasks are numbered `T-1.<n>`. The "Parallel With" column shows which other
tasks can be in flight simultaneously without merge pain. The "Touches"
column lists the files an agent may modify.
| ID | Task | Touches | Depends on | Parallel With |
|---|---|---|---|---|
| T-1.0 | **Server refactor scaffold.** Carve `server.ts` into the `server/` and route modules above; existing HTML behaviour must still work; CI green. | `extensions/remote-control/server/**`, minimal edit of `index.ts` | — | none — must land first |
| T-1.1 | **tmux/manager + tmux/pipe + tmux/snapshot.** Spawn, list, kill, metadata via `@description`. Pipe-pane FIFO reader. Snapshot via `capture-pane`. | `tmux/**` | T-1.0 | T-1.2, T-1.3, T-1.4, T-1.5, T-1.6 |
| T-1.2 | **Sequence module + buffer/writer + buffer/reader.** Monotone chunk IDs, disk ringbuffer with caps (100MB/session, 1GB global, free-space watchdog), idle-cleanup. | `sequence.ts`, `buffer/**` | T-1.0 | T-1.1, T-1.3, T-1.4, T-1.5, T-1.6 |
| T-1.3 | **Auth: tokens + pairing + TLS.** Self-signed cert generation, fingerprint, bearer-token CRUD, `pi-remote pair` CLI + QR rendering, `pi-remote auth list/revoke/name`. | `auth/**`, `cli/index.ts` (subcommands only) | T-1.0 | T-1.1, T-1.2, T-1.4, T-1.5, T-1.6 |
| T-1.4 | **pi adapter.** Subscribe ExtensionAPI events, expose `getCommands`, implement `autoname.ts` spawning `pi -p`. | `pi/**`, edits in `index.ts` to wire subscriptions | T-1.0 | T-1.1, T-1.2, T-1.3, T-1.5, T-1.6 |
| T-1.5 | **Stream + input + snapshot routes (S-02/S-03/S-04/S-05).** WS upgrade routing, binary stream, sequence cursor resume, send-keys with bracketed-paste. | `server/routes/stream.ts`, `server/routes/input.ts`, `server/upgrade.ts` | T-1.0, T-1.1, T-1.2 | T-1.6, T-1.7 |
| T-1.6 | **Side-channel + commands + sessions routes (S-07/S-08/S-09).** | `server/routes/side.ts`, `server/routes/commands.ts`, `server/routes/sessions.ts` | T-1.0, T-1.1, T-1.4 | T-1.5, T-1.7 |
| T-1.7 | **Health endpoint + config + watchdog (S-12).** Disk watchdog ties buffer caps to global state. | `server/routes/health.ts`, new `config.toml` schema in `config.ts` | T-1.0, T-1.2 | T-1.5, T-1.6 |
| T-1.8 | **Integration smoke harness.** Node script under `scripts/smoke/` that spawns a sidecar, opens a stream, sends keys, drops + reconnects, verifies delta. | `scripts/smoke/**` | T-1.5, T-1.6 | none |
| T-1.9 | **Docs: operator guide.** README section "Running pi-remote as a sidecar", config sample, troubleshooting. | `README.md`, optionally `docs/reference/OPERATOR.md` | T-1.5, T-1.6, T-1.7 | parallel with T-1.8 |
| T-1.10 | **APNs scaffold (deferred but cheap).** `apns/` module: config schema, JWT generation, push primitive. Stub the device-token registry — flesh out in Phase 2 when iOS app provides tokens. | `apns/**`, edits in `auth/tokens.ts` to store device-tokens | T-1.3 | T-1.5..T-1.7 |
## Interface Contracts (lock early to enable parallelism)
These are the contracts that downstream tasks depend on. They must be
agreed and frozen at the start of Phase 1 — see `SYNC.md` for the freeze
protocol.
### IC-1 — WebSocket frames
```ts
// binary frame : raw ANSI stream bytes (output direction only).
// text frame : JSON, type-discriminated.
type ClientToServer =
| { type: "resume"; lastSeq: number | null }
| { type: "key"; name: string } // "escape" | "tab" | "up" | "down" | "left" | "right" | "enter" | "shift-enter"
| { type: "keys"; data: string } // literal text, sent via send-keys -l
| { type: "paste"; data: string } // wrapped in bracketed-paste
| { type: "snapshot-request" };
type ServerToClient =
| { type: "state"; value: "thinking" | "tool" | "idle" | "awaiting-input"; tool?: string; ts: number }
| { type: "tree"; nodes: TreeNode[]; current: string } // optional, read-only
| { type: "snapshot"; seq: number; data: string } // base64 ANSI snapshot
| { type: "session-meta"; name: string; description?: string; createdAt: string }
| { type: "error"; code: string; message: string };
```
Binary frames carry an out-of-band `seq` via a leading 8-byte
big-endian header. Owner: T-1.5.
### IC-2 — HTTP REST shape
```
GET /health → { ok, sessions, bufferBytes, ... }
POST /sessions → { id, name }
GET /sessions → [{ id, name, description, state, lastOutputAt }, …]
PATCH /sessions/:id → updates @description
DELETE /sessions/:id → kills tmux session, optionally clears buffer
GET /sessions/:id/commands → [{ name, description, args }]
GET /sessions/:id/thumbnail → text/plain capture-pane (40×12)
```
All endpoints behind bearer token, all responses `application/json` unless
noted. Owner: T-1.5..T-1.7.
### IC-3 — Pairing payload
QR encodes a `pi-remote://` URL:
```
pi-remote://<host>:<port>?pair=<pairing-token>&fp=<sha256-hex>&name=<sidecar-name>
```
Pairing exchange: client `POST /pair` with `{ pairingToken, deviceToken?, environment?, deviceName? }` → server replies `{ bearerToken, sidecarId }`. Owner: T-1.3.
### IC-4 — Config schema (TOML)
```toml
[server]
host = "0.0.0.0"
port = 7777
state_dir = "~/.local/share/pi-remote"
[buffer]
per_session_mb = 100
global_gb = 1
free_min_gb = 1
idle_days = 30
[tmux]
default_width = 120
default_height = 40
[apns]
team_id = "..."
key_id = "..."
key_path = "..."
bundle_id = "..."
[autoname]
enabled = true
trigger_after = 3 # user messages
model = "claude-haiku-4-5"
```
Owner: T-1.7.
## Branching Strategy
- Each task is a feature branch off `main`, named `feat/p1-<task-id>-<slug>`,
e.g. `feat/p1-t1-1-tmux-manager`.
- Open a PR as soon as a task is ready for review. Squash-merge.
- T-1.0 (refactor) lands first, then T-1.1..T-1.4 can run truly parallel.
- T-1.5..T-1.7 each consume one or more of the lower-layer modules; they
start as soon as the dependency PR is in `main`.
## Test Strategy
- Unit: per-module pure-logic tests under `extensions/remote-control/**/__tests__/`.
- Integration smoke: T-1.8 script, runnable locally and in CI.
- Manual: each task PR lists manual-verification steps.
- No iOS testing in this phase.
## Risks
- **R1.** Disk-buffer cap math races vs. global watchdog. Mitigation:
serialise buffer writes through a single async queue per session, lock
the global cap behind a mutex.
- **R2.** ExtensionAPI event names might shift in future pi versions.
Mitigation: pin pi version range in `package.json`, isolate adapter in
`pi/events.ts`.
- **R3.** `pi -p` auto-name calls cost money. Mitigation: gate behind
`[autoname] enabled`, debounce, skip if user already named the session.
## Exit / Handover
- All T-1.x merged.
- Smoke harness passes locally and in CI.
- Operator guide complete.
- A short `docs/reference/PHASE-1-report.md` summarising deviations from
the plan, especially anything that affects Phase 2 contracts.
- Update `SYNC.md` to unblock Phase 2.

185
docs/PHASE-2-ios-mvp.md Normal file
View File

@ -0,0 +1,185 @@
# Phase 2 — iOS App MVP
> **Status:** blocked on Phase 1 (sidecar must be reachable).
> **Owners:** parallelisable; see task table.
> **Repo:** new repository `pi-remote-ios` adjacent to `pi-remote-control`,
> at `git.vpsj.de/jay/pi-remote-ios`. Reason: Swift project, separate
> tooling, separate release cadence.
> **Spec reference:** [`reference/SPEC-ios-app.md`](./reference/SPEC-ios-app.md) §5
> Groups A, B (sans hardware-keyboard), C-01, C-02, D-01 + a/b, E, F.
## Goal
A SwiftUI iOS app that:
- Pairs with a sidecar via QR scan.
- Renders a single pi session 1:1 via SwiftTerm.
- Sends keystrokes back via the IC-1 protocol.
- Survives backgrounding and reconnect within the < 1s P-3 target.
- Switches between multiple sessions with pre-connect cache.
- Receives push notifications when pi reaches `awaiting-input`.
After Phase 2 the app is usable in the user's daily workflow, replacing
the legacy HTML client. Augmentations (slash palette, voice, themes,
search, …) come in Phase 3.
## Acceptance Criteria
- Apple Developer enrolment complete, App ID with Push capability + APNs
Auth Key (`.p8`) generated.
- App builds and runs on the user's iPhone via Xcode (sandbox APNs).
- App pairs via QR, persists bearer token + cert pinning across launches.
- Foreground rendering: SwiftTerm shows pi 1:1, input round-trips.
- Background → foreground: < 1s to live stream, no visible empty screen.
- Three named sessions, switcher works, pre-connect makes switching
feel instant.
- Push notification fires when pi state transitions to `awaiting-input`
while app is backgrounded.
- TestFlight build distributable (production APNs route exercised).
- Face-ID gate available as opt-in setting.
## Project Layout
New repo `pi-remote-ios`:
```
pi-remote-ios/
├── README.md
├── Package.swift — SwiftPM (deps: SwiftTerm, Starscream)
├── Apps/
│ └── piRemote/ — main app target
│ ├── piRemoteApp.swift — @main entry
│ ├── Resources/
│ │ ├── Themes/ — bundled .json theme files
│ │ ├── Fonts/ — JetBrains Mono, Hack, etc.
│ │ └── Assets.xcassets
│ └── Info.plist
├── Sources/
│ ├── Core/ — networking, state, persistence
│ │ ├── Network/
│ │ │ ├── WebSocketClient.swift — Starscream wrapper, permessage-deflate
│ │ │ ├── FrameCodec.swift — IC-1 encode/decode
│ │ │ ├── ResumeCursor.swift — lastSeq tracking per session
│ │ │ └── PinnedTrust.swift — TLS pinning from QR fingerprint
│ │ ├── Auth/
│ │ │ ├── Keychain.swift
│ │ │ └── Pairing.swift — QR parse, exchange
│ │ ├── Sessions/
│ │ │ ├── SessionRegistry.swift — list, spawn, kill (talks to /sessions)
│ │ │ ├── SessionConnection.swift — one WS per session
│ │ │ └── PreConnectPool.swift — D-01a strategy
│ │ ├── Push/
│ │ │ ├── NotificationDelegate.swift
│ │ │ └── DeviceTokenRegistrar.swift — sends token + env to sidecar
│ │ └── Persistence/
│ │ ├── ScrollbackCache.swift — rolling 5MB per session, on disk
│ │ └── Preferences.swift
│ ├── UI/
│ │ ├── Terminal/
│ │ │ ├── TerminalView.swift — UIViewRepresentable wrapping SwiftTerm
│ │ │ ├── ThemeStore.swift — bundled themes, currently selected
│ │ │ └── FontStore.swift
│ │ ├── Input/
│ │ │ ├── ModifierBar.swift — [Ctrl][Esc][Tab][←↑↓→][⇧↵][🎙][📋]
│ │ │ ├── ModifierState.swift — sticky Ctrl + repeat handling
│ │ │ └── PasteSheet.swift — confirm-before-paste
│ │ ├── Status/
│ │ │ └── StatusBar.swift — connection + pi state
│ │ ├── Sessions/
│ │ │ ├── SessionSwitcher.swift — list, spawn, switch
│ │ │ └── SessionRow.swift — name + state badge (no thumbnail in MVP)
│ │ ├── Pairing/
│ │ │ ├── QRScannerView.swift
│ │ │ └── PairingFlowView.swift
│ │ └── Settings/
│ │ └── SettingsView.swift — Face-ID toggle, sidecar info
│ └── Voice/ — empty placeholder, populated in Phase 3
├── Tests/
│ └── CoreTests/
│ ├── FrameCodecTests.swift
│ └── ResumeCursorTests.swift
└── docs/
├── BUILD.md
└── DISTRIBUTION.md — TestFlight steps
```
## Task Breakdown
| ID | Task | Touches | Depends on | Parallel With |
|---|---|---|---|---|
| T-2.0 | **Repo + Xcode project scaffold + Apple Developer setup.** Create repo on git.vpsj.de, generate App ID + APNs Auth Key, commit `.p8` instructions (key itself stays out of git). Empty SwiftUI shell that boots and shows "Hello pi". | repo root, `Apps/piRemote/` | Phase 1 sidecar reachable | none — must land first |
| T-2.1 | **WebSocketClient + FrameCodec.** Starscream, permessage-deflate enabled, encode/decode IC-1 frames, basic ping/pong keepalive. Unit-tested. | `Sources/Core/Network/` | T-2.0 | T-2.2, T-2.3, T-2.4 |
| T-2.2 | **Pairing flow + Keychain + TLS pinning.** QR scanner (AVFoundation), parse `pi-remote://`, exchange via Pairing.swift, store bearer + fingerprint in Keychain, install PinnedTrust into URLSession + Starscream. | `Sources/Core/Auth/`, `Sources/Core/Network/PinnedTrust.swift`, `Sources/UI/Pairing/` | T-2.0 | T-2.1, T-2.3 |
| T-2.3 | **TerminalView + Theme/Font store.** Wrap SwiftTerm as UIViewRepresentable, render incoming binary chunks, expose Pinch-Zoom gesture (iOS-B-05), Selection/Copy (iOS-B-04). Bundle JetBrains Mono + Hack + default themes. | `Sources/UI/Terminal/`, `Apps/piRemote/Resources/` | T-2.0 | T-2.1, T-2.2, T-2.4 |
| T-2.4 | **ModifierBar + Input pipeline.** Layout `[Ctrl][Esc][Tab][←↑↓→][⇧↵][🎙][📋]`, sticky Ctrl, long-press repeat, paste sheet stub (full Smart-Paste in Phase 3). Wires keys into IC-1. | `Sources/UI/Input/` | T-2.1 | T-2.2, T-2.3 |
| T-2.5 | **SessionConnection + ResumeCursor + ScrollbackCache.** One WS per session, persist lastSeq, write incoming bytes into a rolling on-disk file per session. Snapshot fallback on gap. | `Sources/Core/Sessions/SessionConnection.swift`, `Sources/Core/Network/ResumeCursor.swift`, `Sources/Core/Persistence/ScrollbackCache.swift` | T-2.1 | T-2.6, T-2.7 |
| T-2.6 | **SessionRegistry + SessionSwitcher UI.** Talks to `/sessions`, list/spawn/rename/kill, switcher UI, basic SessionRow. No thumbnails or pre-connect yet. | `Sources/Core/Sessions/SessionRegistry.swift`, `Sources/UI/Sessions/` | T-2.1, T-2.5 | T-2.7 |
| T-2.7 | **PreConnectPool + Optimistic Switch + Stale-Frame.** All known sessions hold a hot WS + last frame; switching shows the cached frame instantly with a "syncing…" pill. | `Sources/Core/Sessions/PreConnectPool.swift`, `Sources/UI/Terminal/TerminalView.swift` (cache hooks) | T-2.5, T-2.6 | T-2.8 |
| T-2.8 | **StatusBar + side-channel consumption.** Subscribe to `state` frames, render `● thinking` / `▶ awaiting` / `⏸ idle`, session-name display. | `Sources/UI/Status/`, `Sources/Core/Sessions/SessionConnection.swift` (event surface) | T-2.5 | T-2.7 |
| T-2.9 | **Push: NotificationDelegate + DeviceTokenRegistrar.** Request user permission, register for remote notifications, ship `{ deviceToken, environment }` to sidecar at pair-time and on every launch. Foreground-handler suppresses banners when relevant session is visible. | `Sources/Core/Push/`, edits in pairing/Settings flow | T-2.2, Phase 1 T-1.10 | T-2.8 |
| T-2.10 | **Background lifecycle.** App-foreground triggers reconnect + delta pull, stale-frame freezes during sync, keep-alive ping in foreground only. | `Sources/Core/Sessions/SessionConnection.swift`, app delegate | T-2.5, T-2.7 | parallel with T-2.9 |
| T-2.11 | **Face-ID gate + Settings.** Opt-in toggle, gate appears on cold launch and on resume after > N seconds backgrounded. | `Sources/UI/Settings/`, `Sources/Core/Auth/Keychain.swift` | T-2.0 | parallel with most |
| T-2.12 | **TestFlight pipeline.** Build script, archive, upload, internal testers list. Verify production APNs path. | `docs/DISTRIBUTION.md`, Fastlane or shell scripts | T-2.0, T-2.9 | parallel with everything once T-2.0 is in |
| T-2.13 | **MVP smoke test.** Manual checklist run on the user's iPhone: pair → render → input → backgrounded → push → reopen < 1s session-switch round-trip. Document any deviations. | `docs/PHASE-2-report.md` | all above | none |
## Interface Contracts
iOS consumes the IC-1..IC-4 contracts defined in Phase 1. Any deviation
discovered while building is fixed in the sidecar, not the app, and must
be communicated via `SYNC.md` (lock change protocol).
Additional iOS-internal contract:
### IC-2.1 — SessionConnection surface
```swift
protocol SessionConnection {
var id: String { get }
var state: AnyPublisher<PiState, Never> { get }
var stream: AnyPublisher<Data, Never> { get } // ANSI bytes, in order
func send(_ frame: ClientToServer) async throws
func resume(from lastSeq: UInt64?) async throws
func suspend() async // tear down WS but keep state
}
```
Owners of `SessionConnection`: T-2.5. Consumers: T-2.6, T-2.7, T-2.8, T-2.10.
## Branching Strategy
- All work on `pi-remote-ios`, off `main`.
- One branch per T-2.x task, `feat/p2-<task-id>-<slug>`.
- T-2.0 must land first.
- T-2.1, T-2.2, T-2.3, T-2.4, T-2.11, T-2.12 can start in parallel right
after T-2.0.
- T-2.5..T-2.10 form a dependency cluster but most can interleave.
- T-2.13 last.
## Test Strategy
- Unit tests for FrameCodec, ResumeCursor, theme parsing.
- UI snapshot tests for ModifierBar, SessionRow, StatusBar.
- Manual on-device testing via T-2.13 checklist.
- No XCUITest in MVP — too brittle for the time invested.
## Risks
- **R1.** Apple Developer enrolment delays. Workaround: dev with personal
team + free sideloading for the first 1-2 weeks; switch to paid account
before T-2.12.
- **R2.** Starscream's permessage-deflate compat with our `ws` library
needs verification with a smoke test early — block T-2.1 PR until
proven.
- **R3.** SwiftTerm's alternate-screen handling vs. our scrollback cache.
Cache must skip bytes while alternate-screen is active. Spec calls for
this; implementation needs care.
- **R4.** Push notification permission UX. If user declines, iOS-C-02
degrades to silent. Provide a Settings deep-link to re-enable.
## Exit / Handover
- All T-2.x merged.
- T-2.13 report green.
- App on user's iPhone in daily use.
- `docs/PHASE-2-report.md` in this repo, summary mirrored into `SYNC.md`.
- Trigger Phase 3.

View File

@ -0,0 +1,81 @@
# Phase 3 — iOS Augmentation
> **Status:** blocked on Phase 2 MVP shipping.
> **Owners:** highly parallelisable — features are largely independent.
> **Repo:** `pi-remote-ios`.
> **Spec reference:** [`reference/SPEC-ios-app.md`](./reference/SPEC-ios-app.md) §5
> Groups B-06, B-07, B-08, B-09, C-03, C-04, C-05, D-01c, D-02, A-05 extensions.
## Goal
Make the iOS app distinctly nicer to use than a generic terminal client.
Each Phase 3 feature is independently shippable; no global blocker.
Features can land in any order, driven by daily use feedback.
## Acceptance Criteria
Per-feature checklist (each feature ships when its row passes):
| Feature | Acceptance |
|---|---|
| Slash-Command Palette | Long-press modifier bar opens palette, fuzzy search works, command injects correctly, argument forms render for commands with args. |
| Voice-to-Prompt | Mic button → preview → send works offline (iOS Speech). |
| Predictive Thumbnails | Switcher list shows live 40×12 capture-pane previews refreshed on open. |
| Scrollback Search | Cmd-F (HW kb) or pull-down gesture opens search; jump-to-match highlights and centres. |
| Hardware Keyboard Shortcuts | Cmd-K, Cmd-T, Cmd-1..9, Cmd-F, Cmd-Shift-P, Cmd-, route correctly. |
| Reachability | iPhone landscape: modifier bar mirrored for one-handed use. |
| Smart Paste (full) | Clipboard preview chip, multi-line preview sheet, bracketed-paste correctness. |
| Haptic Feedback | Subtle haptic on thinking→idle and thinking→awaiting transitions. |
| Theme + Font Picker UI | Settings UI exposes all bundled themes and fonts; iCloud-sync for custom. |
## Task Breakdown
| ID | Task | Touches | Depends on | Parallel With |
|---|---|---|---|---|
| T-3.1 | **Slash-Command Palette** (iOS-C-04). Long-press recogniser on ModifierBar, palette sheet, fuzzy-search engine, argument form generator from JSON schema returned by sidecar. | `Sources/UI/Input/SlashPalette/`, sidecar `/sessions/:id/commands` already exists (S-08) | Phase 2 | all others |
| T-3.2 | **Voice-to-Prompt** (iOS-C-05). `Sources/Voice/`, SFSpeechRecognizer, microphone permission, preview-edit-send flow. | `Sources/Voice/`, `Sources/UI/Input/ModifierBar.swift` (🎙 wiring) | Phase 2 | all others |
| T-3.3 | **Predictive Thumbnails** (iOS-D-01c). Add `GET /sessions/:id/thumbnail` on sidecar if not already (Phase 1 IC-2 includes it); poll on switcher open; render small SwiftTerm in `SessionRow`. | `Sources/UI/Sessions/SessionRow.swift`, `Sources/Core/Sessions/SessionRegistry.swift` | Phase 2 | all others |
| T-3.4 | **Scrollback Search** (iOS-D-02). Search bar over `ScrollbackCache`, in-memory index (linear search is fine at 5MB), highlight + jump in TerminalView. | `Sources/UI/Terminal/Search/`, `Sources/Core/Persistence/ScrollbackCache.swift` (read API) | Phase 2 | all others |
| T-3.5 | **Hardware Keyboard Shortcuts** (iOS-B-06). Register `UIKeyCommand` set in piRemoteApp + scene; route to app actions. Caps→Esc opt-in. | `Apps/piRemote/piRemoteApp.swift`, individual view controllers via scene delegate | Phase 2 | T-3.1 (Cmd-Shift-P depends on slash palette existing) |
| T-3.6 | **Reachability / One-Hand-Mode** (iOS-B-07). Landscape layout in `ModifierBar` mirrored; settings toggle. | `Sources/UI/Input/ModifierBar.swift` | Phase 2 | all others |
| T-3.7 | **Smart Paste full** (iOS-B-08 + iOS-B-09 bracketed-paste). Extend stub PasteSheet from Phase 2 with multi-line preview, char/line counter; track `\e[?2004h/l` from stream, switch paste-frame type accordingly. | `Sources/UI/Input/PasteSheet.swift`, `Sources/Core/Sessions/SessionConnection.swift` (state tracker) | Phase 2 | all others |
| T-3.8 | **Haptic Feedback** (iOS-C-03). `UIImpactFeedbackGenerator` hook in StatusBar state change. Setting to disable. | `Sources/UI/Status/StatusBar.swift`, Settings | Phase 2 | all others |
| T-3.9 | **Theme + Font Picker** (iOS-A-05 UI). Settings panes for theme/font selection, custom-theme editor (JSON or color pickers), iCloud KVS sync for custom. | `Sources/UI/Settings/`, `Sources/UI/Terminal/ThemeStore.swift` extensions | Phase 2 | all others |
## Inter-Task Conflicts
Most Phase 3 tasks touch unrelated files. Watch zones:
- **ModifierBar.swift** — T-3.1 (long-press), T-3.2 (mic), T-3.6 (mirror),
T-3.7 (paste). Coordinate via SYNC.md if more than one of these is in
flight simultaneously. Recommended order: T-3.1 → T-3.7 → T-3.6 → T-3.2.
- **SessionConnection.swift** — T-3.7 (bracketed-paste state) and any
follow-up to Phase 2 IC-2.1. Coordinate.
- **Settings UI** — T-3.6, T-3.8, T-3.9 all extend the same settings
surface. Land in series or merge carefully.
## Test Strategy
Per feature: a manual checklist row in `docs/PHASE-3-checklist.md`. No
heavy automation — these are visual / experiential features.
Critical regressions to watch for:
- Slash palette must not break input flow when dismissed (focus return).
- Voice must not steal focus from the WebSocket stream.
- Thumbnails must not block switcher rendering on slow links.
## Risks
- **R1.** SFSpeechRecognizer offline accuracy varies. Mitigation: allow
on-device-only mode (slower, more private) vs. server-assisted toggle.
- **R2.** UIKeyCommand routing is finicky across scenes. Mitigation:
centralise key handling in a single `KeyCommandRouter` actor.
- **R3.** Custom theme JSON schema drift between iCloud devices. Mitigation:
versioned schema, migrate on read.
## Exit
Phase 3 has no hard exit — features land continuously. A "Phase 3 closed"
event is when every task above is shipped or explicitly deferred. At that
point write `docs/PHASE-3-report.md` summarising what made it, what didn't,
and ideas that came out of daily use for a future Phase 4.

22
docs/README.md Normal file
View File

@ -0,0 +1,22 @@
# Implementation Docs
This folder drives the implementation work for the pi-remote iOS app and
its sidecar. Background / spec / audit material lives in
[`reference/`](./reference/).
| File | Purpose |
|---|---|
| [`PHASE-0-spike-stream.md`](./PHASE-0-spike-stream.md) | Stream PoC — verify tmux + pipe-pane + WebSocket. ~1 day, single agent. |
| [`PHASE-1-sidecar.md`](./PHASE-1-sidecar.md) | Sidecar production-ready: all S-features, multi-agent parallel work. |
| [`PHASE-2-ios-mvp.md`](./PHASE-2-ios-mvp.md) | iOS app MVP — Groups A, B, C-01/02, D, E, F. Multi-agent parallel. |
| [`PHASE-3-ios-augmentation.md`](./PHASE-3-ios-augmentation.md) | iOS feature polish — slash palette, voice, thumbnails, search, etc. |
| [`SYNC.md`](./SYNC.md) | Live multi-agent coordination — claims, file ownership, contract changes. |
## Order of work
1. Phase 0 first, single agent.
2. Phase 1 starts after Phase 0 green-lights; multi-agent parallel.
3. Phase 2 starts after Phase 1 is production-ready; multi-agent parallel.
4. Phase 3 is continuous after Phase 2 MVP ships.
See `SYNC.md` for the current state.

158
docs/SYNC.md Normal file
View File

@ -0,0 +1,158 @@
# SYNC — Multi-Agent Coordination
> **Purpose:** allow several agents (human or AI) to work concurrently on
> this codebase without stepping on each other.
>
> **Scope:** all phases. This document is the live coordination surface;
> the phase plans (`PHASE-0..PHASE-3`) are immutable plans, this file
> tracks who is doing what *right now*.
---
## How this works
1. Every concrete work item lives in a phase plan as `T-<phase>.<n>`.
2. Before starting work on a task, an agent:
- Pulls latest `main`.
- Edits the **Active Claims** table below to add a row claiming the
task with its branch name, owner handle, and timestamp.
- Commits that edit on `main` directly (small, low-conflict).
- Then opens the feature branch and works.
3. When the task is done (PR merged) the agent removes its claim row and
appends a one-line entry to **History**.
4. If a task needs to **change a frozen interface contract** (IC-1..IC-4
from Phase 1, IC-2.1 from Phase 2), the agent must:
- Open a section under **Contract Change Requests** below.
- Wait for at least one other active agent (or the orchestrator) to
acknowledge by editing the row to `acked: <handle>`.
- Only then implement the change.
The point: no central scheduler is required. A short structured edit on
`main` is the lock.
---
## Phase Gate
| Phase | Status | Notes |
|---|---|---|
| Phase 0 — Spike Stream | not started | First task. See `PHASE-0-spike-stream.md`. |
| Phase 1 — Sidecar | blocked on Phase 0 | Can begin only after Phase 0 verdict is green. |
| Phase 2 — iOS MVP | blocked on Phase 1 | Sidecar must be reachable and stable. |
| Phase 3 — iOS Augmentation | blocked on Phase 2 | Continuous after MVP ships. |
Update the **Status** column when a phase transitions. Allowed states:
`not started`, `in progress`, `blocked on …`, `done`.
---
## Active Claims
| Task | Branch | Owner | Claimed at | ETA | Notes |
|---|---|---|---|---|---|
| _(none)_ | | | | | |
Example of a filled row:
```
| T-1.1 | feat/p1-t1-1-tmux-manager | @jay | 2026-05-20 14:00 | +2d | starting with manager.ts |
```
Rules:
- **One row per task.** A task can have only one active owner.
- **Owner** = the agent's handle (`@jay`, `@worker-1`, `@scout`, etc.).
- **ETA** is a rough estimate; missing it is OK, but if a row is stale > 2× ETA, anybody may reclaim after pinging.
- **Branch** must exist on the remote within 24h of the claim, otherwise
the row is considered abandoned and may be removed.
---
## File Ownership Map
For each high-traffic file, the table below lists the tasks that may
legitimately modify it. If you need to touch a file outside this list,
add a row or open a Contract Change Request.
| File | Authorised Tasks |
|---|---|
| `extensions/remote-control/index.ts` | T-1.0, T-1.4 (events wiring only) |
| `extensions/remote-control/server.ts` (legacy) | nobody after T-1.0; legacy frozen |
| `extensions/remote-control/server/**` | T-1.0 (refactor), T-1.5, T-1.6, T-1.7 |
| `extensions/remote-control/tmux/**` | T-1.1 |
| `extensions/remote-control/buffer/**` | T-1.2 |
| `extensions/remote-control/sequence.ts` | T-1.2 |
| `extensions/remote-control/auth/**` | T-1.3, T-1.10 (device tokens only) |
| `extensions/remote-control/pi/**` | T-1.4 |
| `extensions/remote-control/apns/**` | T-1.10, Phase-2 T-2.9 (when iOS supplies tokens) |
| `extensions/remote-control/cli/**` | T-1.3, T-1.7 |
| `extensions/remote-control/config.ts` | T-1.7 |
| `docs/SYNC.md` | all (this file) |
| `docs/PHASE-*.md` | nobody once a phase has started (frozen plan) — open a CCR to amend |
| `docs/reference/**` | nobody during implementation — archival |
For the iOS repo `pi-remote-ios`, an analogous map will be added when
Phase 2 kicks off.
---
## Frozen Interface Contracts
| ID | Defined in | Owner of changes |
|---|---|---|
| IC-1 — WebSocket frame protocol | `PHASE-1-sidecar.md` §Interface Contracts | T-1.5 lead, with sign-off from any active T-2.x owner |
| IC-2 — HTTP REST shape | `PHASE-1-sidecar.md` §Interface Contracts | T-1.5..T-1.7 leads |
| IC-3 — Pairing payload | `PHASE-1-sidecar.md` §Interface Contracts | T-1.3 lead |
| IC-4 — Config TOML schema | `PHASE-1-sidecar.md` §Interface Contracts | T-1.7 lead |
| IC-2.1 — `SessionConnection` Swift surface | `PHASE-2-ios-mvp.md` §Interface Contracts | T-2.5 lead |
Once a contract is *frozen* (i.e. at least one consumer task has started
work that depends on it), changes require a CCR.
---
## Contract Change Requests (CCR)
Format:
```
### CCR-YYYY-MM-DD-<slug>
- **Contract**: IC-1 / IC-2 / …
- **Proposer**: @handle
- **Motivation**: 1-3 sentences.
- **Proposed change**: diff or prose.
- **Affected tasks**: list.
- **Status**: open | acked by @… | merged | rejected
```
_(none open)_
---
## Cross-Phase Notes
Threads that don't belong in a single phase plan.
- **Spec deviations.** If implementation reveals that a spec item is
wrong or unbuildable, write a short note here under **History**, plus
open a CCR if it changes a frozen contract. The spec itself stays
immutable until a v4 review round.
- **Risks materialised.** When a `Risks` row from a phase plan actually
hits, log it here with the workaround used.
- **Tools / shared scripts.** Anything added under `scripts/` that's
reused across tasks gets a one-liner here.
---
## History
Append-only log of completed work and notable events. One line each.
```
yyyy-mm-dd @handle T-x.y what was done
```
Example:
```
2026-05-15 @jay init docs reorganised; phase plans + SYNC created
```
(populated as work happens)

View File

@ -0,0 +1,936 @@
# pi ExtensionAPI Audit (für iOS-App-Spec v2)
**Datum:** 2026-05-15
**pi Version:** 0.74.0
**Auditor:** Subagent (Claude Sonnet 4.6)
---
## TL;DR
- ✅ **S-07 State Side-Channel** — machbar mit Einschränkungen (kein explizites `awaiting-input` Event, muss abgeleitet werden)
- ✅ **S-08 Slash-Command-Registry** — direkt verfügbar via `pi.getCommands()`
- ⚠️ **S-13 Tree-State Side-Channel** — Tree-Read funktioniert, aber keine Subscribe-Mechanik für Live-Updates (Polling oder Event-basiert möglich)
- ❌ **Gruppe T Tree-Navigation** — Slash-Command-Injection funktioniert NICHT (Commands werden als Text an LLM gesendet, nicht dispatched). Hack via `(pi as any)` nötig oder Upstream-Change.
---
## 1. Methodik
**Untersuchte Quellen:**
- pi-Installation: `/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/` (v0.74.0)
- TypeScript-Definitionen: `dist/core/extensions/types.d.ts` (1173 Zeilen)
- Session-Manager: `dist/core/session-manager.d.ts` (308 Zeilen)
- Slash-Commands: `dist/core/slash-commands.d.ts` (15 Zeilen)
- Offizielle Doku: `docs/extensions.md` (97KB)
- Bestehende Extension: `/Users/jay/.pi/agent/git/git.vpsj.de/jay/pi-remote-control/extensions/remote-control/`
**Verifikationsmethode:** Statische Analyse der TypeScript-Definitionen + Cross-Check gegen bestehenden Code der `pi-remote-control` Extension.
---
## 2. Capability-Matrix
| Capability | Status | Spec-Feature | Workaround | Aufwand |
|---|---|---|---|---|
| **Lifecycle Events** | ✅ Vorhanden | S-07 | `agent_end` statt `awaiting-input` | Trivial |
| **Tool Execution Events** | ✅ Vorhanden | S-07 | — | Trivial |
| **Slash-Command-Registry** | ✅ Vorhanden | S-08 | — | Trivial |
| **Tree-Read** | ✅ Vorhanden | S-13 | — | Trivial |
| **Tree-Subscribe (Live-Updates)** | ⚠️ Event-basiert | S-13 | Events tracken statt Subscribe | Einfach |
| **Slash-Command-Dispatch** | ❌ Nicht exposed | Gruppe T | `(pi as any)` Hack | Fragil |
| **Tool-Call-Details** | ✅ Vorhanden | S-07 | — | Trivial |
**Legende:**
- ✅ = Out-of-the-box verfügbar
- ⚠️ = Machbar mit Einschränkungen
- ❌ = Nicht ohne Hack oder Upstream-Change
---
## 3. Capabilities im Detail
### 3.1 Lifecycle Events
**Was existiert:**
```typescript
// Quelle: dist/core/extensions/types.d.ts, Zeile 458-477
pi.on("agent_start", async (event, ctx) => { ... });
pi.on("agent_end", async (event, ctx) => { ... });
pi.on("tool_execution_start", async (event, ctx) => { ... });
pi.on("tool_execution_end", async (event, ctx) => { ... });
pi.on("message_start", async (event, ctx) => { ... });
pi.on("message_update", async (event, ctx) => { ... });
pi.on("message_end", async (event, ctx) => { ... });
```
**Event-Payloads (relevante Felder):**
| Event | Payload | Code-Beleg |
|---|---|---|
| `agent_start` | `{ type: "agent_start" }` | types.d.ts:223 |
| `agent_end` | `{ type: "agent_end", messages: AgentMessage[] }` | types.d.ts:228 |
| `tool_execution_start` | `{ toolCallId, toolName, args }` | types.d.ts:261 |
| `tool_execution_end` | `{ toolCallId, toolName, result, isError }` | types.d.ts:275 |
| `message_update` | `{ message, assistantMessageEvent }` | types.d.ts:250 |
**Lücken:**
- ❌ **Kein explizites `awaiting-input` Event.** Das nächstbeste ist `agent_end`, welches signalisiert dass der Agent-Loop beendet ist. Kombiniert mit `ctx.isIdle()` kann man ableiten, ob Pi auf User-Input wartet.
- ❌ **Kein `thinking` vs. `idle` State-Enum.** Muss selbst aus Events abgeleitet werden.
**Ableitung des Pi-States für S-07:**
```typescript
// Pseudo-Code für die Sidecar-Logik
let state: "thinking" | "tool" | "idle" | "awaiting-input" = "idle";
pi.on("agent_start", () => {
state = "thinking";
broadcast({ type: "state", value: "thinking" });
});
pi.on("tool_execution_start", (event) => {
state = "tool";
broadcast({ type: "state", value: "tool", tool: event.toolName });
});
pi.on("tool_execution_end", () => {
state = "thinking"; // zurück zu thinking nach Tool
});
pi.on("agent_end", (event, ctx) => {
if (ctx.isIdle()) {
state = "awaiting-input";
broadcast({ type: "state", value: "awaiting-input" });
} else {
state = "thinking"; // Follow-up-Messages pending
}
});
```
**Code-Beleg (bestehende Extension nutzt diese Events):**
```typescript
// pi-remote-control/extensions/remote-control/index.ts, Zeile 89-109
pi.on("agent_start", async (_event, ctx) => {
server?.broadcast({ type: "agent_start" });
updateStatus(ctx);
});
pi.on("agent_end", async (_event, ctx) => {
server?.broadcast({ type: "agent_end" });
updateStatus(ctx);
});
pi.on("tool_execution_start", async (event) => {
server?.broadcast({
type: "tool_start",
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
});
});
```
**Fazit:** S-07 State-Side-Channel ist **machbar**. Die Granularität ist ausreichend für die Spec-Anforderungen (thinking, tool, idle, awaiting-input). Kein Upstream-Change nötig.
---
### 3.2 Slash-Command-Registry
**Was existiert:**
```typescript
// Quelle: dist/core/extensions/types.d.ts, Zeile 772
pi.getCommands(): SlashCommandInfo[];
```
**Return-Type:**
```typescript
// Quelle: dist/core/slash-commands.d.ts, Zeile 1-9
export interface SlashCommandInfo {
name: string;
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo: SourceInfo;
}
```
**Beispiel:**
```typescript
pi.on("session_start", async (event, ctx) => {
const commands = pi.getCommands();
// [
// { name: "new", description: "Start a new session", source: "extension", ... },
// { name: "fork", description: "Fork from an entry", source: "extension", ... },
// { name: "tree", description: "Show conversation tree", source: "extension", ... },
// { name: "my-custom", description: "...", source: "skill", ... }
// ]
// Für S-08 Endpoint /sessions/<id>/commands:
const payload = commands.map(c => ({
name: c.name,
description: c.description,
source: c.source
}));
// Send via WebSocket or REST
});
```
**Code-Beleg:** Die bestehende Extension nutzt `pi.registerCommand()` aber `pi.getCommands()` ist bisher nicht verwendet. Die Funktion ist aber in `types.d.ts:772` klar definiert.
**Fazit:** S-08 Slash-Command-Registry ist **direkt machbar**, out-of-the-box. Kein Hack, kein Upstream-Change nötig.
---
### 3.3 Conversation Tree
**Was existiert:**
```typescript
// Quelle: dist/core/extensions/types.d.ts, Zeile 87
ctx.sessionManager: ReadonlySessionManager;
```
**Relevante Methoden (dist/core/session-manager.d.ts):**
```typescript
interface ReadonlySessionManager {
getBranch(fromId?: string): SessionEntry[];
getTree(): SessionTreeNode[];
getLeafId(): string | null;
getEntry(id: string): SessionEntry | undefined;
getChildren(parentId: string): SessionEntry[];
getSessionName(): string | undefined;
// ...
}
```
**Tree-Structure:**
```typescript
// Quelle: session-manager.d.ts, Zeile 74-81
export interface SessionTreeNode {
entry: SessionEntry;
children: SessionTreeNode[];
/** Resolved label for this entry, if any */
label?: string;
/** Timestamp of the latest label change for this entry, if any */
labelTimestamp?: string;
}
```
**SessionEntry (relevant für Tree-State):**
```typescript
// session-manager.d.ts, Zeile 16-27
export interface SessionEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
export interface SessionMessageEntry extends SessionEntryBase {
type: "message";
message: AgentMessage;
}
```
**Beispiel für S-13 Tree-State-Endpoint:**
```typescript
pi.on("session_start", async (event, ctx) => {
const tree = ctx.sessionManager.getTree();
const currentLeafId = ctx.sessionManager.getLeafId();
// Tree als JSON für iOS-App serialisieren:
function serializeTree(node: SessionTreeNode) {
const entry = node.entry;
let summary = "";
let msgCount = 0;
if (entry.type === "message") {
const msg = (entry as SessionMessageEntry).message;
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: msg.content.filter(c => c.type === "text").map(c => c.text).join("");
summary = text.slice(0, 50);
}
}
// Count messages in subtree
const countMessages = (n: SessionTreeNode): number => {
let count = n.entry.type === "message" ? 1 : 0;
for (const child of n.children) count += countMessages(child);
return count;
};
msgCount = countMessages(node);
return {
id: entry.id,
parent: entry.parentId,
summary,
messageCount: msgCount,
createdAt: entry.timestamp,
children: node.children.map(serializeTree)
};
}
const treeData = {
type: "tree",
nodes: tree.map(serializeTree),
current: currentLeafId
};
// Broadcast oder per REST-Endpoint abrufbar machen
broadcast(treeData);
});
```
**Live-Updates (Subscribe-Problem):**
Es gibt **kein** `on("tree_update")` Event. Tree-Mutationen passieren nur via:
- `/fork` → triggert `session_before_fork` + `session_tree`
- `/new` → triggert `session_before_switch`
- `/compact` → triggert `session_compact`
- Checkout via `/tree` → triggert `session_tree`
**Events für Tree-Tracking:**
```typescript
// Quelle: types.d.ts, Zeile 177-189
pi.on("session_tree", async (event, ctx) => {
// event = {
// newLeafId: string | null,
// oldLeafId: string | null,
// summaryEntry?: BranchSummaryEntry,
// fromExtension?: boolean
// }
// Tree hat sich geändert → re-serialize und broadcasten
const updatedTree = serializeTree(ctx.sessionManager.getTree());
broadcast({ type: "tree", ...updatedTree });
});
pi.on("session_compact", async (event, ctx) => {
// Compaction hat Tree verändert → Update broadcasten
const updatedTree = serializeTree(ctx.sessionManager.getTree());
broadcast({ type: "tree", ...updatedTree });
});
```
**Code-Beleg (bestehende Extension nutzt getBranch):**
```typescript
// pi-remote-control/extensions/remote-control/messages.ts, Zeile 66
const branch = ctx.sessionManager.getBranch();
```
**Fazit:** S-13 Tree-State ist **machbar mit Event-basiertem Push**. Kein echtes Subscribe-Pattern, aber ausreichend für die Spec. Tree-Read ist trivial, Live-Updates erfordern Event-Handling (`session_tree`, `session_compact`). Kein Upstream-Change nötig.
---
### 3.4 Programmatic Prompt-Dispatch
**Was existiert:**
```typescript
// Quelle: types.d.ts, Zeile 758-763
pi.sendUserMessage(
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" }
): void;
```
**Was NICHT existiert:**
```typescript
// ❌ NICHT in der ExtensionAPI exposed:
pi.prompt(text: string): void; // würde Slash-Commands dispatchen
```
**Problem (aus früherer Recherche bestätigt):**
`pi.sendUserMessage()` ruft intern `session.prompt()` mit `expandPromptTemplates: false` auf, was Slash-Command-Processing überspringt. Slash-Commands wie `/fork`, `/new`, `/compact` werden als **literaler Text an den LLM gesendet**, nicht als Befehle ausgeführt.
**Code-Beleg (aus vorheriger Session):**
> Wir hatten in der vorherigen Session festgestellt: `pi.sendUserMessage()` explicitly sets `expandPromptTemplates: false`, bypassing slash command processing. `session.prompt()` (which handles slash commands) is not exposed in `ExtensionAPI`.
**Workaround-Optionen:**
#### Option A: Hack via `(pi as any)`
```typescript
// FRAGIL: greift auf interne APIs zu, kann bei Updates brechen
pi.on("some_event", async (event, ctx) => {
// @ts-ignore
const runtime = (pi as any).runtime;
if (runtime && runtime.session) {
await runtime.session.prompt("/fork abc123");
}
});
```
**Risiken:**
- ❌ Keine TypeScript-Typen
- ❌ Bricht bei internen Refactorings
- ❌ `runtime` könnte `undefined` sein je nach Timing
- ❌ Nicht dokumentiert, keine Garantie
#### Option B: Re-Implement Commands lokal
```typescript
// iOS-App sendet Command-Request
// Sidecar implementiert /fork, /new, /compact direkt via ExtensionCommandContext
pi.registerCommand("ios-fork", {
description: "Fork from iOS app",
handler: async (args, ctx) => {
// args = entryId
await ctx.fork(args, { position: "at" });
}
});
// iOS schickt dann via WebSocket:
// { type: "command", name: "ios-fork", args: "abc123" }
// Sidecar ruft pi-internen Command-Handler auf
```
**Problem:** Geht nur in interactive mode, nicht in RPC/print mode. Außerdem muss die Extension selbst die Commands implementieren, was dupliziert Code.
#### Option C: Upstream-Feature-Request
```typescript
// Gewünschte API:
pi.prompt(text: string): Promise<void>;
// oder
pi.executeCommand(commandName: string, args: string): Promise<void>;
```
**Fazit:** Gruppe T Tree-Navigation via **Slash-Command-Injection ist NICHT machbar** ohne Hack. Option A (`(pi as any)`) funktioniert vermutlich, ist aber fragil. Option B (Re-Implement) ist sauberer, aber aufwändiger. Option C (Upstream) ist langfristig richtig, aber blockiert die iOS-App.
**Empfehlung:** Für MVP: **Option B** (Re-Implement Commands in Extension). Für v2: **Upstream-Feature-Request für `pi.prompt()`**.
---
### 3.5 Tool-Call-Daten
**Was existiert:**
```typescript
// Quelle: types.d.ts, Zeile 261-278
pi.on("tool_execution_start", async (event, ctx) => {
// event = {
// type: "tool_execution_start",
// toolCallId: string,
// toolName: string,
// args: any
// }
});
pi.on("tool_execution_end", async (event, ctx) => {
// event = {
// type: "tool_execution_end",
// toolCallId: string,
// toolName: string,
// result: any, // NICHT truncated!
// isError: boolean
// }
});
```
**Code-Beleg (bestehende Extension nutzt das bereits):**
```typescript
// pi-remote-control/extensions/remote-control/index.ts, Zeile 103-127
pi.on("tool_execution_start", async (event) => {
server?.broadcast({
type: "tool_start",
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
});
});
pi.on("tool_execution_end", async (event) => {
type TextContent = { type: string; text: string };
type ToolResult = { content?: TextContent[] } | string;
const result = event.result as ToolResult;
const content = typeof result === "object" ? result.content : undefined;
const resultText = Array.isArray(content)
? content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("")
: typeof result === "string"
? result
: "";
server?.broadcast({
type: "tool_end",
toolCallId: event.toolCallId,
result: resultText,
isError: event.isError,
});
});
```
**Fazit:** Tool-Call-Daten sind **vollständig verfügbar** für S-07. Name, Args, Result (ungekürzt), Error-Status — alles da. Trivial implementierbar.
---
### 3.6 Sonstiges (Bonus)
**Weitere nützliche Capabilities, die wir ggf. übersehen haben:**
#### Model-Info
```typescript
// Quelle: types.d.ts, Zeile 90
ctx.model: Model<any> | undefined;
// Beispiel:
pi.on("model_select", async (event, ctx) => {
broadcast({
type: "model",
model: {
id: event.model.id,
name: event.model.name,
provider: event.model.provider
}
});
});
```
**Use-Case für iOS-App:** Session-Switcher (iOS-D-01) könnte pro Session anzeigen, welches Modell verwendet wurde.
#### CWD-Tracking
```typescript
// Quelle: types.d.ts, Zeile 85
ctx.cwd: string;
```
**Use-Case:** Session-Metadaten (S-09) könnte CWD pro Session exposen.
#### Abort-Signal
```typescript
// Quelle: types.d.ts, Zeile 95
ctx.abort(): void;
ctx.signal: AbortSignal | undefined;
```
**Use-Case:** iOS-App könnte einen "Stop"-Button haben, der `abort()` triggert via WebSocket-Control-Frame.
#### Context-Usage
```typescript
// Quelle: types.d.ts, Zeile 102
ctx.getContextUsage(): ContextUsage | undefined;
interface ContextUsage {
tokens: number | null;
contextWindow: number;
percent: number | null;
}
```
**Use-Case:** Status-Bar (iOS-C-01) könnte "Context: 45%" anzeigen.
---
## 4. Empfehlung pro Spec-Feature
| Spec-Feature | Status | Empfehlung | Begründung |
|---|---|---|---|
| **S-07 State Side-Channel** | ✅ Machbar | Implementieren | Events ausreichend granular, `isIdle()` + `agent_end` = awaiting-input |
| **S-08 Slash-Command-Registry** | ✅ Machbar | Implementieren | `pi.getCommands()` out-of-the-box |
| **S-13 Tree-State Side-Channel** | ⚠️ Machbar | Implementieren mit Events | Tree-Read trivial, Live-Updates via `session_tree` + `session_compact` |
| **Gruppe T Tree-Navigation** | ❌ Hack nötig | **Nicht empfohlen für MVP** | Slash-Command-Injection geht nicht. Option B (Re-Implement) aufwändig, Option A (Hack) fragil. Upstream-Request für v2. |
---
## 5. Vorgeschlagene Upstream-Änderungen
### 5.1 `pi.prompt()` für Slash-Command-Dispatch
**Problem:** Extensions können User-Messages senden (`pi.sendUserMessage()`), aber keine Slash-Commands programmatisch dispatchen. Das limitiert iOS-App-Features wie Tree-Navigation (Gruppe T), weil `/fork`, `/new`, `/compact` nur als Text an den LLM gehen, nicht ausgeführt werden.
**Vorschlag:**
```typescript
pi.prompt(text: string, options?: {
expandPromptTemplates?: boolean
}): Promise<void>;
```
**Begründung:**
- Extensions die als "Remote-Control" fungieren (RPC, iOS-App, Web-UI) brauchen die Möglichkeit, Slash-Commands im Namen des Users auszuführen.
- Bestehende `pi.sendUserMessage()` umgeht absichtlich Template-Expansion (vermutlich für Safety). Ein explizites `pi.prompt()` signalisiert Intent.
- Alternative: `pi.executeCommand(name, args)` wäre noch expliziter, aber weniger flexibel.
**Implementierung (Skizze):**
```typescript
// In extensions/wrapper.ts
api.prompt = async (text: string, options = {}) => {
runtime.assertActive();
const { expandPromptTemplates = true } = options;
// Ruft die interne session.prompt() auf, die Slash-Commands dispatched
await runtime.session.prompt(text, { expandPromptTemplates });
};
```
**Impact:** Niedrig. Nur eine neue Methode in der `ExtensionAPI`, delegiert an existierende `session.prompt()`.
---
### 5.2 `on("awaiting_input")` Event
**Problem:** Um iOS-C-02 (Push-Notification "Pi ist fertig") sauber zu implementieren, brauchen wir ein explizites Event statt Ableitung via `agent_end` + `ctx.isIdle()`.
**Vorschlag:**
```typescript
pi.on("awaiting_input", async (event, ctx) => {
// event = { type: "awaiting_input" }
});
```
**Begründung:**
- Explizit ist besser als implizit. `agent_end` + `isIdle()` funktioniert, ist aber umständlich und fehleranfällig (z.B. wenn Follow-Up-Messages pending sind).
- Semantisch klarer: "Pi wartet auf User-Input" ist ein State, kein abgeleitetes Konstrukt.
**Implementierung (Skizze):**
```typescript
// In core/agent-session.ts, nach dem agent loop:
if (this.isIdle() && !this.hasPendingMessages()) {
await this.eventBus.emit("awaiting_input", {}, this.buildContext());
}
```
**Impact:** Sehr niedrig. Ein neues Event, keine Breaking-Changes.
---
### 5.3 Tree-Subscribe-Pattern (Optional)
**Problem:** `session_tree` + `session_compact` Events decken die meisten Tree-Mutations ab, aber nicht alle (z.B. neue Messages werden per `message_end` gemeldet, nicht als Tree-Mutation).
**Vorschlag:**
```typescript
pi.on("tree_change", async (event, ctx) => {
// event = {
// type: "tree_change",
// operation: "append" | "branch" | "compact" | "fork",
// entryId: string,
// parentId: string | null
// }
});
```
**Begründung:**
- Vereinheitlicht alle Tree-Mutations in einem Event.
- Extensions müssen nicht mehrere Events tracken (`message_end`, `session_tree`, `session_compact`).
**Implementierung:** Moderater Aufwand, weil es alle Append-Operationen tracken muss.
**Impact:** Niedrig, optional. Die bestehenden Events reichen aus, aber das wäre cleaner.
**Status:** **NICE-TO-HAVE**, nicht kritisch für die iOS-App.
---
## 6. Zusammenfassung: Implementierungs-Roadmap für Sidecar
### Phase 1: S-07 State Side-Channel (MUST)
**Code-Änderungen (pi-remote-control/extensions/remote-control/server.ts):**
```typescript
interface StateMessage {
type: "state";
value: "thinking" | "tool" | "idle" | "awaiting-input";
tool?: string;
ts: number;
}
let currentState: StateMessage["value"] = "idle";
pi.on("agent_start", async () => {
currentState = "thinking";
server?.broadcast({
type: "state",
value: "thinking",
ts: Date.now()
});
});
pi.on("tool_execution_start", async (event) => {
currentState = "tool";
server?.broadcast({
type: "state",
value: "tool",
tool: event.toolName,
ts: Date.now()
});
});
pi.on("tool_execution_end", async () => {
currentState = "thinking";
server?.broadcast({
type: "state",
value: "thinking",
ts: Date.now()
});
});
pi.on("agent_end", async (event, ctx) => {
if (ctx.isIdle()) {
currentState = "awaiting-input";
server?.broadcast({
type: "state",
value: "awaiting-input",
ts: Date.now()
});
}
});
```
**Aufwand:** 1-2 Stunden
---
### Phase 2: S-08 Slash-Command-Registry (SHOULD)
**Code-Änderungen (neuer Endpoint):**
```typescript
// In server.ts, HTTP-Handler erweitern:
if (pathname === "/commands") {
const commands = pi.getCommands();
const payload = commands.map(c => ({
name: c.name,
description: c.description,
source: c.source
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
return;
}
```
**Aufwand:** 30 Minuten
---
### Phase 3: S-13 Tree-State Side-Channel (SHOULD)
**Code-Änderungen (neuer Endpoint + Events):**
```typescript
// Helper für Tree-Serialisierung
function serializeTree(ctx: ExtensionContext) {
const tree = ctx.sessionManager.getTree();
const leafId = ctx.sessionManager.getLeafId();
function mapNode(node: SessionTreeNode) {
let summary = "";
let msgCount = 0;
// Entry-Type-spezifische Logik
if (node.entry.type === "message") {
const msg = (node.entry as any).message;
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: msg.content.filter(c => c.type === "text").map(c => c.text).join("");
summary = text.slice(0, 50);
}
}
// Count messages recursive
const count = (n: SessionTreeNode): number => {
let c = n.entry.type === "message" ? 1 : 0;
for (const child of n.children) c += count(child);
return c;
};
msgCount = count(node);
return {
id: node.entry.id,
parent: node.entry.parentId,
summary,
messageCount: msgCount,
createdAt: node.entry.timestamp,
children: node.children.map(mapNode)
};
}
return {
type: "tree",
nodes: tree.map(mapNode),
current: leafId
};
}
// REST-Endpoint
if (pathname === "/tree") {
const treeData = serializeTree(ctx);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(treeData));
return;
}
// Live-Updates via Events
pi.on("session_tree", async (event, ctx) => {
const treeData = serializeTree(ctx);
server?.broadcast(treeData);
});
pi.on("session_compact", async (event, ctx) => {
const treeData = serializeTree(ctx);
server?.broadcast(treeData);
});
pi.on("message_end", async (event, ctx) => {
// Neue Message = Tree-Mutation
const treeData = serializeTree(ctx);
server?.broadcast(treeData);
});
```
**Aufwand:** 2-3 Stunden
---
### Phase 4: Gruppe T — Tree-Navigation (CONDITIONAL)
**Ohne Upstream-Change: Option B (Re-Implement)**
```typescript
pi.registerCommand("ios-fork", {
description: "Fork from iOS app (internal)",
handler: async (args, ctx) => {
const entryId = args.trim();
await ctx.fork(entryId, { position: "at" });
}
});
pi.registerCommand("ios-new", {
description: "New session from iOS app (internal)",
handler: async (args, ctx) => {
await ctx.newSession();
}
});
pi.registerCommand("ios-compact", {
description: "Compact from iOS app (internal)",
handler: async (args, ctx) => {
ctx.compact();
}
});
// WebSocket-Handler für iOS-Command-Requests
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "execute_command") {
// iOS schickt: { type: "execute_command", name: "ios-fork", args: "abc123" }
const command = registeredCommands.get(msg.name);
if (command) {
command.handler(msg.args, ctx);
}
}
});
```
**Aufwand:** 3-4 Stunden (inkl. Testing)
**Alternative mit Upstream-Change:**
Sobald `pi.prompt()` verfügbar:
```typescript
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "slash_command") {
// iOS schickt: { type: "slash_command", text: "/fork abc123" }
await pi.prompt(msg.text);
}
});
```
**Aufwand:** 30 Minuten
---
## 7. Risiken & Offene Punkte
### R-1: Timing-Race-Condition bei State-Ableitung
**Problem:** `agent_end` + `ctx.isIdle()` könnte in Randfällen falsch sein (z.B. wenn zwischen Event-Emit und `isIdle()`-Check ein Follow-Up-Message queued wurde).
**Mitigation:** Upstream-Event `awaiting_input` (siehe 5.2) wäre sauberer. Für MVP akzeptabel, für Production ggf. nachbessern.
---
### R-2: Tree-Serialisierung bei großen Sessions
**Problem:** Bei sehr langen Sessions (> 1000 Entries) kann `getTree()` + Serialisierung langsam werden.
**Mitigation:**
- Lazy-Loading: iOS-App ruft nur Subtrees ab (via `ctx.sessionManager.getChildren(parentId)`).
- Caching: Sidecar cached serialisiertes Tree und invalidiert nur bei Tree-Events.
---
### R-3: Slash-Command-Hack (`(pi as any)`) bricht bei Updates
**Problem:** Wenn wir Option A (Hack) für Gruppe T nutzen, kann das bei pi-Updates brechen.
**Mitigation:**
- Tests einbauen die warnen wenn der Hack bricht.
- Upstream-Feature-Request parallel verfolgen.
- Für MVP Option B (Re-Implement) bevorzugen.
---
### R-4: ExtensionAPI-Version-Lock
**Problem:** Wir auditieren gegen pi v0.74.0. Künftige Versionen könnten API-Changes haben.
**Mitigation:**
- In `package.json` der Extension `peerDependencies` festlegen: `"@earendil-works/pi-coding-agent": "^0.74.0"`.
- Bei pi-Updates: Re-Audit durchführen.
---
## 8. Fazit & Next Steps
**Für MVP (Phase 0):**
- ✅ S-07, S-08, S-13 sind **implementierbar ohne Upstream-Changes**
- ⚠️ Gruppe T (Tree-Navigation) **nicht empfohlen für MVP** (Hack fragil, Re-Implement aufwändig)
- → **Empfehlung:** MVP ohne Gruppe T. Tree-View als read-only (S-13), Navigation bleibt über SSH-Terminal.
**Für v2 (nach Upstream-Discussion):**
- Feature-Request für `pi.prompt()` stellen (siehe 5.1)
- Optional: `awaiting_input` Event (siehe 5.2)
- Gruppe T dann sauber implementieren
**Nächste Schritte:**
1. Spec v3 schreiben mit finalen Feature-Entscheidungen
2. Phase 0 (Stream-PoC) starten — unabhängig von diesem Audit
3. Phase 1 (Sidecar mit S-07, S-08, S-13) parallel entwickeln
4. iOS-App-Entwicklung kann starten sobald Phase 1 läuft
---
**Audit abgeschlossen.**

11
docs/reference/README.md Normal file
View File

@ -0,0 +1,11 @@
# Reference Documents
Background and design artefacts. The implementation work is driven by the
phase plans in the parent `docs/` directory.
| File | Purpose |
|---|---|
| [`SPEC-ios-app.md`](./SPEC-ios-app.md) | Final spec v3 for the iOS app and sidecar. The source of truth for what is being built. |
| [`EXTENSION-API-AUDIT.md`](./EXTENSION-API-AUDIT.md) | Audit of pi's ExtensionAPI — what's exposed, what's not, workarounds. Drives the realisability of S-07, S-08 and similar. |
| [`SPEC-ios-app-review-v1.md`](./SPEC-ios-app-review-v1.md) | Archived review thread of spec v1 → v2 with inline discussions. Historical, do not edit. |
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | Original architecture document for the existing `pi-remote-control` extension (HTML/WebSocket client). |

View File

@ -0,0 +1,749 @@
# pi-remote — iOS Native App Spec
> **Review-Modus:** Jedes Feature unten hat eine ID und kann einzeln gestrichen
> werden. Sektionen "Vision", "Principles", "Architecture" sind das Rückgrat —
> wenn die fallen, kollabiert der Rest. Alles unter "Server Features" und "iOS
> Features" ist verhandelbar. Features tragen einen Priority-Hinweis
> (`MUST` / `SHOULD` / `NICE`) als Vorschlag, nicht als Festlegung.
---
## 1. Vision
Eine iOS-App, die eine laufende `pi`-Session **byte-genau spiegelt**, wie sie
im SSH-Terminal aussieht — und durch native iOS-Mittel (Touch, Sprache,
Notifications, System-Integration) die Bedienung dieser Terminal-Umgebung
unterwegs angenehm macht.
**Kein Hybrid-Rendering.** Was im Terminal ANSI ist, bleibt in der App ANSI.
Die App fügt eine *Augmentation-Schicht* hinzu, kein paralleles UI.
**Kernszenario:** Eine Pi-Session läuft tagelang. Der Nutzer "besucht" sie
unregelmäßig — vom Mac via SSH, vom iPhone via App. Beide sehen jederzeit
denselben State. Reattach ist instant.
joe: anderer use case ist das spawnen und wechseln aktiver sessions. es muss
super easy sein, sessions zu spawnen und zu switchen. das session handling auf server seite sollte robust sein.
claude: Großer Punkt — das verändert die Gewichtung deutlich. Konkret:
- S-09 (Multi-Session) wird von SHOULD zu **MUST**, ebenso iOS-E-01.
- Wir brauchen explizite Server-Endpoints: `POST /sessions` (spawn), `DELETE /sessions/<id>` (kill), `PATCH /sessions/<id>` (rename), `GET /sessions` (list inkl. Status).
- Robustheit heißt für mich konkret: jede Session überlebt Sidecar-Restart (tmux-Sessions bleiben sowieso, wir reattach beim Boot), getrennte Ringbuffer/Log-Files pro Session, Crash einer Session zieht die anderen nicht runter.
- Spawn-Flow: User tippt in der App "+" → optional Name oder leer (dann Auto-Name, siehe S-09-Kommentar) → Server macht `tmux new-session -d -s <id> 'pi'` → Stream direkt verfügbar.
- Switching-UX siehe iOS-E-01-Reply.
Ich trage das in v2 der Spec ein und ziehe S-09 + iOS-E-01 hoch.
---
## 2. Principles (locked)
- **P-1** — Der Terminal-Stream ist die einzige Wahrheit. Strukturierte Events
dienen ausschließlich Statusanzeigen und Notifications, nie dem Rendering
des Inhalts.
- **P-2** — SSH-Erfahrung bleibt unverändert. Der Mac-Workflow ändert sich
durch dieses Projekt nicht spürbar.
- **P-3** — Reconnect ist die wichtigste Operation. Sie muss < 1s nach
App-Wake sichtbares Ergebnis liefern. joe: warum is das so wichtig?
claude: Weil iOS deine App alle paar Minuten suspended und du sie dutzendfach
am Tag öffnest. Wenn jedes Öffnen 3-5s mit einem Spinner verbringt, fühlt
sich die App "kaputt" an, selbst wenn technisch nichts kaputt ist. Bei < 1s
ist es "die App war nie weg". Das ist das Gefühl, das du willst, wenn du
in einer langen Session immer wieder reinguckst.
Wenn du das anders gewichtest, können wir es zu "< 2s" lockern aber
irgendein hartes Ziel sollten wir haben, sonst optimieren wir es nicht.
Soll das raus / weicher werden?
- **P-4** — Touch-UX wird *zur Terminal-Bedienung* gebaut, nicht *anstelle*
von Terminal-Bedienung.
- **P-5** — Solo-Use. Multi-User, Sharing, Org-Features sind out of scope.
---
## 3. Architecture (locked)
```
┌──────────────────────────────────────────────────────────┐
│ Server │
│ │
│ pi (Ink TUI) ◄─► tmux session ◄─► SSH-Client (Mac) │
│ │ │
│ │ pipe-pane (raw bytes) │
│ │ send-keys │
│ ▼ │
│ pi-remote sidecar │
│ │ │
│ │ WebSocket │
│ │ ├─ raw ANSI stream │
│ │ ├─ control (send-keys) │
│ │ └─ side-channel (state, meta) │
└─────────────────────────┼────────────────────────────────┘
┌──────────────────────────┐
│ iOS App │
│ ┌────────────────────┐ │
│ │ SwiftTerm renderer │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ Augmentation layer │ │
│ │ (slash, snippets, │ │
│ │ status, voice, │ │
│ │ markers, ...) │ │
│ └────────────────────┘ │
└──────────────────────────┘
```
**Komponenten:**
- **tmux** — Session-Persistenz, Multi-Client-Attach, Pane-Pipe, Send-Keys.
Nicht selbst gebaut.
- **Sidecar** (`pi-remote-control` erweitert) — Node-Prozess. Spawn/Reattach
tmux, exponiert WebSocket-API, hält Ringbuffer für Replay, leitet
ExtensionAPI-Metadaten als Side-Channel weiter.
- **iOS-App** — SwiftUI-Shell, SwiftTerm als Renderer-View, eigener
Augmentation-Layer.
**Datenfluss Stream:** `pi` → tmux pipe-pane → sidecar ringbuffer → WS → iOS
SwiftTerm.
**Datenfluss Input:** iOS → WS → sidecar → `tmux send-keys` → tmux pane → pi
stdin. Der Mac-SSH-Client sieht denselben Input.
---
## 4. Server Features (Sidecar)
### S-01 — tmux launcher / attach
**MUST.** Sidecar startet eine tmux-Session (`pi-main` oder pro Projekt
benannt), spawned `pi` darin. Falls Session existiert → reattach. Existiert
als CLI-Befehl `pi-remote attach`.
*Rationale:* P-2 — User-Workflow bleibt fast identisch.
*Dependencies:* —
### S-02 — Raw ANSI WebSocket-Endpoint
**MUST.** Endpoint `/stream` liefert die Bytes von `tmux pipe-pane` als
binäre WS-Frames. Eine Verbindung = ein Pane.
joe: warum können wir sicher sein, dass der stream der kommt von der ios app bzw der library so angenommen werden? sind hier fallstricke, die wir noch nicht sehen? ist der binary stream das beste für den use case? bringt uns compression hier was?
claude: Drei Teilfragen, separat beantwortet.
**(1) Kann SwiftTerm den Stream verarbeiten?**
Ja, mit Caveat. SwiftTerm ist eine vollständige VT100/xterm-Implementierung — alles was tmux/Ink rauswirft (CSI/SGR/cursor-positioning/alternate-screen-buffer/title) wird verarbeitet. **Fallstricke** die ich sehe:
- *Alternate Screen Buffer*: pi/Ink nutzt `\e[?1049h` für Full-Screen-Modus. SwiftTerm handhabt das, aber wir müssen den Scrollback-Cache (iOS-A-04) korrekt davon trennen (alternate-screen-Inhalt nicht im scrollback persistieren).
- *Mouse-Mode*: pi nutzt vermutlich kein Mouse-Tracking, aber falls eine Extension das tut, schicken wir die ANSI-Sequenzen blind an SwiftTerm — könnte auf Touch komisch sein. Lösung: Mouse-Tracking-Sequenzen optional ignorieren.
- *Terminal-Size-Mismatch*: tmux ist auf eine feste Größe konfiguriert. Wenn iPhone-Portrait nur 40 Spalten zeigt, sieht die Ausgabe gequetscht aus. Lösung: tmux per Session auf eine sinnvolle Größe (z.B. 120×40) fixen, Client rendert mit Pinch-Zoom passend.
- *Truecolor / 24bit*: SwiftTerm + Ink unterstützen beide, sollte sauber sein.
- *Title sequences*: pi setzt evtl. den Terminal-Title — wir können das als Side-Channel-Hinweis nutzen oder ignorieren. joe: brauchen wir nicht
**(2) Binär vs. Text-Frame?**
Binär. Begründung: ANSI ist 8-bit-clean (256-color codes, UTF-8 in vielen Bytes) — als Text-Frame müssten wir Base64'en (33% Overhead) oder JSON-escapen (variabel, oft schlimmer). Binär ist auch das, was WebSocket-Stacks für `ArrayBuffer`/`Data` direkt vertragen. Side-Channel-Events bleiben separate Text-Frames (JSON). Multiplexing per Frame-Typ.
**(3) Compression?**
Ja, lohnt sich. `permessage-deflate` (WebSocket-Standard-Extension) ist genau dafür gemacht. ANSI-Streams sind hochgradig redundant (gleiche SGR-Codes, gleiche Sequenzen), typische Kompressionsrate 3-5×. Für Mobile-Daten und Reconnect-Snapshots (die schnell mehrere MB groß werden) macht das einen spürbaren Unterschied. Auf Server-Seite billig in `ws` (Node-Library), auf iOS-Seite muss der WS-Client `permessage-deflate` unterstützen — `Starscream` (Swift WS-Lib) tut das.
**Empfehlung:** binär + `permessage-deflate` an, alternate-screen sauber tracken, Terminal-Size auf 120×40 fixieren als Default. Soll ich das in der Spec konkret festschreiben?
*Rationale:* P-1 — Direkter Stream als einzige Render-Quelle.
*Dependencies:* S-01
### S-03 — Send-Keys-Endpoint
**MUST.** WS-Nachrichten vom Client mit Keystroke-Payload werden in
`tmux send-keys -l` (literal) oder Key-Namen übersetzt und injiziert.
*Rationale:* Input-Pfad ohne den der Stream nutzlos ist.
*Dependencies:* S-01
### S-04 — Sequence-Cursor & Delta-Replay
**MUST.** Sidecar nummeriert ausgehende Bytes (oder Chunks). Client sendet
bei Reconnect `lastSeq`, Server liefert ab dort weiter.
*Rationale:* P-3 — Reconnect-Performance.
*Dependencies:* S-02
### S-05 — Snapshot-Endpoint
**MUST.** Wenn `lastSeq` außerhalb des Ringbuffers liegt: Server liefert
`tmux capture-pane -p -e -S -10000` als Snapshot + neuen Start-Seq.
*Rationale:* Fallback wenn App lange weg war.
*Dependencies:* S-01, S-04
### S-06 — Ringbuffer auf Disk
**SHOULD.** Sidecar persistiert die letzten N MB ANSI-Bytes pro Pane auf
Disk (z.B. `/var/lib/pi-remote/buffer/<session>.log`). Erlaubt Replay
weiter zurück als tmux-Scrollback.
joe: fokus hier auch auf das log file management. ich würde vermeiden wollen das die platte irgendwann vollläuft.
claude: Berechtigt. Konkretisierungsvorschlag für die Spec:
- **Pro Session**: Ringbuffer-Datei `/var/lib/pi-remote/buffer/<session>.log`, hartes Limit (default 100MB), Rotation in 10MB-Chunks, älteste Chunks werden gelöscht sobald Limit überschritten. joe: keine rotation. eine file pro session. wenn die 100mb überlaufen, dann is das halt so.
- **Global**: Hartes Total-Cap (default 1GB) über alle Sessions; wenn überschritten, werden die ältesten Chunks der größten Session gekürzt.
- **Idle-Cleanup**: Sessions die seit > 30 Tagen keinen Output erzeugt UND keinen Client gesehen haben → Buffer wird komprimiert/archiviert oder gelöscht (konfigurierbar).
- **Disk-Watchdog**: Sidecar checkt periodisch verfügbaren Platz; wenn < 1GB frei, schaltet er Buffer-Schreiben ab und meldet Health-Status "degraded" (S-13).
- Konfigurierbar via `~/.config/pi-remote/config.toml`.
Fertig in die Spec übernehmen? joe: ja
*Rationale:* "Sessions die tagelang laufen" können sehr großen Output
ansammeln; tmux-history wird truncated.
*Dependencies:* S-02
### S-07 — State Side-Channel
**SHOULD.** Sidecar abonniert pi-ExtensionAPI-Events (`thinking`,
`tool-start`, `tool-end`, `idle`, `awaiting-input`) und sendet sie über
denselben WS als JSON-Control-Frames.
*Rationale:* Quelle für Status-Bar und Notifications. Nicht für Rendering.
*Dependencies:* S-01
### S-08 — Slash-Command-Registry
**SHOULD.** Endpoint `/commands` liefert JSON-Liste der verfügbaren
Slash-Commands (Name, Beschreibung, Argument-Schema) aus pi's Registry.
Dynamisch — Extensions die Commands hinzufügen erscheinen automatisch.
*Rationale:* Speist die iOS-Command-Palette (iOS-D-01).
*Dependencies:* S-01
### S-09 — Multi-Session-Management
**SHOULD.** Sidecar kann mehrere tmux-Sessions parallel verwalten
(z.B. eine pro Projekt). Endpoint `/sessions` listet sie, Client wählt
beim Connect.
joe: client (also ios ux) sollte am besten alle sessions vom looka nd feel so anzeigen als wären sie direkt connected/zugreifbar. können wir mit tmux da auch irgendwie session namen oder descriptions vergeben? optional dynamisch von pi benannt (mini prompt, wenig tokens)?
claude: Drei separate Themen, alle ja:
**(a) Sessions "als wären sie connected":**
Die App connectet im Hintergrund parallel zu allen bekannten Sessions, hält für jede einen rolling Mini-Buffer (z.B. letzte 100 Zeilen) im RAM, plus die letzte gerenderte Frame-Snapshot. Switch = Render-View wechselt instant, im Hintergrund läuft Sync für volle Historie wenn nötig. → siehe auch iOS-E-01-Reply.
**(b) Session-Namen & Descriptions in tmux:**
- Name: tmux-Sessions HABEN bereits Namen (`tmux new -s <name>`). Wir nutzen den als stable ID.
- Description: tmux unterstützt User-Options, `tmux set-option -t <session> @description "…"`. Lesbar via `tmux show-options -t <session> -v @description`. Beliebige Metadaten so anhängbar (z.B. `@project`, `@created`).
- Wir können das mit unserem eigenen JSON-State-File im Sidecar kombinieren wenn wir mehr brauchen. joe: keinen weiteren state selbst handlen. tmux oder gar nicht
**(c) Auto-Naming via Pi (mini-prompt):**
Gute Idee. Konkret: nach ~3 User-Messages in einer namenlosen Session feuert der Sidecar einen Cheap-LLM-Call (haiku-tier, ~50 Tokens Output) gegen den bisherigen Verlauf: "Gib einen 2-4-Wort-Titel für diese Konversation." Ergebnis landet als `@description`. Manuelles Umbenennen aus der App jederzeit möglich und überschreibt das.
Open Question: Welcher Mini-LLM, welche Credentials? Wahrscheinlich derselbe Provider wie pi selbst — wir borgen pi's Konfiguration aus der Extension. Soll ich das als sub-feature S-09a notieren? joe: wir nutzen pi direkt. die cli bietet one shot an. als model nutzen wir haiku. anthropic auth ist in pi schon configuriert.
*Rationale:* Speist Session-Switcher (iOS-E-01).
*Dependencies:* S-01
### S-10 — Bearer-Token-Auth
**MUST.** WS-Verbindungen erfordern Bearer-Token aus Sidecar-Config.
Per-Device-Tokens möglich.
joe: auth mit mbile sollte einfach gehen, qr code im terminal oder so.
claude: Ja, sauberer Pfad. Konkret:
- Sidecar hat einen CLI-Befehl `pi-remote pair`. Generiert ein Pairing-Token (kurzlebig, z.B. 5min gültig).
- Druckt einen QR-Code im Terminal (Unicode block chars, funktioniert per SSH).
- QR enthält: WS-URL + Pairing-Token + (siehe S-11-Reply) TLS-Fingerprint.
- iOS-App scannt → tauscht Pairing-Token gegen permanenten Bearer-Token, speichert im Keychain (iOS-G-01).
- Token kann per Device benannt sein ("jay's iPhone"), revocable via CLI (`pi-remote auth list/revoke`).
QR-Library Server-seitig: `qrcode-terminal` (Node) — winzig, kein Aufwand.
*Rationale:* Sidecar ist ggf. öffentlich erreichbar.
*Dependencies:* —
### S-11 — TLS-Termination (Hinweis)
**MUST in Production.** WS via `wss://` hinter Reverse-Proxy oder
nativ. Kein Plaintext über öffentliche Netze.
joe: einfaches handling von self signed certificates. pinning via onboarding qr code oder so.
kein lust auf pki xD
claude: Genau der Plan. Trust-on-First-Use via QR:
- Sidecar generiert beim ersten Start ein selbstsigniertes Cert (ED25519 oder RSA-2048), persistent auf Disk.
- `pi-remote pair` QR enthält neben Token auch den SHA-256-Fingerprint des Server-Certs.
- iOS-App pinnt den Fingerprint beim Pairing fest. Jede künftige Connection verifiziert dagegen.
- Cert läuft nie ab (oder 10 Jahre — egal, wir validieren ja nur den Fingerprint, nicht die CA-Chain).
- Kein Let's Encrypt, kein DNS, kein Reverse-Proxy nötig — solange du eine IP/Hostname hast die das iPhone erreicht.
- Falls Cert rotiert wird (z.B. neuer Sidecar-Host): re-pairing via neuem QR.
Kein PKI, kein CA, kein Browser-Warning-Hell. Klar?
### S-12 — Marker-Detection / Prompt-Grenzen
**NICE.** Sidecar versucht heuristisch oder via Extension-Hook,
Prompt-Grenzen im Stream zu erkennen und als Marker im Side-Channel zu
publizieren (`{type: "marker", seq: 12345, label: "user-prompt-start"}`).
joe: wie robust ist das. gibt pi hier consistente marker?
claude: Ehrlich: ich weiß es nicht sicher. Vermutung: pi/Ink gibt **keine** expliziten ANSI-Marker-Sequenzen aus, die wir parsen könnten — Ink redrawt komplette Frames, das ist alles ein einziger Stream-of-Updates. Heuristisch ginge nur "Zeile beginnt mit `> ` oder ``" o.ä., das ist brüchig.
DU hast in Q-4 schon gesagt "gar nicht rein als feature". Damit fällt S-12 + iOS-E-03 + iOS-B-04 zusammen. Cleaner so. → Ich streiche S-12 in v2.
*Rationale:* Speist Jump-to-Marker (iOS-E-03).
*Dependencies:* S-07
### S-13 — Health & Metrics
**NICE.** `/health` Endpoint mit Session-Count, Buffer-Size, Connected
Clients. Für Monitoring.
*Rationale:* Selbst-Debugging im Long-Running-Setup.
*Dependencies:* —
---
## 5. iOS Client Features
### Gruppe A — Rendering & Stream
#### iOS-A-01 — SwiftTerm-Renderer
**MUST.** Vollwertiger ANSI-Terminal-View, der den raw Stream rendert.
Konfigurierbar: Font (monospace), Größe, Farbschema.
*Rationale:* P-1 — der Stream wird gerendert, nicht interpretiert.
*Dependencies:* S-02
#### iOS-A-02 — Sequence-Cursor & Reconnect
**MUST.** Client speichert lokal die letzte `seq` pro Session.
Bei WS-Reconnect: schickt `lastSeq`, verarbeitet Delta oder Snapshot.
*Rationale:* P-3.
*Dependencies:* S-04, S-05
#### iOS-A-03 — Stale-Frame-Display
**SHOULD.** Während Sync nach App-Wake: letzten gerenderten Frame
einfrieren, "syncing…" als subtile Overlay-Pill anzeigen. Kein leerer
Screen.
*Rationale:* Wahrnehmung: App fühlt sich immer "live" an.
*Dependencies:* iOS-A-02
#### iOS-A-04 — Local Scrollback-Cache
**SHOULD.** App puffert empfangene Bytes lokal (rolling, z.B. 5MB) für
Offline-Scrolling und Suche, auch wenn WS offline.
*Rationale:* Speist iOS-E-02 (Scrollback-Search). Reduziert Server-Hits.
*Dependencies:* iOS-A-01
#### iOS-A-05 — Theme-Editor
**NICE.** Color-Schemes (Solarized, Dracula, custom), Font-Size,
Line-Height. iCloud-Sync der Preferences.
joe: gerne eine klassische liste bekannter defaults mitliefern (inkl. monokai o.vgl.)
claude: 👍 — ich schreibe in v2 eine konkrete Liste ein: **Solarized Light/Dark, Monokai, Dracula, Nord, Gruvbox Dark/Light, Tomorrow Night, GitHub Light/Dark, System (folgt iOS Dark Mode), Default**. Plus "Custom" für eigene Farbtabellen. iCloud-Sync nur für Custom-Themes.
joe: top. ich will auch unbedingt mind. "Hack" und Jetbrains Mono als font.
*Rationale:* Lange Lesephasen → Augenkomfort.
*Dependencies:* iOS-A-01
### Gruppe B — Input & Modifier
#### iOS-B-01 — Software-Keyboard + Text-Input
**MUST.** Standard-iOS-Keyboard. Text wird beim Tippen oder beim
Enter-Tap an `send-keys` geschickt.
joe: leeres enter sendet new line im editor
claude: Hmm, ich muss das nochmal verstehen — meinst du:
**(a)** Eingabefeld der App ist ein **mehrzeiliges Compose-Feld** (du tippst kompletten Prompt mit Newlines, drückst dann einen separaten "Send"-Button). Enter im Feld = Newline einfügen.
Vorteil: bequem für längere Prompts, kein Risiko versehentlich abzuschicken.
Nachteil: nicht 1:1 wie im Terminal — pi sieht den Prompt erst beim Send als ein Block.
**(b)** Eingabe ist **direkt-passthrough** (jeder Tastendruck geht sofort als `send-keys`). Enter sendet `\r` an pi → pi terminiert Eingabe und schickt sie an den Agent. Wenn du in pi's eigenem multi-line-Modus bist (Shift-Enter o.ä.), entsprechend.
Ich hatte (b) im Kopf, weil P-1/P-4 (Terminal-Bedienung). Aber dein Kommentar klingt nach (a) mit einer Hybrid-Regel: "leeres Enter = newline einfügen" (also Modus a, nur smart).
Welche Variante meinst du? Mein Vorschlag: **(b) als Default, (a) optional togglebar** — weil wir sonst pi's eigenes Input-Handling (z.B. multi-line-Mode in pi) duplizieren und Bugs kriegen.
joe: du hast recht. es ist b. aber ich will den shift+enter easily available haben. termius hat shortcuts die man auf volume down und up legen kann. oder doch besser in der status leiste?
*Rationale:* Die Basis.
*Dependencies:* S-03
#### iOS-B-02 — Modifier-Bar
**MUST.** Akzessoriumsleiste über der Tastatur mit: `ESC`, `Tab`,
`Ctrl`, `Alt`, `↑↓←→`, `Enter`. Sticky-Toggle für Modifier (ein Tap =
hält bis zur nächsten Eingabe).
joe: welche modifier machen hier sinn? wir sind ja pi agent centric. gibts noch alternativen. der platz ist ja begrenzt.
claude: Guter Punkt — generischer Terminal-Approach passt nicht zu pi-centric. Konkret was pi tatsächlich braucht:
**Aus pi-Usage tatsächlich essenziell:**
- `Esc` — pi abbrechen / aus Modi raus
- `↑/↓` — History scrollen, in Auswahllisten navigieren
- `Tab` — Autocomplete in pi-Eingabe (Dateien, Commands)
- `Enter` — senden
- `Ctrl-C` — laufendes Tool abbrechen / pi-Action stoppen joe: macht das nicht Esc eh schon?
**Nice-to-have:**
- `Ctrl-D` — pi beenden (vielleicht versteckt, zu gefährlich für leichte Touch)
- `Ctrl-L` — clear
**Vorschlag finale Modifier-Bar (pi-tuned):**
```
[Esc] [Tab] [↑] [↓] [Ctrl-C] [/] [🎙] joe: lieber ctrl als ctrl+c. und / kann raus.
```
- `/` ist Shortcut: öffnet Slash-Command-Palette (iOS-C-04) ODER tippt einfach `/` ein
- `🎙` ist Voice-Input (iOS-C-06)
- Pfeile nur ↑/↓ weil ←/→ via Touch-Cursor selten (und Touch-Cursor flog ja raus) joe: link rechts pfeile brauche ich eben weil touch cursor rausflog)
- Ctrl-C als eigener Button weil häufigste Aktion
- Kein Alt/Meta, kein Ctrl-als-Modifier-Toggle — wir mappen 2-3 wichtige Ctrl-Combos auf eigene Buttons, fertig joe: s.o.
Wenn man im pi-internen Editor (für längere Prompts) ist, könnten ←/→ einblendbar sein context-aware. Aber MVP ohne.
Passt das? Was fehlt dir noch in deinem täglichen pi-Flow?
*Rationale:* Ohne ESC und Pfeile ist iOS-Terminal-Nutzung untauglich.
*Dependencies:* iOS-B-01
#### iOS-B-03 — Long-Press-Repeat
**SHOULD.** Long-Press auf Pfeiltasten / Backspace → repeat mit
Beschleunigung.
*Rationale:* Lange Navigation in Prompts.
*Dependencies:* iOS-B-02
#### iOS-B-04 — Tap-to-Cursor
**SHOULD.** Tippen auf eine Position in der aktuellen pi-Eingabezeile →
App schickt entsprechend viele `←`/`→` um den Cursor dahin zu bewegen.
*Rationale:* Cursorbewegung per Touch — einer der "killer" Hebel den ein
dummes Terminal nicht hat.
*Dependencies:* iOS-A-01, iOS-B-02. Heuristik: aktuelle Zeile = letzte
Zeile vor Prompt-Marker; ohne S-12 nur best-effort.
joe: brauchen wir nicht fliegt raus.
claude: ✂️ raus. Konsistent mit dem Marker-Drop.
#### iOS-B-05 — Selection & Copy
**MUST.** Doppel-Tap → Wort. Tripel-Tap → Zeile. Lange-Press + Drag →
Range. Native iOS-Copy-Menu.
*Rationale:* Output ist oft das Wertvolle (Code, IDs, Pfade).
*Dependencies:* iOS-A-01
#### iOS-B-06 — Pinch-Zoom Font
**SHOULD.** Pinch in der Terminal-View → Font-Size live.
*Rationale:* Lesbarkeit.
*Dependencies:* iOS-A-01
joe: love it
claude: 🤝
#### iOS-B-07 — Hardware-Keyboard-Support
**SHOULD.** Externe iPad-Keyboards: Caps→Esc-Remap (optional),
Modifier-Pass-Through (Cmd, Option, Ctrl), Shortcuts wie Cmd-K → klar
definierte App-Aktionen.
*Rationale:* iPad mit Magic Keyboard = ernsthafte Pi-Workstation.
*Dependencies:* iOS-B-01
joe: love it
claude: 🤝 — wird in v2 noch konkretisiert welche Cmd-K Shortcuts sinnvoll sind (Cmd-K Clear, Cmd-T new session, Cmd-1..9 session switch, Cmd-F search).
#### iOS-B-08 — Reachability / One-Hand-Mode
**NICE.** iPhone-Querformat: Modifier-Bar + Action-Buttons auf einer
Seite gespiegelt, daumengerecht.
*Rationale:* Einhandbedienung.
*Dependencies:* iOS-B-02
#### iOS-B-09 — Smart Paste
**SHOULD.** Clipboard-Indicator in der Modifier-Bar. Tap → Preview
(insb. multi-line), Confirm → paste. Bei Pi-detection (z.B. bracketed
paste mode an) korrekt einrahmen.
joe: check ich nicht. erkläre nochmal
claude: Sorry, zu kryptisch. Zwei Sachen die zusammenhängen:
**"Smart Paste" = Confirm-vor-Paste**
Problem: du kopierst auf dem iPhone aus Safari einen Stacktrace, wechselst in die pi-App, tappst aus Versehen auf "Paste" → riesiger Blob wird an pi geschickt, möglicherweise mit ANSI-Escape-Codes oder Geheimnissen drin. Schwer zu undo.
Lösung: Clipboard-Indicator in der Modifier-Bar zeigt einen kleinen Vorschau-Chip ("📋 12 lines, 847 chars"). Tap → Sheet mit vollem Preview + "Insert" / "Cancel". Erst dann geht's an pi.
**"Bracketed Paste Mode" = pi unterscheidet getippte vs. gepastete Eingabe**
Technischer Hintergrund: moderne Terminals wrappen gepastete Eingabe in spezielle ESC-Sequenzen (`\e[200~` vor Paste, `\e[201~` danach). pi/Ink kann dann erkennen "das war Paste, nicht getippt" und z.B. den ganzen Block als einen Prompt nehmen statt nach jedem Newline zu submitten. Wenn pi diesen Modus aktiviert (`\e[?2004h` im Stream), muss unsere App das mitkriegen und beim Paste die richtigen Sequenzen drumherum schicken.
Die beiden Sachen sind orthogonal — Confirm-vor-Paste ist UX, Bracketed-Paste ist Protocol-Correctness. Ich hatte sie in einem Bullet gemischt, das war unklar. In v2 trenne ich sie in **iOS-B-09a (Paste-Confirm)** und **iOS-B-09b (Bracketed-Paste-Compliance)**.
Macht das mehr Sinn? Beide drin lassen, oder nur eines?
joe: passt.
*Rationale:* Sicherer als blindes Paste.
*Dependencies:* iOS-B-01
### Gruppe C — Pi-aware Augmentation
#### iOS-C-01 — Status-Bar
**MUST.** Top-Bar zeigt Connection-Status, Session-Name und
abgeleiteten Pi-State (`● thinking` / `⏵ tool: Edit` / `▶ waiting` /
`⏸ idle`) basierend auf Side-Channel-Events.
*Rationale:* Schneller Blick: was tut Pi gerade?
*Dependencies:* S-07
#### iOS-C-02 — Push-Notification bei State-Change
**MUST.** Wenn App im Background und Pi wechselt zu
`awaiting-input` → Push-Notification "Pi ist fertig". Tap →
App in der richtigen Session.
*Rationale:* Killer-Feature für "Session läuft lange, kommt wieder
wenn fertig".
*Dependencies:* S-07, APNs-Setup
#### iOS-C-03 — Haptic Feedback bei State-Wechseln
**NICE.** Subtile Vibration bei `thinking → idle` Übergang.
*Rationale:* Fühlt sich "lebendig" an.
*Dependencies:* S-07
#### iOS-C-04 — Slash-Command-Palette
**SHOULD.** Swipe-up-Sheet mit nativer Liste aller Slash-Commands
(aus S-08), fuzzy-search, Tap → injiziert via `send-keys`. Bei
Commands mit Argumenten → Formular-View für die Eingabe.
*Rationale:* Slash-Commands sind im Terminal mühsam zu tippen.
*Dependencies:* S-03, S-08
#### iOS-C-05 — Snippet-Library
**SHOULD.** Vorgefertigte Prompts mit Variablen ("review {{branch}}
for security"). iCloud-Sync. Native Edit-UI.
*Rationale:* Wiederkehrende Prompts.
*Dependencies:* iOS-B-01
joe: will ich mit pi internen tools lösen, kann raus
claude: ✂️ raus. Macht Sinn — Snippets gehören thematisch zu pi, nicht zum Remote-Client.
#### iOS-C-06 — Voice-to-Prompt
**NICE.** Mikro-Button → Speech-Recognition (lokal, iOS native) →
editable Preview → Send.
*Rationale:* Unterwegs schneller als tippen.
*Dependencies:* iOS-B-01, Mic-Permission
### Gruppe D — Session & Navigation
#### iOS-E-01 — Session-Switcher
**SHOULD.** Native iOS-Liste der verfügbaren tmux-Sessions (aus S-09).
Wechsel = neuer WS-Stream, vorhergehender wird im Hintergrund weiter
gepuffert.
joe: kann man hier schon auf basis einer bestimmten aktion des users preloaden? wäre nice, wenn es sich so anfühlt als wäre die session "schon immer da gewesen" :D
claude: Yes! Konkret zwei Strategien kombinierbar:
**(1) Background-Pre-Connect aller Sessions**
App connectet im Hintergrund zu allen bekannten Sessions parallel, hält für jede einen kleinen In-Memory-Stream-Buffer. Switch = Renderer wechselt instant, ist schon synchronisiert. Kosten: paar Sockets + paar MB RAM für Buffer pro Session. Akzeptabel solange du < ~10 aktive Sessions hast.
**(2) Optimistic-Switch mit Stale-Frame**
Swipe zu Session B → sofort den letzten gerenderten Frame von B zeigen (lokal gecached), gleichzeitig live-Stream nachholen. Du siehst niemals einen leeren Screen, nur eine kurze "sync"-Pille.
**(3) Bonus: Predictive Pre-Render bei Switcher-Open**
Wenn User die Switcher-Liste öffnet → App pingt alle Sessions, holt Frame-Snapshots, zeigt Live-Thumbnails (kleine Mini-Terminal-Vorschauen in der Liste). Switch ist dann visuell "ich sehe schon was drin ist".
Kosten: trivial server-seitig (capture-pane), client-seitig moderate Memory.
Ich trage (1) + (2) + (3) in v2 als iOS-E-01a/b/c ein. Soll (3) rein? Wäre wirklich "feels-magic"-Material.
*Rationale:* Pro Projekt eine Pi-Session.
*Dependencies:* S-09
#### iOS-E-02 — Scrollback-Search
**SHOULD.** Such-Sheet sucht im lokalen Scrollback-Cache (iOS-A-04)
mit Highlight + Jump-to-Match.
*Rationale:* "Wo war das eine Snippet?" — Standard-Bedarf.
*Dependencies:* iOS-A-04
#### iOS-E-03 — Jump-to-Marker
**NICE.** Vertikales Swipe-Gesture springt zwischen Prompt-Grenzen.
Visualisiert als kleine Edge-Indicator-Linien.
*Rationale:* "Vor 3 Antworten gab's diese Idee" — Standard-Bedarf.
*Dependencies:* S-12, iOS-A-01
joe: lassen wir raus. dazu aber ein anderer gedanke. wie viel info können wir reliable von pi über die session und die tree view bekommen? das feature vom pi ist so zentral, dass es nativen ios support verdient
claude: ✂️ iOS-E-03 raus. Aber der **andere Gedanke** ist groß — wert eines eigenen Threads.
**Zur Frage: Tree-View nativ in iOS rendern.**
Das wäre die einzige Stelle, an der wir P-1 ("Stream ist die einzige Wahrheit") aufweichen. Argument dafür: Tree-State ist **Meta-State der Session**, nicht **Inhalt der Session**. Das ist eine andere Kategorie als "Markdown rendern statt ANSI". Konsistent argumentierbar.
**Was wir bräuchten (Server-seitig):**
Ein neuer Side-Channel-Endpoint, der pi's Tree als strukturiertes JSON liefert + Live-Updates bei Branch/Fork/Compact. Format ungefähr:
```json
{
"nodes": [
{"id": "abc", "parent": null, "summary": "main", "messageCount": 42, "createdAt": "..."},
{"id": "def", "parent": "abc", "summary": "explored iOS", "messageCount": 18, ...}
],
"current": "def"
}
```
**Was wir brauchen (Client-seitig):**
Ein natives SwiftUI-Tree-View-Sheet. Tap auf Knoten → entweder direkter Switch via `/checkout <id>` an pi gesendet, oder Preview-Modus mit Stream-Snapshot des Knotens.
**Großes Aber:**
Das hängt komplett davon ab, was die ExtensionAPI hergibt. Das ist genau das Thema von Q-2/Q-3 (was kann die ExtensionAPI?). Wir hatten in der vorherigen Session festgestellt: `pi.sendUserMessage()` umgeht Slash-Command-Processing, `session.prompt()` ist nicht exposed, der Tree wird intern in der Runtime gehalten — Zugriff auf Tree-State ist nicht öffentlich.
**Vorschlag:** Ich trage in v2 einen neuen Feature-Block **Gruppe T — Tree Navigation** ein, mit dem expliziten Hinweis "depends on ExtensionAPI investigation". Konkret:
- **S-14 (NEU)** — Tree-State-Endpoint + Live-Updates (server-seitig, bedingt machbar)
- **iOS-T-01 (NEU)** — Native Tree-View mit Checkout-Action
- **iOS-T-02 (NEU)** — Branch/Fork-Action aus der App heraus (`/fork`, `/new`, `/compact` via slash-command-injection)
Ohne ExtensionAPI-Verbesserung sind iOS-T-01/T-02 nur via Slash-Command-Injection halbgar machbar (wir sehen den Tree nicht, können aber Commands schicken). Mit ExtensionAPI-Erweiterung wird's mächtig.
Soll ich diese Gruppe T in v2 mit aufnehmen + als ersten Spike-Punkt "ExtensionAPI-Audit" definieren?
#### iOS-E-04 — Bookmarks
**NICE.** Long-Press auf Stelle im Scrollback → benannte Marke.
Bookmarks-Sheet listet sie, Tap → Jump.
*Rationale:* Manuelle Marker für wichtige Stellen.
*Dependencies:* iOS-A-04
joe: brauchen wir nicht. raus
claude: ✂️ raus.
### Gruppe E — Background & Lifecycle
#### iOS-F-01 — WS-Keepalive
**MUST.** Ping/Pong-Frames im Foreground halten Connection auf
zombie-detection-bewussten iOS-Stacks.
*Rationale:* iOS suspended WS gerne ohne Notification.
*Dependencies:* S-02
#### iOS-F-02 — Wake-up-Sync
**MUST.** App-Foreground-Event triggert sofort Reconnect + Delta-Pull.
Spinner falls > 200ms.
*Rationale:* P-3.
*Dependencies:* iOS-A-02
#### iOS-F-03 — Silent-Push-Trigger für Wake
**NICE.** Server kann Silent-Push schicken um App zu wecken bevor
User selbst auf das Icon tippt → Daten sind schon da.
*Rationale:* "App fühlt sich an als wär sie nie weg gewesen."
*Dependencies:* S-07, APNs
joe: wann sollte sie das tun? geht das überhaupt mit ios aggresivem app lifecycle management?
claude: Geht — aber mit Caveats die das Feature ziemlich entwerten.
**Mechanik:** APNs unterstützt "Silent Pushes" (`content-available: 1`, kein Alert). iOS weckt die App im Background für ~30s Processing-Zeit. App kann WebSocket aufmachen, Delta pullen, schlafen.
**Caveats:**
- iOS coalesced/dropped Silent Pushes nach eigenem Gusto. Apple gibt keine Garantie der Zustellung, nur "best effort".
- Budget ist limitiert: ~2-3 erfolgreiche Background-Wakes pro Stunde, danach drosselt iOS aggressiv.
- Wenn User die App im App-Switcher force-quit-tet, kommen **gar keine** Background-Wakes mehr — auch Silent Push nicht.
- Battery / Low-Power-Mode killt das komplett.
**Realistischer Wert für uns:** marginal. Das echte Killer-Feature ist iOS-C-02 (sichtbare Push wenn pi `awaiting-input` erreicht) — die ist nicht silent und wird zuverlässig zugestellt.
**Vorschlag:** iOS-F-03 streichen. Stattdessen verlassen wir uns auf iOS-F-02 (Wake-up-Sync beim manuellen Öffnen), das ist mit Sequence-Cursor + permessage-deflate schnell genug. Silent-Push lohnt den Aufwand nicht.
Einverstanden mit streichen?
joe: ja
### Gruppe F — Security
#### iOS-G-01 — Token im Keychain
**MUST.** Bearer-Token aus S-10 wird im iOS Keychain gespeichert,
nie im Klartext.
*Dependencies:* S-10
#### iOS-G-02 — Face-ID / Touch-ID Gate
**SHOULD.** Optionaler Biometrie-Lock vorm Öffnen.
*Rationale:* Pi-Sessions können sensitive Daten zeigen.
*Dependencies:* —
joe: ja, will ich haben
claude: 👍 — soll Face-ID dann verpflichtend (`MUST`) sein oder optional toggle? Bei optional ist's Setting. Bei MUST muss jeder App-Wake erst durch Face-ID. Mein Vorschlag: **opt-in via Setting, default off** — sonst nervt das Re-Auth alle paar Minuten. joe: opt in
#### iOS-G-03 — Certificate-Pinning
**NICE.** TLS-Pinning gegen Sidecar-Host.
*Rationale:* MITM-Hardening.
*Dependencies:* S-11
joe: einfaches handling von self signed certificates. pinning via onboarding qr code oder so.
kein lust auf pki xD
claude: Siehe S-11-Reply — gleicher Mechanismus, das ist dasselbe Feature aus Client-Sicht. In v2 fasse ich S-11 + iOS-G-03 zu einem zusammenhängenden "TLS via TOFU+Pinning" Block.
---
## 6. Out of Scope (locked rejections)
- **Rich Chat-Rendering.** Wir parsen den Stream nicht. Wenn Pi Markdown
ausgibt, sieht der Nutzer ANSI-Markdown wie im Terminal.
- **Embedded Mini-Terminals / Block-Selection.** Es gibt einen Stream.
- **Multi-User-Sharing.** Solo.
- **Org/Team-Features, Cloud-Hosted-Service.** Selbstgehostet, ein Mensch.
- **Inline-Image-Rendering** (iTerm2 image protocol etc.). Eventuell
später, aber nicht in v1.
- **Eigenes Mosh.** tmux + WebSocket reicht.
- **Cross-platform-Client (Android / Web).** Nur iOS nativ. Bestehende
HTML-UI bleibt aber bestehen (separates Artefakt).
---
## 7. Offene Fragen
- **Q-1** — Wie wird `pi-remote attach` zur Default-Methode? Shell-Alias
manuell vs. in pi-Launcher gepatcht?
joe: irrelevant. kümmer ic mich selbst drum
claude: ✂️ Q-1 raus.
- **Q-2** — Side-Channel-State: liefert die aktuelle ExtensionAPI alle
benötigten Events (`thinking`, `awaiting-input`)? Falls nicht, was
brauchen wir upstream?
joe: was kann die extension api denn? das sollten wir vorher prüfen
claude: 👍 — das ist der zentrale Spike vor Phase 0. Ich schlage vor wir definieren das als **Spike-0a — ExtensionAPI-Audit**:
- Was liefert sie heute an Events? (`thinking`, `awaiting-input`, `tool-start/end`, Stream-Indicators?)
- Welche Slash-Commands kann sie abfragen? (Registry exposed oder nicht?)
- Tree-State zugänglich? (Read-only? Subscribe? Mutations via Command-Injection?)
- `pi.prompt()` oder Äquivalent — gibt's einen Weg Slash-Commands programmatisch zu dispatchen?
Output: ein Markdown-Dokument `docs/EXTENSION-API-AUDIT.md` das alle Features dieser Spec zu konkreter ExtensionAPI-Capability mapped. Das blockt nichts am Stream selbst (S-01-S-05) — das funktioniert ohne API-Erweiterung — aber alles unter S-07, S-08, Gruppe C, Gruppe T hängt daran.
Soll ich diesen Spike als ersten Punkt vor Phase 0 anlegen?
- **Q-3** — Slash-Command-Registry: ist die Liste über ExtensionAPI
abrufbar oder brauchen wir Hack?
joe: siehe Q2
claude: 👍 — gleicher Spike.
- **Q-4** — Marker-Detection (S-12): rein heuristisch reicht für iOS-B-04
und iOS-E-03? Oder Extension-Hook nötig?
joe: gar nicht rein als feature
claude: ✂️ — bestätigt. S-12, iOS-E-03, iOS-B-04 fallen alle damit.
- **Q-5** — Apple Developer Program ($99/Jahr) vs. AltStore / Sideloadly?
Für Solo-Use beides möglich.
joe: apple developer
claude: 👍 — $99/Jahr. Closed.
- **Q-6** — APNs: eigener Push-Server vs. simple background-fetch?
joe: bietet apple da keine lösung?
claude: Doch, **APNs** (Apple Push Notification service) ist Apples Lösung. Aber Apple stellt nur den Push-**Endpunkt** bereit (`api.push.apple.com`), nicht den Server der die Pushes auslöst — das musst du selbst betreiben.
In unserem Setup ist der "Push-Server" einfach der Sidecar selbst:
- Sidecar hält ein APNs-Auth-Token (JWT mit deinem Developer-Key, signed via Apple)
- Wenn Pi-State zu `awaiting-input` wechselt → Sidecar POSTet an APNs-Endpunkt mit der Device-Token (kommt vom iOS-Client beim Pairing) → Apple liefert die Notification ans iPhone aus.
- Keine zusätzliche Infrastruktur, kein Firebase / OneSignal / Push-Gateway dazwischen. Apples APNs ist HTTP/2 mit JWT, ca. 50 Zeilen Node.
Library: `node-apn` oder `@parse/node-apn`. Setup: APNs-Auth-Key generieren im Developer-Portal (gehört zu deinem Apple-Dev-Account, $99 Q-5), Key in Sidecar-Config, fertig.
Also: **kein eigener Push-**Server**, nur ein eigener Push-**Client** (= der Sidecar). Closed?

View File

@ -0,0 +1,624 @@
# pi-remote — iOS Native App Spec (v3)
> **Status:** v3 nach ExtensionAPI-Audit.
> Audit-Ergebnis: [`EXTENSION-API-AUDIT.md`](./EXTENSION-API-AUDIT.md).
> Review-Verlauf v1: [`SPEC-ios-app-review-v1.md`](./SPEC-ios-app-review-v1.md).
>
> **Changelog v2 → v3:**
> - Audit hat S-07, S-08 und Tree-Read als out-of-the-box machbar bestätigt → von PENDING auf firm SHOULD.
> - **Tree-Navigation komplett aus iOS entfernt.** Gruppe T gestrichen. Begründung: Slash-Command-Dispatch ist in der ExtensionAPI blockiert, Workarounds (Hack oder Re-Implement) sind nicht den Aufwand wert. Tree-Navigation bleibt nativ in pi.
> - Spike-0a abgeschlossen, ist nun referenziertes Audit-Dokument.
> - **Q-A** geschlossen: pi-CLI `-p`/`--print` Mode funktioniert mit Haiku 4.5 in ~2s. S-09a hat jetzt konkrete CLI-Flags.
> - **Q-C** geschlossen: APNs-Setup-Details unter iOS-C-02. Sidecar routet pro Device-Token nach Sandbox (Xcode-Debug) vs. Production (TestFlight/Release).
---
## 1. Vision
Eine iOS-App, die laufende `pi`-Sessions **byte-genau spiegelt**, wie sie im
SSH-Terminal aussehen — und durch native iOS-Mittel (Touch, Sprache,
Notifications, System-Integration) die Bedienung dieser Terminal-Umgebung
unterwegs angenehm macht.
**Kein Hybrid-Rendering.** Was im Terminal ANSI ist, bleibt in der App ANSI.
Die App fügt eine *Augmentation-Schicht* hinzu, kein paralleles UI.
**Kernszenarien:**
1. **Long-Visit-Sync** — Eine Session läuft tagelang. Nutzer "besucht" sie
unregelmäßig vom Mac (SSH) und iPhone (App). Beide zeigen jederzeit
denselben State. Reattach ist instant.
2. **Session-Lifecycle vom Phone** — Sessions vom iPhone aus spawnen,
benennen, switchen und beenden — so easy wie native iOS-Tabs.
---
## 2. Principles
- **P-1** — Der Terminal-Stream ist die einzige Wahrheit für Inhalt.
Strukturierte Events dienen ausschließlich Statusanzeigen,
Notifications und Meta-State (z.B. Tree-Navigation). Niemals
Re-Rendering von Stream-Inhalt.
- **P-2** — SSH-Erfahrung bleibt unverändert. Der Mac-Workflow ändert
sich durch dieses Projekt nicht spürbar.
- **P-3** — Reconnect ist die wichtigste Operation. Sichtbares Ergebnis
< 1s nach App-Wake.
- **P-4** — Touch-UX wird *zur Terminal-Bedienung* gebaut, nicht
*anstelle* von Terminal-Bedienung.
- **P-5** — Solo-Use. Multi-User, Sharing, Org-Features sind out of scope.
- **P-6** — Server-State wird in tmux gehalten, wo möglich. Kein
paralleler State-Store im Sidecar.
---
## 3. Architecture
```
┌──────────────────────────────────────────────────────────┐
│ Server │
│ │
│ pi (Ink TUI) ◄─► tmux session ◄─► SSH-Client (Mac) │
│ │ │
│ │ pipe-pane (raw bytes) │
│ │ send-keys │
│ ▼ │
│ pi-remote sidecar │
│ │ │
│ │ WebSocket (wss://) │
│ │ ├─ raw ANSI stream (binary) │
│ │ ├─ control (send-keys, JSON) │
│ │ └─ side-channel (state, JSON) │
└─────────────────────────┼────────────────────────────────┘
┌──────────────────────────┐
│ iOS App │
│ ┌────────────────────┐ │
│ │ SwiftTerm renderer │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ Augmentation layer │ │
│ └────────────────────┘ │
└──────────────────────────┘
```
**Komponenten:**
- **tmux** — Session-Persistenz, Multi-Client-Attach, Pane-Pipe, Send-Keys.
Sessions als stable IDs, Metadaten via tmux User-Options
(`@description`, `@project`, etc.).
- **Sidecar** (`pi-remote-control` erweitert) — Node-Prozess. Spawn /
Reattach tmux, exponiert WebSocket-API, hält Ringbuffer für Replay,
leitet ExtensionAPI-Metadaten als Side-Channel weiter.
- **iOS-App** — SwiftUI-Shell, SwiftTerm als Renderer-View, eigener
Augmentation-Layer.
**Datenfluss Stream:** `pi` → tmux pipe-pane → sidecar ringbuffer → WS
(binär, `permessage-deflate`) → iOS SwiftTerm.
**Datenfluss Input:** iOS → WS → sidecar → `tmux send-keys` → tmux pane →
pi stdin. Mac-SSH-Client sieht denselben Input.
**Konventionen:**
- Terminal-Size: tmux pane fixiert auf **120 × 40**. Client rendert mit
Pinch-Zoom auf die physische Display-Größe.
- WebSocket-Frames: binär = ANSI-Stream-Chunk; text = JSON-Control
(input, side-channel, snapshot-request).
- Alternate-Screen-Buffer wird vom Client erkannt und nicht in den
Scrollback-Cache aufgenommen.
- Terminal-Title-Sequences werden ignoriert.
- Mouse-Tracking-Sequenzen werden weitergeleitet, vom Client aber nicht
visualisiert (Touch-UX).
---
## 4. Server Features (Sidecar)
### S-01 — tmux launcher / attach
**MUST.** CLI `pi-remote attach [<session>]`. Existiert die Session,
reattach; sonst neu spawnen (`tmux new-session -d -s <name> 'pi'`) und
attach. Default-Session: `pi-main`. Per Projekt benennbar.
*Dependencies:* —
### S-02 — Raw ANSI WebSocket Stream
**MUST.** Endpoint `/stream/<session>`.
- Binäre Frames für ANSI-Bytes aus `tmux pipe-pane`.
- WebSocket-Extension `permessage-deflate` aktiv (35× Compression
typisch für ANSI).
- Eine Verbindung = ein Pane.
- Alternate-Screen-Sequenzen werden durchgereicht; Sidecar markiert sie
nicht separat (Client tracked).
*Dependencies:* S-01
### S-03 — Send-Keys-Endpoint
**MUST.** WS-Text-Frames als JSON: `{type:"keys", data:"..."}` für
literal-Text, oder `{type:"key", name:"escape"|"tab"|"up"|...}` für
Spezialtasten. Sidecar mapped auf `tmux send-keys` oder
`tmux send-keys -l`. Bracketed-Paste-Frames
(`{type:"paste", data:"..."}`) wrappen automatisch mit `\e[200~ ... \e[201~`.
*Dependencies:* S-01
### S-04 — Sequence-Cursor & Delta-Replay
**MUST.** Sidecar nummeriert Chunks ausgehend (monotone u64). Client
sendet bei Reconnect `{type:"resume", lastSeq:<n>}`. Server liefert ab
`lastSeq+1` weiter. Falls Lücke → Snapshot (S-05).
*Dependencies:* S-02
### S-05 — Snapshot-Endpoint
**MUST.** Wenn `lastSeq` außerhalb des Ringbuffers liegt:
`tmux capture-pane -p -e -S -10000` als Snapshot + neuen Start-Seq.
Snapshot wird komprimiert über WS geliefert.
*Dependencies:* S-01, S-04
### S-06 — Per-Session Disk Buffer
**SHOULD.** Sidecar persistiert ANSI-Stream pro Session in eine
einzelne Datei `/var/lib/pi-remote/buffer/<session>.log`.
- **Pro Session**: hartes Cap = **100MB** (configurable). Bei
Überlauf wird der Schreibvorgang gestoppt; existierender Inhalt
bleibt unangetastet. Neue Bytes ab dem Punkt fehlen im Buffer —
Snapshot via S-05 funktioniert weiterhin direkt aus tmux.
- **Global**: Hartes Total-Cap = **1GB** über alle Sessions; wenn
überschritten, schaltet der Sidecar Buffer-Schreiben global ab und
meldet `degraded` über S-13.
- **Idle-Cleanup**: Sessions ohne Output UND ohne Client seit > 30
Tagen → Buffer-Datei wird gelöscht (Session selbst nur falls
ebenfalls beendet).
- **Disk-Watchdog**: Bei freiem Platz < 1GB Buffer-Schreiben global
aus, Health-Status `degraded`.
- Konfigurierbar via `~/.config/pi-remote/config.toml`.
*Dependencies:* S-02
### S-07 — State Side-Channel
**SHOULD.** Sidecar abonniert pi-ExtensionAPI-Events (`agent_start`,
`agent_end`, `tool_execution_start`, `tool_execution_end`,
`session_tree`, `session_compact`) und publiziert als JSON-Control-Frames:
```json
{"type":"state","value":"thinking"|"tool"|"idle"|"awaiting-input",
"tool":"Edit"|"Read"|...,"ts":1234567890}
```
`awaiting-input` wird abgeleitet aus `agent_end` + `ctx.isIdle()`,
da kein explizites Event existiert (siehe Audit §3.1).
*Dependencies:* S-01
### S-08 — Slash-Command-Registry
**SHOULD.** Endpoint `/sessions/<id>/commands` liefert JSON-Liste der
verfügbaren Slash-Commands inkl. Beschreibung und Argument-Schema via
`pi.getCommands()` (laut Audit out-of-the-box verfügbar). Dynamisch —
Extensions die Commands hinzufügen erscheinen automatisch.
*Dependencies:* S-01
### S-09 — Multi-Session-Management
**MUST.** Sidecar verwaltet mehrere tmux-Sessions parallel, alle robust
gegen Sidecar-Restart (tmux überlebt, Sidecar reattached beim Boot).
**Endpoints:**
- `GET /sessions` — Liste mit Metadaten (Name, Description, Created,
letzter Output, Connected-Clients, Pi-State).
- `POST /sessions` — neue Session spawnen, Body optional `{name, project}`.
- `PATCH /sessions/<id>` — umbenennen / Description ändern.
- `DELETE /sessions/<id>` — Session beenden (tmux kill-session + Buffer
optional löschen).
**State:** Ausschließlich in tmux gehalten. Sessions haben Namen
(stable ID), Metadaten via tmux User-Options:
- `@description` — Auto- oder manuell vergeben (siehe S-09a)
- `@project` — optional, vom User gesetzt
- `@created` — Timestamp
Kein eigener JSON-State-Store im Sidecar.
*Dependencies:* S-01
### S-09a — Auto-Naming via pi
**NICE.** Nach ~3 User-Messages in einer namenlosen Session triggert
der Sidecar einen One-Shot-Call via pi-CLI:
```bash
pi -p --provider anthropic --model claude-haiku-4-5 \
--no-tools --no-context-files --no-extensions --no-session \
--thinking off \
"Give a 2-4 word title for: '<transcript>'. Reply with title only."
```
~2s Latenz, pi's eigene Anthropic-Auth wird verwendet — keine separaten
Credentials nötig. Ergebnis landet als `@description` in tmux.
Manuelles Umbenennen aus der App jederzeit möglich und überschreibt.
*Dependencies:* S-09
### S-10 — Pairing & Bearer-Token-Auth
**MUST.** CLI `pi-remote pair` generiert ein kurzlebiges (5min)
Pairing-Token und druckt einen QR-Code im Terminal (Unicode block
chars, via `qrcode-terminal`).
QR-Inhalt: `pi-remote://<host>:<port>?pair=<token>&fp=<sha256-cert-fp>`
iOS-App scannt → tauscht Pairing-Token gegen permanenten Bearer-Token,
pinnt den TLS-Fingerprint. Token im Keychain (iOS-G-01).
CLI:
- `pi-remote auth list` — alle Tokens
- `pi-remote auth revoke <name>` — Token widerrufen
- `pi-remote auth name <name>` — Token umbenennen (z.B. "jay's iPhone")
*Dependencies:* S-11
### S-11 — TLS via Self-Signed + Trust-on-First-Use
**MUST.** Sidecar generiert beim ersten Start ein selbstsigniertes
Cert (ED25519 oder RSA-2048), 10 Jahre gültig, persistent auf Disk.
SHA-256-Fingerprint wird in den QR aus S-10 mit aufgenommen. Client
pinnt den Fingerprint beim Pairing. Bei Cert-Rotation (z.B. neuer
Host) muss re-paired werden.
Kein Let's Encrypt, keine CA, kein Reverse-Proxy nötig.
*Dependencies:* —
### S-12 — Health & Metrics
**NICE.** `/health` Endpoint mit Session-Count, Buffer-Size,
Connected-Clients, Disk-Watchdog-Status. Für Monitoring und
Selbst-Debugging.
*Dependencies:* —
> S-13 (Tree-State Side-Channel) wurde in v3 gestrichen.
> Begründung: siehe Out-of-Scope und Changelog.
---
## 5. iOS Client Features
### Gruppe A — Rendering & Stream
#### iOS-A-01 — SwiftTerm-Renderer
**MUST.** Vollwertiger ANSI-Terminal-View. Renderer-Setup mit
Truecolor, 120×40 fixed grid, configurable Font + Theme.
*Dependencies:* S-02
#### iOS-A-02 — Sequence-Cursor & Reconnect
**MUST.** Client speichert lokal pro Session den letzten `seq`. Bei
WS-Reconnect: schickt `{type:"resume", lastSeq}`, verarbeitet Delta
oder Snapshot.
*Dependencies:* S-04, S-05
#### iOS-A-03 — Stale-Frame-Display
**SHOULD.** Während Sync nach App-Wake: letzten Frame einfrieren,
"syncing…" als subtile Overlay-Pill anzeigen. Kein leerer Screen.
*Dependencies:* iOS-A-02
#### iOS-A-04 — Local Scrollback-Cache
**SHOULD.** App puffert empfangene Bytes lokal pro Session (rolling,
default 5MB) für Offline-Scrolling und Suche. Alternate-Screen-Inhalt
wird ausgenommen.
*Dependencies:* iOS-A-01
#### iOS-A-05 — Themes & Fonts
**SHOULD.** Eingebaute Themes:
- Default-Dark, Default-Light
- Solarized Light, Solarized Dark
- Monokai, Dracula
- Nord, Gruvbox Dark, Gruvbox Light
- Tomorrow Night
- GitHub Light, GitHub Dark
- System (folgt iOS Light/Dark Mode)
Eingebaute Fonts (gebundlet):
- JetBrains Mono (Default)
- Hack
- SF Mono
- Menlo
- Fira Code
- Cascadia Code
- IBM Plex Mono
- Monaspace Neon
Custom Themes editierbar, iCloud-Sync nur für Custom.
*Dependencies:* iOS-A-01
### Gruppe B — Input & Modifier
#### iOS-B-01 — Software-Keyboard, Direct-Passthrough
**MUST.** Standard-iOS-Keyboard. **Mode (b) direct-passthrough**:
jeder Tastendruck geht sofort als `send-keys`. Enter sendet `\r` an
pi → pi behandelt selbst. Kein App-eigenes Compose-Feld.
Shift+Enter (newline) via dediziertem `⇧↵`-Button in der Modifier-Bar
(siehe iOS-B-02).
*Dependencies:* S-03
#### iOS-B-02 — Modifier-Bar
**MUST.** Accessory-Bar über der Tastatur:
```
[Ctrl] [Esc] [Tab] [←] [↑] [↓] [→] [⇧↵] [🎙] [📋]
```
- **Ctrl** ganz links — Sticky-Toggle: ein Tap → leuchtet → nächste
Taste wird als Ctrl+X gesendet. Beliebige Ctrl-Combos möglich
(Ctrl-C, Ctrl-D, Ctrl-L, Ctrl-U, ...).
- **Esc** — eigener Button, dauerhaft sichtbar.
- **Tab** — Autocomplete in pi.
- **Pfeiltasten** (Mitte) — History scrollen, in Menüs navigieren,
Input-Cursor bewegen.
- **`⇧↵`** — Shift+Enter, ein Tap = `\n` (newline in pi multi-line).
- **`🎙`** — Voice-Input (iOS-C-06).
- **`📋`** — Paste-Button ganz rechts (öffnet Paste-Sheet, iOS-B-08).
Bei knappem Platz (iPhone-Portrait) ist die Bar horizontal scrollbar.
*Dependencies:* iOS-B-01
#### iOS-B-03 — Long-Press-Repeat
**SHOULD.** Long-Press auf Pfeiltasten oder Backspace → repeat mit
Beschleunigung.
*Dependencies:* iOS-B-02
#### iOS-B-04 — Selection & Copy
**MUST.** Doppel-Tap → Wort. Tripel-Tap → Zeile. Long-Press + Drag →
Range. Native iOS-Copy-Menu.
*Dependencies:* iOS-A-01
#### iOS-B-05 — Pinch-Zoom Font
**SHOULD.** Pinch in der Terminal-View → Font-Size live.
*Dependencies:* iOS-A-01
#### iOS-B-06 — Hardware-Keyboard-Support
**SHOULD.** Externe iPad-Keyboards:
- Caps→Esc-Remap (optional)
- Modifier-Pass-Through (Cmd, Option, Ctrl)
- App-Shortcuts:
- Cmd-K — Clear
- Cmd-T — New session
- Cmd-1..9 — Session switch
- Cmd-F — Scrollback-Search
- Cmd-, — Settings
- Cmd-Shift-P — Slash-Command-Palette
*Dependencies:* iOS-B-01
#### iOS-B-07 — Reachability / One-Hand-Mode
**NICE.** iPhone-Querformat: Modifier-Bar gespiegelt für einhändige
Bedienung.
*Dependencies:* iOS-B-02
#### iOS-B-08 — Smart Paste (Confirm)
**SHOULD.** `📋`-Button in der Modifier-Bar zeigt Clipboard-Vorschau-
Chip ("📋 12 lines, 847 chars"). Tap → Sheet mit vollem Preview,
Insert / Cancel. Verhindert versehentliches Paste großer Blobs.
*Dependencies:* iOS-B-01
#### iOS-B-09 — Bracketed-Paste-Compliance
**SHOULD.** Client trackt im Stream `\e[?2004h` / `\e[?2004l`. Wenn
aktiv, wird bei Paste die `{type:"paste"}` Variante an S-03 geschickt,
sodass pi den ganzen Block als Paste erkennt.
*Dependencies:* S-03, iOS-B-08
### Gruppe C — Pi-aware Augmentation
#### iOS-C-01 — Status-Bar
**MUST.** Top-Bar zeigt: Connection-Status, Session-Name, Pi-State
(`● thinking` / `⏵ tool: Edit` / `▶ awaiting` / `⏸ idle`) basierend auf
S-07.
*Dependencies:* S-07
#### iOS-C-02 — Push-Notification bei Awaiting-Input
**MUST.** Wenn App im Background und Pi wechselt zu `awaiting-input`:
Push-Notification "Pi ist fertig · <session-name>". Tap → App öffnet in
der richtigen Session.
**APNs-Setup:**
- Apple Developer Portal: App-ID mit Push-Capability +
APNs Auth-Key (`.p8`, einmaliger Download). Notiere Team-ID und Key-ID.
- Dieselbe `.p8`-Datei funktioniert für Sandbox und Production.
- Sidecar-Config (neue Sektion in `~/.config/pi-remote/config.toml`):
```toml
[apns]
team_id = "..."
key_id = "..."
key_path = "/etc/pi-remote/AuthKey_<key-id>.p8"
bundle_id = "de.vpsj.pi-remote"
```
- **Environment-Routing pro Device:**
Xcode-Debug-Builds (lokales Testen via WiFi-Pair) registrieren ihre
Device-Tokens bei der Sandbox; TestFlight- und Release-Builds gehen
gegen Production. Beide gleichzeitig betreibbar, da derselbe Key
funktioniert. Sidecar speichert pro Device-Token ein `environment:
"sandbox" | "production"` Feld und routet beim Push entsprechend zu
`api.sandbox.push.apple.com` bzw. `api.push.apple.com`.
- **Pairing-Erweiterung (S-10):** iOS-App schickt beim Pairing
zusätzlich `{deviceToken, environment, deviceName}`. `environment`
bestimmt sich zur Compile-Time aus der Build-Config.
- **HTTP-Headers**:
- `apns-topic: <bundle-id>`
- `apns-push-type: alert` (required ab iOS 13)
- `apns-priority: 10`
- `apns-collapse-id: session-<id>` — verhindert Notification-Spam, neuere
State-Wechsel überschreiben ältere im Lock-Screen.
- **JWT:** ES256-signed mit `.p8`-Key, 1h gültig, im Sidecar für ~55min
cachen.
- **Auto-Cleanup:** APNs-Response `410 Gone` → Device-Token tot, Sidecar
löscht Mapping. App registriert beim nächsten Launch neu.
- **Library:** `@parse/node-apn` oder `node-apn-http2`.
*Dependencies:* S-07, S-10 (Pairing trägt Device-Token), APNs Auth-Key
#### iOS-C-03 — Haptic Feedback bei State-Wechseln
**NICE.** Subtile Vibration bei `thinking → idle` oder
`thinking → awaiting-input`.
*Dependencies:* S-07
#### iOS-C-04 — Slash-Command-Palette
**SHOULD.** **Long-Press auf die Modifier-Bar** → öffnet Sheet mit
nativer Liste aller Slash-Commands (aus S-08), Fuzzy-Search. Tap →
injiziert via `send-keys`. Commands mit Argumenten → Formular-View.
*Dependencies:* S-03, S-08
#### iOS-C-05 — Voice-to-Prompt
**NICE.** `🎙`-Button → Speech-Recognition (lokal, iOS native) →
editable Preview → Send.
*Dependencies:* iOS-B-01, Mic-Permission
### Gruppe D — Session & Navigation
#### iOS-D-01 — Session-Switcher (MUST)
**MUST.** Native iOS-Liste der verfügbaren Sessions (via S-09). Spawn /
Rename / Kill direkt aus dem Switcher.
*Dependencies:* S-09
#### iOS-D-01a — Background Pre-Connect
**SHOULD.** App connectet im Hintergrund zu allen bekannten Sessions
parallel, hält pro Session einen kleinen In-Memory-Stream-Buffer +
letzten Frame. Switch = Renderer wechselt instant.
Kosten: ein Socket pro Session, ein paar MB RAM. Akzeptabel solange
< ~10 aktive Sessions.
*Dependencies:* iOS-D-01
#### iOS-D-01b — Optimistic-Switch mit Stale-Frame
**SHOULD.** Swipe zu Session B → sofort gecachten Frame zeigen,
parallel live-Sync. Niemals leerer Screen, nur kurze "sync"-Pille.
*Dependencies:* iOS-D-01, iOS-D-01a
#### iOS-D-01c — Predictive Thumbnails
**SHOULD.** Im Session-Switcher zeigt jede Session eine kleine Live-
Mini-Vorschau (z.B. 40×12 Zeichen Snapshot via `tmux capture-pane`).
Beim Öffnen des Switchers werden die Thumbnails aktualisiert.
*Dependencies:* iOS-D-01
#### iOS-D-02 — Scrollback-Search
**SHOULD.** Such-Sheet sucht im lokalen Scrollback-Cache (iOS-A-04)
mit Highlight + Jump-to-Match. Auch offline nutzbar.
*Dependencies:* iOS-A-04
### Gruppe E — Background & Lifecycle
#### iOS-E-01 — WS-Keepalive
**MUST.** Ping/Pong-Frames im Foreground halten Connection. iOS
suspended WS gerne ohne Notification.
*Dependencies:* S-02
#### iOS-E-02 — Wake-up-Sync
**MUST.** App-Foreground-Event triggert sofort Reconnect + Delta-Pull.
Spinner falls > 200ms. Ziel < 1s sichtbarer Sync (P-3).
*Dependencies:* iOS-A-02
### Gruppe F — Security
#### iOS-F-01 — Token im Keychain
**MUST.** Bearer-Token aus S-10 wird im iOS Keychain gespeichert.
*Dependencies:* S-10
#### iOS-F-02 — Face-ID / Touch-ID Gate
**SHOULD.** Biometrie-Lock vorm Öffnen der App. **Opt-in via Setting,
default off.**
*Dependencies:* —
#### iOS-F-03 — TLS-Pinning via QR-Pairing
**MUST.** Beim Pairing (S-10) wird der Cert-Fingerprint gepinnt. Jede
künftige Connection verifiziert dagegen. Re-Pairing bei Cert-Rotation.
*Dependencies:* S-10, S-11
---
## 6. Out of Scope (locked)
- **Rich Chat-Rendering.** Wir parsen den Stream nicht. ANSI bleibt ANSI.
- **Embedded Mini-Terminals / Block-Selection.** Es gibt einen Stream.
- **Multi-User-Sharing.** Solo.
- **Org/Team-Features, Cloud-Hosted-Service.** Selbstgehostet.
- **Inline-Image-Rendering** (iTerm2 protocol etc.). Eventuell später.
- **Eigenes Mosh.** tmux + WebSocket reicht.
- **Cross-platform-Clients (Android / Web).** Nur iOS nativ. Die
bestehende HTML-UI bleibt als separates Artefakt.
- **Silent-Push-Background-Wake.** iOS coalesced unzuverlässig; manueller
Wake-up-Sync (iOS-E-02) reicht.
- **Marker-Detection / Jump-to-Marker.** Pi liefert keine zuverlässigen
Marker.
- **Tap-to-Cursor.** Ohne Marker-Detection nicht robust machbar.
- **Eigener Sidecar-State-Store.** State lebt in tmux (P-6).
- **Disk-Buffer-Rotation.** Ein Cap, drüber hinaus = Daten verloren.
Snapshot funktioniert weiter.
- **Bookmarks / manuelle Marker.** Pi-interne Tools sollen das übernehmen.
- **Snippet-Library** (Prompts mit Variablen). Pi-interne Tools.
- **Tree-View / Tree-Navigation in iOS** (jeglicher Form, read-only
oder interaktiv). Tree wird ausschließlich nativ in pi bedient. Audit
hat gezeigt: Slash-Command-Dispatch ist in der ExtensionAPI nicht sauber
zugänglich; Workarounds sind die Komplexität nicht wert.
---
## 7. ExtensionAPI-Audit (abgeschlossen)
Das Audit wurde durchgeführt; Ergebnis liegt in
[`EXTENSION-API-AUDIT.md`](./EXTENSION-API-AUDIT.md).
**Kernergebnisse:**
- S-07, S-08, sowie Tree-**Read** sind out-of-the-box machbar.
- Tree-**Write** (Slash-Command-Dispatch wie `/fork`, `/checkout`,
`/new`) ist nicht sauber zugänglich → Gruppe T gestrichen (siehe
Out-of-Scope).
- Tool-Call-Daten sind vollständig verfügbar (Name, Args, Result).
Kein weiterer Spike vor Phase 1 nötig.
---
## 8. Phasen
> Phase-Aufwände bewusst weggelassen (sind in v1 gestrichen worden).
- **Spike-0 — Stream-PoC**`pi-remote-control` um tmux pipe-pane +
WS-Stream erweitern. Verifizieren dass pi sauber in tmux läuft (kein
Crash, Alternate-Screen sauber, kein Latency-Problem).
- **Phase 1 — Sidecar production-ready** — S-01 bis S-12 (alle), S-09a
optional.
- **Phase 2 — iOS-App MVP** — Renderer (Gruppe A), Input (Gruppe B
ohne Hardware-KB), Status-Bar (iOS-C-01), Session-Switcher
(iOS-D-01 + a/b), Reconnect-Lifecycle (Gruppe E), Auth (Gruppe F).
- **Phase 3 — iOS-App Augmentation** — Slash-Palette (iOS-C-04),
Voice (iOS-C-05), Thumbnails (iOS-D-01c), Scrollback-Search
(iOS-D-02), Hardware-KB (iOS-B-06).
---
## 9. Offene Punkte
Keine offenen Punkte mehr in v3. Q-A und Q-C wurden in dieser Version
geschlossen (siehe S-09a bzw. iOS-C-02).