101 lines
5.0 KiB
Markdown
101 lines
5.0 KiB
Markdown
# Architecture
|
|
|
|
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
|
|
need you, answer their requests, reply, start and browse threads, and get notifications. It runs
|
|
over the watch's own cellular connection, so no phone is needed.
|
|
|
|
How a client talks to a T3 server is in [t3-protocol.md](t3-protocol.md), and the watch's API is in
|
|
[api.md](api.md).
|
|
|
|
## Shape
|
|
|
|
```
|
|
watchOS app --HTTPS + device token--> gateway (Bun) --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. It serves lists from memory, so the watch never waits on a T3 round trip
|
|
to draw the feed. Thread text comes from each thread's bounded snapshot, read on demand and
|
|
cached until the thread changes.
|
|
- The gateway is the only place that holds T3 credentials. The watch holds only 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 and needs no Effect runtime or vendored
|
|
contracts.
|
|
|
|
| File | Job |
|
|
| --- | --- |
|
|
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, pings, and a silence timeout |
|
|
| `src/t3/host.ts` | One T3 server: route choice, ticket mint, connect, reconnect with backoff, shell state, and the HTTP reads |
|
|
| `src/t3/types.ts` | Narrow, lenient types for the fields the gateway reads. Unknown fields and values pass through |
|
|
| `src/hub.ts` | Every host in the database, kept connected and rereading the table for changes |
|
|
| `src/awareness.ts` | A port of T3's own awareness logic, which maps a thread shell to a phase |
|
|
| `src/views.ts` | The feed, thread detail, browse, search and summary responses |
|
|
| `src/notify.ts` | Watches phase changes and decides what to push |
|
|
| `src/push.ts` | Sends pushes to each paired watch that wants that kind |
|
|
| `src/apns.ts` | ES256 provider JWT and an HTTP/2 client for APNs |
|
|
| `src/api.ts` | The watch's REST API |
|
|
| `src/db.ts` | SQLite schema and queries |
|
|
| `src/cli.ts` | The operator CLI: `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. The gateway exchanges it for a
|
|
30 day session limited to reading and driving threads, and stores that in SQLite. A pairing
|
|
credential works once and expires in minutes, so the session token never appears in a terminal.
|
|
T3 sessions cannot be refreshed, so the gateway checks each one daily and pushes a warning to the
|
|
watch a week before it expires. `scripts/renew-hosts.sh` renews them all in one go.
|
|
|
|
A host can have several routes, such as a LAN address, a VPN address and a public URL. The
|
|
gateway tries them in order on every connect and stays on the first that answers.
|
|
|
|
### Notifications
|
|
|
|
The gateway pushes when a thread starts waiting for you, finishes, or fails. On a cold start it
|
|
loads state without pushing, so a restart never replays old news. Each watch picks which kinds it
|
|
wants. A subagent's request is pushed under the thread that started it.
|
|
|
|
### Storage
|
|
|
|
```
|
|
hosts(id, label, url, alt_urls, environment_id, token, token_expires_at, created_at)
|
|
devices(id, name, token_hash, apns_token, apns_env, notifications, created_at, last_seen_at, revoked_at)
|
|
pairing_codes(code_hash, expires_at, used_at)
|
|
```
|
|
|
|
Device tokens and pairing codes are stored as SHA-256 hashes.
|
|
|
|
### Testing
|
|
|
|
Unit tests cover the parts that carry logic: RPC framing against an in-process fake server, route
|
|
choice and reconnects, the awareness port, feed ordering and subagent roll-up, notification
|
|
diffing, push filtering, and the API shapes. Run them with `bun test` in `gateway/`.
|
|
|
|
## Watch app
|
|
|
|
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`). XcodeGen generates the project from
|
|
`watch/project.yml`. A widget extension provides the complications and the Smart Stack widget. The
|
|
watch makes plain `URLSession` calls, and the device token lives in the Keychain, shared with the
|
|
widget.
|
|
|
|
The feed refreshes when it appears, when the app returns to the foreground, every 10 seconds while
|
|
it is on screen, and when a notification arrives.
|
|
|
|
[watch/README.md](../watch/README.md) covers building, the demo mode and the screenshot tests.
|
|
|
|
## Decisions
|
|
|
|
- Threads started from the watch use the project's default model and the host's default runtime
|
|
mode, unless the watch picks another model or turns on Ask before acting.
|
|
- An App Store app has to be an iOS app, so a watch-only app ships inside an empty iOS wrapper.
|
|
The watch app's own bundle ID is also its APNs topic.
|
|
- `delegate_task` children are not marked as subagents by T3, so the gateway recognizes them by
|
|
`delegatedFrom` and treats them as subagents of the thread that started them.
|