Docs: public README, setup guide and architecture, generic deploy files, and the T3 Code notice
This commit is contained in:
1 parent
b08fd9bdb1
commit
f8241e42bb
13 files changed
+345
-301
No files matched your search
@@ -12,3 +12,5 @@ DerivedData/
|
||||
build/
|
||||
*.p8
|
||||
gateway/data/
|
||||
# Your machines for scripts/renew-hosts.sh
|
||||
scripts/hosts.conf
|
||||
@@ -0,0 +1,26 @@
|
||||
WristCode includes code ported from T3 Code (https://github.com/pingdotgg/t3code):
|
||||
gateway/src/awareness.ts is ported from packages/shared/src/agentAwareness.ts.
|
||||
|
||||
T3 Code is released under the MIT License:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 T3 Tools Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,63 +1,69 @@
|
||||
# WristCode
|
||||
|
||||
An Apple Watch client for [T3 Code](https://t3.codes). Browse threads across every T3 Code
|
||||
environment you run, reply by dictation, start new threads, and get a notification when an agent
|
||||
finishes. It works over the watch's own cellular connection, with no phone nearby.
|
||||
An Apple Watch app for [T3 Code](https://t3.codes). It shows every T3 Code environment you run on
|
||||
your wrist: which agents need you, what each one is doing right now, and what finished. You can
|
||||
approve commands, answer questions, reply by dictation and start new threads. It works over the
|
||||
watch's own cellular connection, with no phone nearby.
|
||||
|
||||
Early work in progress.
|
||||
<p>
|
||||
<img src="watch/screenshots/feed-activity.png" width="200" alt="Working threads with what each agent is doing">
|
||||
<img src="watch/screenshots/thread-approval.png" width="200" alt="A command waiting for approval">
|
||||
<img src="watch/screenshots/thread-earlier.png" width="200" alt="A finished thread with its messages">
|
||||
</p>
|
||||
|
||||
## What it does
|
||||
|
||||
- A feed of every thread across your machines. Threads waiting on you come first, with Allow and
|
||||
Deny right on the card. Working threads show the step the agent is on, such as the command it is
|
||||
running or the file it is editing. Finished threads say how many files and lines they changed.
|
||||
- Notifications when a thread needs you, finishes or fails. Approvals and single-choice questions
|
||||
can be answered from the notification. You choose which kinds you get.
|
||||
- Replies and new threads by dictation. A new thread can use any model the host offers, and can
|
||||
start in a mode that asks before every action.
|
||||
- Every thread, settled ones too, by host and project, and search across all of them.
|
||||
- Subagent requests show up on the card of the thread that started them.
|
||||
- Complications and a Smart Stack widget with the request that has waited longest, or what is
|
||||
running now.
|
||||
- A status screen with each host's connection and route, and a test notification.
|
||||
|
||||
## How it fits together
|
||||
|
||||
watchOS only allows WebSockets for audio streaming apps, and T3 Code clients talk to their server
|
||||
over a WebSocket. So the watch talks HTTPS to a small gateway, and the gateway talks to each T3 Code
|
||||
watchOS only lets audio apps use WebSockets, and T3 Code clients talk to their server over one. So
|
||||
the watch talks HTTPS to a small gateway, and the gateway keeps a WebSocket open to each T3 Code
|
||||
server.
|
||||
|
||||
```
|
||||
Apple Watch --HTTPS--> gateway --WebSocket--> T3 Code server (one per environment)
|
||||
Apple Watch --HTTPS--> gateway --WebSocket--> T3 Code server (one per machine)
|
||||
|
|
||||
+--APNs--> notifications on the watch
|
||||
```
|
||||
|
||||
- `gateway/` is the gateway: Bun and TypeScript, shipped as a container image.
|
||||
- `watch/` is the watchOS app: SwiftUI, watch only, no iPhone companion.
|
||||
- `docs/` holds the design notes and the T3 Code protocol reference.
|
||||
- `mocks/` holds static UI mocks of the watch screens.
|
||||
- `gateway/` is the gateway, in Bun and TypeScript, shipped as a container image.
|
||||
- `watch/` is the watchOS app in SwiftUI. It is watch only, with no iPhone app.
|
||||
- `deploy/` has a compose file and an example `.env` for the gateway.
|
||||
- `scripts/renew-hosts.sh` renews the gateway's T3 sessions.
|
||||
- `docs/` has the [setup guide](docs/setup.md), the [architecture](docs/architecture.md), the
|
||||
[gateway API](docs/api.md) and the [T3 Code protocol notes](docs/t3-protocol.md).
|
||||
- `mocks/` has the static HTML mocks I drew before building each screen.
|
||||
|
||||
## Running the gateway
|
||||
## Setting it up
|
||||
|
||||
The gateway is one container with one SQLite file in `/data`. It runs under rootless podman on
|
||||
junior.gib, the VPS. [`deploy/compose.yml`](deploy/compose.yml) is a copy of that stack, and
|
||||
[`gateway/Containerfile`](gateway/Containerfile) builds the image. CI builds and deploys every push
|
||||
to `main` that touches `gateway/`.
|
||||
You need:
|
||||
|
||||
Operators use the `wristcode` CLI inside the container, on junior.gib:
|
||||
- One or more machines running T3 Code.
|
||||
- A machine to run the gateway container, reachable from the internet over HTTPS so the watch can
|
||||
use it on cellular. It needs to reach each T3 Code server, over your LAN, a VPN or a public URL.
|
||||
- An Apple developer account, to build the watch app and to send notifications.
|
||||
|
||||
```sh
|
||||
# Connect a T3 Code server. Make the one-time credential on that server first:
|
||||
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
|
||||
podman exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <credential> --label "Mac mini"
|
||||
[docs/setup.md](docs/setup.md) walks through running the gateway, connecting your T3 Code
|
||||
machines, building the app and pairing your watch.
|
||||
|
||||
# A host reachable more than one way lists fallbacks, preferred first. The gateway uses the
|
||||
# first route that answers: here a direct WireGuard address, then the public URL.
|
||||
podman exec -it wristcode-gateway wristcode host add http://desktop.gib:3773 <credential> --label "Desktop" --alt-url https://t3desktop.gbrown.org
|
||||
## Status
|
||||
|
||||
# Pair a watch: enter the printed code on the watch within 5 minutes.
|
||||
podman exec -it wristcode-gateway wristcode pair
|
||||
|
||||
podman exec -it wristcode-gateway wristcode host list
|
||||
podman exec -it wristcode-gateway wristcode device list
|
||||
podman exec -it wristcode-gateway wristcode device revoke <id or name>
|
||||
```
|
||||
|
||||
The gateway exchanges the credential for a 30 day T3 session limited to reading and driving
|
||||
threads, so the long-lived token never leaves the gateway. T3 sessions cannot be refreshed. A week
|
||||
before one expires, the watch gets a notification. [`scripts/renew-hosts.sh`](scripts/renew-hosts.sh)
|
||||
renews every host in one go over SSH, passing each one-time credential straight to the gateway
|
||||
without printing it. Edit its host list to match your machines.
|
||||
|
||||
For local development, run `bun --watch src/main.ts` in `gateway/` with `WRISTCODE_DB` pointing at
|
||||
a scratch file. Without APNs settings, pushes are logged instead of sent.
|
||||
I use it every day with five machines. It is young, and the gateway API can change between
|
||||
versions, so update the gateway and the app together.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT. See [LICENSE](LICENSE). `gateway/src/awareness.ts` is ported from T3 Code, which is also MIT
|
||||
licensed. See [NOTICE](NOTICE).
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copy to .env beside compose.yml. Without the APNS_ settings the gateway still runs, but it only
|
||||
# logs notifications instead of sending them.
|
||||
|
||||
# The APNs auth key from your Apple developer account (Keys, with Apple Push Notifications on).
|
||||
APNS_KEY_ID=ABC123DEFG
|
||||
APNS_TEAM_ID=ABCDE12345
|
||||
# The .p8 file, placed in ./data so the container sees it at /data.
|
||||
APNS_KEY_PATH=/data/apns.p8
|
||||
# The watch app's bundle ID. Pushes are addressed to it.
|
||||
APNS_TOPIC=com.example.wristcode.watchkitapp
|
||||
|
||||
TZ=UTC
|
||||
+13
-25
@@ -1,34 +1,22 @@
|
||||
# A copy of the stack on junior.gib (the VPS). The live file is server/vps/Wristcode/compose.yml in
|
||||
# gib/Panama, symlinked into ~/Server/Wristcode/, beside podman-wristcode.service.
|
||||
# The WristCode gateway as one container with one SQLite file. Put it behind a reverse proxy that
|
||||
# serves HTTPS on a public name, such as wristcode.example.com, and forwards to port 8080. The
|
||||
# watch needs that name reachable over cellular.
|
||||
#
|
||||
# WristCode gateway: the backend for the WristCode Apple Watch app (git.gbrown.org/gib/wristcode).
|
||||
# It holds a WebSocket to every T3 Code server, serves the watch's HTTPS API, and sends APNs pushes.
|
||||
# NPM serves wristcode.gbrown.org and forwards to wristcode-gateway:8080 over nginx-bridge.
|
||||
# .env beside this file (mode 600). See .env.example.
|
||||
#
|
||||
# .env beside this file (mode 600):
|
||||
# APNS_KEY_ID=... the APNs key's ID
|
||||
# APNS_TEAM_ID=GQJNM5YK2M
|
||||
# APNS_KEY_PATH=/data/apns.p8 the .p8 file, in ./data
|
||||
#
|
||||
# Operator CLI: podman exec -it wristcode-gateway wristcode <host add|pair|device list|...>
|
||||
networks:
|
||||
nginx-bridge:
|
||||
external: true # ALWAYS external; see AGENTS.md §5
|
||||
|
||||
# Operator CLI: docker compose exec gateway wristcode <host add|pair|host list|device list|...>
|
||||
services:
|
||||
wristcode-gateway:
|
||||
image: git.gbrown.org/gib/wristcode-gateway:latest
|
||||
gateway:
|
||||
image: ${WRISTCODE_IMAGE:-wristcode-gateway:latest}
|
||||
build:
|
||||
context: ../gateway
|
||||
dockerfile: Containerfile
|
||||
container_name: wristcode-gateway
|
||||
hostname: wristcode-gateway
|
||||
domainname: wristcode.gbrown.org
|
||||
networks: ['nginx-bridge']
|
||||
env_file: [.env]
|
||||
environment:
|
||||
- TZ=America/New_York
|
||||
# The image runs as `bun` (uid 1000). keep-id maps it to gib, so ./data stays gib-owned (AGENTS.md §3.2).
|
||||
userns_mode: "keep-id:uid=1000,gid=1000"
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080" # for a proxy on the same machine. Drop it if the proxy shares a network.
|
||||
volumes:
|
||||
- ./data:/data:z # lowercase :z survives update-containers recreates, as in Agentchat
|
||||
- ./data:/data # the image runs as uid 1000, which must be able to write here
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: wget -qO- http://127.0.0.1:8080/v1/health || exit 1
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript t
|
||||
[`gateway/src/contract.ts`](../gateway/src/contract.ts) are the source of truth, and the watch
|
||||
mirrors them in `watch/WristCode/API/Models.swift`. Change both together.
|
||||
|
||||
The base URL is `https://wristcode.gbrown.org`. Every route except `GET /v1/health` and
|
||||
The base URL is your gateway's public HTTPS address, such as `https://wristcode.example.com`. Every route except `GET /v1/health` and
|
||||
`POST /v1/pair` needs `Authorization: Bearer <deviceToken>`. Timestamps are ISO 8601 strings in UTC.
|
||||
|
||||
Errors return a non-2xx status with `{ "error": "<message for a person>" }`.
|
||||
@@ -330,11 +330,11 @@ GET /v1/status
|
||||
|
||||
HostStatusDetail {
|
||||
"id": "...", "label": "Laptop", "status": "online", "error": null,
|
||||
"activeUrl": "http://laptopvpn.gib:3773",
|
||||
"activeUrl": "http://laptop-vpn.lan:3773",
|
||||
"routes": [
|
||||
{ "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." },
|
||||
{ "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." },
|
||||
{ "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
||||
{ "url": "http://laptop.lan:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." },
|
||||
{ "url": "http://laptop-vpn.lan:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." },
|
||||
{ "url": "https://t3-laptop.example.com", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
|
||||
],
|
||||
"onlineSince": "...", "lastOnlineAt": null, "nextRetryAt": null,
|
||||
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
# 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, junior.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 junior.gib
|
||||
podman 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`.
|
||||
- The stack runs under rootless podman on junior.gib, the VPS, beside Gitea and most other
|
||||
services. Its compose file and systemd user unit live in gib/Panama under
|
||||
`server/vps/Wristcode/` and are symlinked into `~/Server/Wristcode/` and
|
||||
`~/.config/systemd/user/`. It joins `nginx-bridge`, publishes no host ports, and bind mounts
|
||||
`./data`. The APNs key and other config come from a mode 600 `.env`.
|
||||
- junior's Nginx Proxy Manager serves `wristcode.gbrown.org` and forwards to
|
||||
`wristcode-gateway:8080`. The Cloudflare record is proxied, as every VPS hostname must be.
|
||||
- Deploys: after pushing the image, CI SSHes to junior's WireGuard address with a deploy key
|
||||
pinned to `~/Server/ci-deploy.sh`, which pulls and restarts the stack. The nightly
|
||||
`update-containers` timer 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 junior.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).
|
||||
- The gateway runs on junior.gib, the VPS, which is more reliable than the home server. CI deploys
|
||||
right away through junior's `~/Server/ci-deploy.sh`, with the nightly update timer as the
|
||||
fallback.
|
||||
- The App Store record uses `org.gbrown.wristcode`, an empty iOS wrapper the App Store requires
|
||||
around a watch-only app. The watch app itself is `org.gbrown.wristcode.watchkitapp`, which is also
|
||||
its APNs topic.
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Setting up WristCode
|
||||
|
||||
This guide runs the gateway, connects your T3 Code machines to it, builds the watch app, and pairs
|
||||
your watch. It takes about an hour, most of it in Apple's developer portal.
|
||||
|
||||
## 1. Run the gateway
|
||||
|
||||
The gateway is one container with one SQLite file in `/data`. Run it on a machine that is always
|
||||
on and can reach every T3 Code server.
|
||||
|
||||
```sh
|
||||
cd deploy
|
||||
cp .env.example .env # fill in after step 2, or leave the APNS_ lines empty for now
|
||||
mkdir data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`compose.yml` builds the image from `gateway/` and listens on `127.0.0.1:8080`. Podman works the
|
||||
same with `podman compose`. The image runs as uid 1000, which needs to be able to write `data/`.
|
||||
|
||||
The watch needs to reach the gateway over cellular, so put it behind a reverse proxy that serves
|
||||
HTTPS on a public name, such as `wristcode.example.com`, and forwards to port 8080. Check it with:
|
||||
|
||||
```sh
|
||||
curl https://wristcode.example.com/v1/health
|
||||
```
|
||||
|
||||
Every route except health and pairing needs a device token, which only pairing hands out.
|
||||
|
||||
## 2. Set up notifications
|
||||
|
||||
The gateway sends notifications through Apple's push service (APNs) with a key from your developer
|
||||
account.
|
||||
|
||||
1. In [Certificates, IDs & Profiles](https://developer.apple.com/account/resources/authkeys/list),
|
||||
make a key with Apple Push Notifications service enabled, and download its `.p8` file.
|
||||
2. Put the file in `deploy/data/apns.p8`.
|
||||
3. Fill in `deploy/.env`: the key's ID, your team ID, and `APNS_TOPIC`, which is the watch app's
|
||||
bundle ID from step 4.
|
||||
4. Restart the gateway with `docker compose up -d`.
|
||||
|
||||
Without these settings the gateway still works. It logs each notification instead of sending it.
|
||||
|
||||
## 3. Connect your T3 Code machines
|
||||
|
||||
The gateway logs in to each T3 Code server with a one-time pairing credential. On the T3 Code
|
||||
machine:
|
||||
|
||||
```sh
|
||||
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
|
||||
```
|
||||
|
||||
Then, within a few minutes, on the gateway machine:
|
||||
|
||||
```sh
|
||||
docker compose exec gateway wristcode host add https://t3-desktop.example.com <credential> --label "Desktop"
|
||||
```
|
||||
|
||||
The gateway trades the credential for a 30 day session that can read and drive threads, and the
|
||||
credential stops working. The label is what the watch shows.
|
||||
|
||||
A machine you can reach several ways can list fallbacks with `--alt-url`, preferred first. The
|
||||
gateway uses the first route that answers, so put a LAN or VPN address first and a public URL
|
||||
last:
|
||||
|
||||
```sh
|
||||
docker compose exec gateway wristcode host add http://desktop.lan:3773 <credential> \
|
||||
--label "Desktop" --alt-url https://t3-desktop.example.com
|
||||
```
|
||||
|
||||
`wristcode host list` shows each host and when its session expires. T3 sessions cannot be renewed,
|
||||
so a week before one expires the watch gets a notification. To renew, run `host add` again with a
|
||||
fresh credential, or let `scripts/renew-hosts.sh` do every host over SSH. Copy
|
||||
`scripts/hosts.conf.example` to `scripts/hosts.conf` and list your machines in it first.
|
||||
|
||||
## 4. Build the watch app
|
||||
|
||||
You need a Mac with Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen).
|
||||
|
||||
1. Copy `watch/Local.xcconfig.example` to `watch/Local.xcconfig`, which git ignores, and set your
|
||||
team ID and a bundle ID prefix you own, such as `com.example`. The watch app's bundle ID is
|
||||
`<prefix>.wristcode.watchkitapp`, which is the `APNS_TOPIC` from step 2.
|
||||
2. Generate the project and open it:
|
||||
|
||||
```sh
|
||||
cd watch
|
||||
xcodegen generate
|
||||
open WristCode.xcodeproj
|
||||
```
|
||||
|
||||
3. Run the `WristCode` scheme on your watch. Xcode registers the bundle IDs and the push
|
||||
capability for you with automatic signing.
|
||||
|
||||
To install it without Xcode attached, upload a build to TestFlight with
|
||||
`watch/scripts/testflight.sh`. It needs an App Store Connect API key and an app record for
|
||||
`<prefix>.wristcode`. [watch/README.md](../watch/README.md) has the details, plus a demo mode
|
||||
that runs without a gateway.
|
||||
|
||||
## 5. Pair your watch
|
||||
|
||||
On the gateway machine:
|
||||
|
||||
```sh
|
||||
docker compose exec gateway wristcode pair
|
||||
```
|
||||
|
||||
It prints a 6 digit code that works once, for 5 minutes. On the watch, enter your gateway's URL
|
||||
and the code. The watch then asks to send notifications. Allow it, and send a test notification
|
||||
from the gear button, then Notifications, to check the whole chain.
|
||||
|
||||
`wristcode device list` shows paired watches, and `wristcode device revoke <name>` unpairs one.
|
||||
@@ -94,7 +94,7 @@ describe("Notifier", () => {
|
||||
const row = {
|
||||
id: "host-1",
|
||||
label: "Mac mini",
|
||||
url: "https://t3mac.gbrown.org",
|
||||
url: "https://t3-mac.example.com",
|
||||
alt_urls: null,
|
||||
environment_id: "host-1",
|
||||
token: "t",
|
||||
|
||||
@@ -123,14 +123,14 @@ describe("buildSummary", () => {
|
||||
thread({ id: "c", status: "completed" }),
|
||||
thread({ id: "d", status: "running", lineage: { relationshipToParent: "subagent" } }),
|
||||
]),
|
||||
host("LittleMan", "offline", [
|
||||
host("VPS", "offline", [
|
||||
thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }),
|
||||
]),
|
||||
], new SummaryCache());
|
||||
expect(summary).toEqual({
|
||||
needsYou: 2,
|
||||
working: 1,
|
||||
next: { hostId: "LittleMan", threadId: "e", title: "Older ask", hostLabel: "LittleMan", request: null },
|
||||
next: { hostId: "VPS", threadId: "e", title: "Older ask", hostLabel: "VPS", request: null },
|
||||
current: { hostId: "Mac mini", threadId: "b", title: "Receipt OCR", activity: null },
|
||||
hostsOffline: 1,
|
||||
});
|
||||
@@ -139,10 +139,10 @@ describe("buildSummary", () => {
|
||||
|
||||
describe("routeKind", () => {
|
||||
it("tells direct, VPN and public routes apart", () => {
|
||||
expect(routeKind("http://desktop.gib:3773")).toBe("direct");
|
||||
expect(routeKind("http://192.168.2.2:3773")).toBe("direct");
|
||||
expect(routeKind("http://laptopvpn.gib:3773")).toBe("vpn");
|
||||
expect(routeKind("https://t3laptopvpn.gbrown.org")).toBe("public");
|
||||
expect(routeKind("http://desktop.lan:3773")).toBe("direct");
|
||||
expect(routeKind("http://10.0.0.2:3773")).toBe("direct");
|
||||
expect(routeKind("http://laptop-vpn.lan:3773")).toBe("vpn");
|
||||
expect(routeKind("https://t3-laptop-vpn.example.com")).toBe("public");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Settings for renew-hosts.sh. Copy to hosts.conf, which git ignores, and fill in your machines.
|
||||
|
||||
# SSH target for the machine running the gateway container.
|
||||
GATEWAY_SSH="gateway.example.lan"
|
||||
# The container runtime there, podman or docker, and the container's name.
|
||||
GATEWAY_RUNTIME="podman"
|
||||
GATEWAY_CONTAINER="wristcode-gateway"
|
||||
|
||||
# label | SSH host, or "local" for the machine running the script | T3 URLs
|
||||
# URLs are space separated, preferred first. The gateway uses the first one that answers, so put
|
||||
# direct LAN or VPN routes first and a public URL last as the fallback.
|
||||
HOSTS=(
|
||||
"Desktop|local|http://desktop.lan:3773 https://t3-desktop.example.com"
|
||||
"Mac mini|mac.lan|http://mac.lan:3773"
|
||||
)
|
||||
+7
-16
@@ -4,28 +4,19 @@
|
||||
# on the gateway, so the credential is never printed. Run it from a machine that can SSH to every
|
||||
# host by name. The watch warns a week before a session expires.
|
||||
#
|
||||
# scripts/renew-hosts.sh renew every host below
|
||||
# scripts/renew-hosts.sh renew every host in hosts.conf
|
||||
# scripts/renew-hosts.sh "Mac mini" renew one host by label
|
||||
set -euo pipefail
|
||||
|
||||
GATEWAY_SSH="${GATEWAY_SSH:-junior.gib}"
|
||||
# The container runtime on the gateway host: podman on junior, docker on the home server.
|
||||
# The host list and gateway settings live in hosts.conf beside this script, which git ignores.
|
||||
# Copy hosts.conf.example to start one.
|
||||
CONFIG="${RENEW_HOSTS_CONFIG:-$(dirname "$0")/hosts.conf}"
|
||||
[ -f "$CONFIG" ] || { echo "No $CONFIG. Copy hosts.conf.example and fill it in." >&2; exit 1; }
|
||||
# shellcheck source=hosts.conf.example
|
||||
source "$CONFIG"
|
||||
GATEWAY_RUNTIME="${GATEWAY_RUNTIME:-podman}"
|
||||
GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-wristcode-gateway}"
|
||||
|
||||
# label | SSH host, or "local" for the machine running this script | T3 URLs
|
||||
# URLs are space separated, preferred first. The gateway uses the first one that answers. Direct
|
||||
# routes come first: from junior they cross WireGuard straight to the machine, so they skip
|
||||
# Cloudflare and the home server's tunnel and proxy. The public URLs are the fallback. LittleMan is
|
||||
# junior itself, reached at its own WireGuard address.
|
||||
HOSTS=(
|
||||
"Desktop|local|http://desktop.gib:3773 https://t3desktop.gbrown.org"
|
||||
"Mac mini|mac.gib|http://mac.gib:3773 https://t3mac.gbrown.org"
|
||||
"server-gib|server.gib|http://server.gib:3773 https://t3sr.gbrown.org"
|
||||
"LittleMan|junior.gib|http://192.168.2.2:3773 https://t3jr.gbrown.org"
|
||||
"Laptop|laptop.gib|http://laptop.gib:3773 http://laptopvpn.gib:3773 https://t3laptop.gbrown.org https://t3laptopvpn.gbrown.org"
|
||||
)
|
||||
|
||||
# Prints a pairing credential. Runs on the T3 host. The CLI is not on PATH in non-login shells,
|
||||
# so it looks where T3 Code installs it.
|
||||
MINT='
|
||||
|
||||
Reference in new issue
Block a user