204 lines
10 KiB
Markdown
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`.
|