diff --git a/.gitignore b/.gitignore
index ad1ca07..8a0f0fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ DerivedData/
build/
*.p8
gateway/data/
+# Your machines for scripts/renew-hosts.sh
+scripts/hosts.conf
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..92a064f
--- /dev/null
+++ b/NOTICE
@@ -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.
diff --git a/README.md b/README.md
index b16f5c5..17a76d9 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
+
+
+
+
+
+## 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 --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 --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
-```
-
-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).
diff --git a/deploy/.env.example b/deploy/.env.example
new file mode 100644
index 0000000..60d4337
--- /dev/null
+++ b/deploy/.env.example
@@ -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
diff --git a/deploy/compose.yml b/deploy/compose.yml
index 0c73875..ce36c92 100644
--- a/deploy/compose.yml
+++ b/deploy/compose.yml
@@ -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
-networks:
- nginx-bridge:
- external: true # ALWAYS external; see AGENTS.md §5
-
+# Operator CLI: docker compose exec gateway wristcode
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
diff --git a/docs/api.md b/docs/api.md
index 9e066e1..a081db2 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -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 `. Timestamps are ISO 8601 strings in UTC.
Errors return a non-2xx status with `{ "error": "" }`.
@@ -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"
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..761c88a
--- /dev/null
+++ b/docs/architecture.md
@@ -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.
diff --git a/docs/plan.md b/docs/plan.md
deleted file mode 100644
index 4217fc7..0000000
--- a/docs/plan.md
+++ /dev/null
@@ -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
-```
-
-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 `. 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:` 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.
diff --git a/docs/setup.md b/docs/setup.md
new file mode 100644
index 0000000..beec3bc
--- /dev/null
+++ b/docs/setup.md
@@ -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 --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 \
+ --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
+ `.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
+`.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 ` unpairs one.
diff --git a/gateway/test/notify.test.ts b/gateway/test/notify.test.ts
index 7f1002d..5bee7f4 100644
--- a/gateway/test/notify.test.ts
+++ b/gateway/test/notify.test.ts
@@ -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",
diff --git a/gateway/test/views.test.ts b/gateway/test/views.test.ts
index 61c6705..ea62b9d 100644
--- a/gateway/test/views.test.ts
+++ b/gateway/test/views.test.ts
@@ -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");
});
});
diff --git a/scripts/hosts.conf.example b/scripts/hosts.conf.example
new file mode 100644
index 0000000..c0043d8
--- /dev/null
+++ b/scripts/hosts.conf.example
@@ -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"
+)
diff --git a/scripts/renew-hosts.sh b/scripts/renew-hosts.sh
index 79470f6..7ed59e2 100755
--- a/scripts/renew-hosts.sh
+++ b/scripts/renew-hosts.sh
@@ -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='