Docs: public README, setup guide and architecture, generic deploy files, and the T3 Code notice
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 20s

This commit is contained in:
gib committed 2026-11-02 13:51:19 -05:00
1 parent b08fd9bdb1
commit f8241e42bb
13 files changed
+345 -301

No files matched your search

+2
View File
@@ -12,3 +12,5 @@ DerivedData/
build/
*.p8
gateway/data/
# Your machines for scripts/renew-hosts.sh
scripts/hosts.conf
+26
View File
@@ -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.
+47 -41
View File
@@ -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).
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+100
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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",
+6 -6
View File
@@ -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");
});
});
+15
View File
@@ -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
View File
@@ -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='