Files
wristcode/docs/plan.md
T

204 lines
10 KiB
Markdown

# WristCode implementation plan
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
need you, approve their requests, reply, start new threads, and get a notification when a turn
finishes. It runs over the watch's own cellular connection, so no phone is needed.
How a client talks to a T3 server is documented in [t3-protocol.md](t3-protocol.md). This plan
builds on it.
## Shape
```
watchOS app --HTTPS + device token--> gateway (Bun, server-gib) --WebSocket + bearer--> T3 server x N
|
+--HTTP/2--> APNs --> watch notifications
```
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
T3 write goes over a WebSocket.
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
thread shell in memory. The gateway serves list views from memory, so the watch never waits on a T3
round trip to draw the feed.
- The gateway is the only place that holds T3 credentials, and the watch only holds its own device
token.
## Gateway
Bun and TypeScript, with no framework. `Bun.serve` for HTTP, `bun:sqlite` for state, and
`node:http2` for APNs. The T3 RPC client is hand-written, about 150 lines, as the protocol doc
recommends. It needs no Effect and no vendored contracts.
### Modules
| File | Job |
| --- | --- |
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, 5 s Ping, 15 s silence timeout |
| `src/t3/host.ts` | One T3 server: well-known check, ticket mint, connect, reconnect with jittered backoff, shell state, resume with `afterSequence` |
| `src/t3/types.ts` | Narrow, lenient types for the fields we read. Unknown fields and enum values pass through |
| `src/awareness.ts` | Port of upstream `resolveThreadAwarenessPhaseV2`. Maps a thread shell to a phase |
| `src/feed.ts` | Builds the watch feed across all hosts: needs you, then working, then recently finished |
| `src/notify.ts` | Diffs phases on every `thread.updated` and on resync snapshots, and decides what to push |
| `src/apns.ts` | ES256 provider JWT plus an HTTP/2 client for APNs |
| `src/api.ts` | The watch REST API |
| `src/db.ts` | SQLite schema and queries |
| `src/cli.ts` | `wristcode host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
### T3 credentials
`host add` takes a T3 pairing credential, not a long-lived token:
```sh
# on the T3 host
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
# on server-gib
docker exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <pairing credential>
```
The gateway exchanges the credential at `POST /oauth/token` for a 30 day bearer session and stores
it in SQLite. A pairing credential is single use and expires in 5 minutes, so the long-lived token
never appears in a terminal, a transcript, or a chat. Renewing works the same way with a fresh
pairing credential.
T3 sessions cannot be refreshed. The gateway checks `GET /api/auth/session` daily, and seven days
before a token expires it sends a push to the watch that names the host.
### Watch API
Every route except pairing and health takes `Authorization: Bearer <device token>`. The gateway
stores device tokens as SHA-256 hashes.
| Route | Purpose |
| --- | --- |
| `GET /v1/health` | Liveness, plus each host's connection state |
| `POST /v1/pair` | `{ code, deviceName }` returns `{ deviceToken }`. `wristcode pair` makes the codes. They are 6 digits, last 5 minutes, work once, and are rate limited |
| `PUT /v1/device/push` | `{ apnsToken, apnsEnvironment }` |
| `GET /v1/feed` | `{ needsYou, working, recent, hosts }`. Each item has host, project, title, phase, the last message trimmed to about 280 characters, the pending request summary, and `updatedAt` |
| `GET /v1/threads/:host/:thread` | The last 20 messages from `/bounded`, trimmed, plus the full pending request (prompt, command, options) |
| `POST /v1/threads/:host/:thread/messages` | `{ text }` sends `message.dispatch` with `queue_after_active` and `creationSource: "mobile"` |
| `POST /v1/threads/:host/:thread/respond` | `{ requestId, decision }` or `{ requestId, answers }` |
| `POST /v1/threads/:host/:thread/stop` | `thread.stop`, or `run.interrupt` when the host lacks `threadStop` |
| `GET /v1/projects` | Hosts, their projects, and each project's resolved default model, for the new thread screen |
| `POST /v1/threads` | `{ host, projectId, text }` calls `launchThread` with the resolved defaults |
Responses are JSON with stable field names. The watch decodes them with `Codable`, so this API is the
contract between the two halves and gets its own tests.
### Notifications
The gateway pushes when a top-level thread's phase changes to `completed`, `failed`,
`waiting_for_approval` or `waiting_for_input`. Each notification has a category the watch registers
actions for:
| Category | Actions |
| --- | --- |
| `approval` | Allow, Deny (calls `/respond`) |
| `finished` | Reply (text input, calls `/messages`), Open |
| `failed` | Open |
| `input` | Open |
On a cold start, the gateway loads state without pushing, so a restart never replays old news. After a
disconnect, the gateway diffs the resync snapshot against the last known state, so a turn that
finished while the socket was down still notifies once.
### Storage
```sql
hosts(id, label, url, environment_id, token, token_expires_at, last_sequence)
devices(id, name, token_hash, apns_token, apns_env, created_at, last_seen_at, revoked_at)
pairing_codes(code_hash, expires_at, used_at)
```
### Testing
Unit tests cover the parts that carry logic. They exercise RPC framing against an in-process fake WebSocket server
(including Ack flow and reconnect), the awareness port against upstream's own test cases, feed
ordering, notification diffing (cold start, resync, debouncing), and the API contract. The only end
to end check is a manual run against server-gib's own T3 before deploying.
## Container, CI and deploy
- `gateway/Containerfile` uses the official Bun image and a non-root user, and `/data` holds the
SQLite file. It is a plain OCI build, the same under podman and docker.
- `.gitea/workflows/gateway.yml` follows `secure-market`. On pushes touching `gateway/`, it runs
typecheck and tests on the `build` runner. On `main`, it also builds the image and pushes
`git.gbrown.org/gib/wristcode-gateway:<sha>` and `:latest`. It needs the repo secrets
`REGISTRY_USER` and `REGISTRY_PASSWORD`.
- On server-gib the stack lives in `~/Server/Wristcode/compose.yml`, modeled on `~/Server/Gbrown`.
It joins `nginx-bridge`, publishes no host ports, bind mounts `./data`, and carries the watchtower
label. The APNs key and other config come from a mode 600 `.env`.
- Nginx Proxy Manager gets a proxy host for `wristcode.gbrown.org` that points at
`wristcode-gateway:8080`, using the `*.gbrown.org` certificate. DNS and the Cloudflare tunnel
already cover the subdomain.
- Deploys: after pushing the image, CI calls `~/Server/ci-deploy.sh wristcode` on server-gib over
SSH with a deploy key, which pulls and restarts the stack. Watchtower's nightly pull is the
fallback.
- Local development: `bun --watch src/main.ts` against real T3 hosts, with no local image builds.
## Watch app
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`), with the newest watchOS as the
deployment target. XcodeGen generates the project from `watch/project.yml`. The team is
`GQJNM5YK2M`. The Mac mini builds it with `xcodebuild` and runs it in the watch simulator, and
TestFlight installs it on the watch using the App Store Connect key already on the Mac mini.
### Screens (mock B)
| Screen | Content |
| --- | --- |
| Pair | Gateway URL (prefilled with `https://wristcode.gbrown.org`) and a 6 digit code. Shown until paired |
| Feed | Count of threads waiting on you, then cards: needs you (with Allow and Deny on the card), working (with the agent's last line), recent (greyed) |
| Thread | The agent's latest message, large. Older messages are one scroll up. Reply by dictation or Scribble. Stop while running |
| Approval | Inline on the feed card and on the thread: the command or file, and the server's options |
| New thread | Host and project (defaults to the last used), model shown read only, task by dictation |
The feed refreshes on appear, when the app returns to the foreground, every 10 seconds while it is
on screen, and when a notification arrives. The watch makes plain `URLSession` HTTPS calls, and the
device token lives in the Keychain.
### Code layout
```
watch/
project.yml
WristCode/
WristCodeApp.swift app entry, notification registration
API/GatewayClient.swift async URLSession calls, Codable models
API/Models.swift
Auth/DeviceStore.swift Keychain
Views/PairView.swift
Views/FeedView.swift
Views/ThreadView.swift
Views/NewThreadView.swift
Notifications/NotificationHandler.swift categories and actions
```
## Phases
Each phase ends with something usable.
1. **Gateway read path.** RPC client, host connection, shell state, awareness, `host add` through
pairing, and `GET /v1/feed`. Checked against server-gib's T3 from the Fedora desktop.
2. **Gateway write path and pairing.** Send, respond, stop, launch, the projects route, watch
pairing codes, and device tokens. Checked with curl against a real thread.
3. **Ship the gateway.** Containerfile, CI, registry, the compose stack on server-gib, and the NPM
proxy host. All four hosts are added.
4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator
against the deployed gateway.
5. **Notifications and the real watch.** An APNs key in the Apple Developer portal, the gateway's
APNs sender, notification actions, and a TestFlight build installed on the watch.
6. **Later.** Complications showing the count of threads waiting on you, a Smart Stack widget, token
expiry warnings, and multiple gateways.
Phases 1 and 2 happen on the Fedora desktop. Phase 4 runs on the Mac mini in parallel once the
phase 2 API is fixed. The gateway owns `gateway/`, the Mac mini owns `watch/`, and both pull before
they push to `docs/`.
## Decisions
- Threads started from the watch inherit the host's default runtime mode (`full-access` on these
hosts).
- CI deploys right away through a `wristcode` entry in `~/Server/ci-deploy.sh`, with Watchtower's
nightly pull as the fallback.
- The bundle ID is `org.gbrown.wristcode`.