Add T3 protocol reference and implementation plan
This commit is contained in:
1 parent
e7f5b7e45b
commit
0fd556e313
2 files changed
+535
No files matched your search
+203
@@ -0,0 +1,203 @@
|
||||
# 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: Watchtower pulls `:latest` nightly. A `wristcode` entry in `~/Server/ci-deploy.sh` would
|
||||
make deploys immediate after CI.
|
||||
- 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/`.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- **Runtime mode for threads started from the watch.** Either inherit the host's default
|
||||
(`full-access` on these hosts) or force `approval-required`, so that anything risky waits for a
|
||||
tap on the wrist.
|
||||
- **Instant deploys.** Add a `wristcode` entry to `~/Server/ci-deploy.sh` with a CI deploy key, or
|
||||
rely on Watchtower's nightly pull.
|
||||
- **Bundle ID.** For example `org.gbrown.wristcode`.
|
||||
Reference in new issue
Block a user