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. +

+ Working threads with what each agent is doing + A command waiting for approval + A finished thread with its messages +

+ +## 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='