@gobius/t3ctl 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +334 -141
  2. package/package.json +1 -1
  3. package/t3ctl.mjs +156 -30
package/README.md CHANGED
@@ -1,149 +1,342 @@
1
1
  # t3ctl
2
2
 
3
- A controller CLI for [T3 Code](https://github.com/pingdotgg/t3code) — a peer of the
4
- mobile app, not a host. Lists and (eventually) drives threads across all your machines.
3
+ A terminal client for [T3 Code](https://github.com/pingdotgg/t3code) that lists and
4
+ drives your coding-agent threads across every machine you run T3 Code on. Register
5
+ each host once, then start turns, interrupt runaway agents, and see what's running
6
+ everywhere from one prompt — without opening the desktop app.
5
7
 
6
- T3 Code is MIT-licensed open source. **Read the source before guessing at anything here:**
7
- `docs/user/remote-access.md`, `docs/internals/environment-auth.md`,
8
- `docs/internals/t3-connect.md`, and `packages/contracts/src/orchestration.ts`.
8
+ > Unofficial community client. Not affiliated with T3 Tools. See [Limitations](#limitations).
9
9
 
10
- ## Install
10
+ ## Quick start
11
11
 
12
- npm i -g @gobius/t3ctl # installs a `t3ctl` binary
13
- npx @gobius/t3ctl ls
12
+ You need a running T3 Code server (`t3 serve`, or the desktop app, which runs one)
13
+ and Node 22+.
14
14
 
15
- ## Status: local read + write working; relay transport not started
15
+ ```sh
16
+ # 1. install
17
+ npm i -g @gobius/t3ctl
16
18
 
17
- t3ctl ls [-t|--threads] [-a|--all] [--json]
18
- t3ctl host add <name> <origin> <token>
19
- t3ctl project create <title> <workspace-root>
20
- t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>]
21
- t3ctl thread start <thread> <message...> [--interaction-mode plan] [--model ...]
22
- t3ctl thread interrupt <thread>
23
- t3ctl thread settle|archive|unarchive|unpin|delete <thread>
19
+ # 2. mint a token — run this on the machine hosting T3 Code
20
+ npx t3 auth session issue --label t3ctl --ttl 30d --token-only
24
21
 
25
- `<project>` resolves by id, title, or workspace root. `<thread>` resolves by id,
26
- exact title, then unique case-insensitive substring (ambiguous matches are listed,
27
- not guessed). The five verb commands above are exactly those whose payload is
28
- `{commandId, threadId}`; `unsettle` carries extra fields and is not among them. `--model` defaults to
29
- `claudeAgent/claude-opus-5`; `instanceId` is the segment before the first slash
30
- (opencode models are themselves slashed, e.g. `opencode/github-copilot/gpt-5.4`).
31
-
32
- `thread create` creates an *idle* thread with no messages — it does not start the
33
- agent; use `thread start` for that. The UI never produces this state: it always fires `thread.create` immediately
34
- followed by `thread.turn.start`, a single command that carries the first message
35
- inline (`message: {messageId, role, text, attachments}` plus a `titleSeed`).
36
- `thread.message-sent` and `thread.turn-start-requested` are the resulting *events*,
37
- not commands. Starting a turn is not implemented here yet.
38
-
39
- ## Auth
40
-
41
- The server advertises its own policy at `GET /api/auth/session`; `bearer-access-token`
42
- is a supported session method. `t3 auth` is the documented way to manage access.
43
-
44
- npx t3 auth session issue --label t3ctl --ttl 30d --token-only
45
- npx t3 auth session list
46
- npx t3 auth session revoke <session-id>
47
-
48
- `t3` is published on npm (the `apps/server` package); `npx t3` works. Note that npx
49
- resolves the latest *published* version, which may lag the Nightly server you're running
50
- — T3 Code warns about client/server version skew. The same CLI ships inside the desktop
51
- app and needs no install, which guarantees an exact version match:
52
-
53
- APP="/Applications/T3 Code (Nightly).app"
54
- ELECTRON_RUN_AS_NODE=1 "$APP/Contents/MacOS/T3 Code (Nightly)" \
55
- "$APP/Contents/Resources/app.asar/apps/server/dist/bin.mjs" auth session issue --token-only
56
-
57
- Note `@t3tools/contracts` and `@t3tools/client-runtime` are `private: true` — readable in
58
- the repo, but not installable from npm. Hence the hand-rolled HTTP client here.
59
-
60
- ## Endpoints
61
-
62
- | Purpose | Endpoint |
63
- |---|---|
64
- | List everything | `GET /api/orchestration/snapshot` |
65
- | Per-thread | `GET /api/orchestration/threads/:threadId` |
66
- | Writes (commands) | `POST /api/orchestration/dispatch` |
67
-
68
- Commands are imperative, events past-tense (`thread.create` → `thread.created`).
69
- **The client generates `commandId`, `threadId`, and `projectId`**; `commandId` is the
70
- idempotency key. Exact schemas: `packages/contracts/src/orchestration.ts`.
71
-
72
- project.create { commandId, projectId, title, workspaceRoot, createdAt,
73
- createWorkspaceRootIfMissing? }
74
- thread.create { commandId, threadId, projectId, title, modelSelection,
75
- runtimeMode, interactionMode?, branch, worktreePath, createdAt }
76
-
77
- ### Derived thread status
78
-
79
- Most-urgent-first: `running` (`session.activeTurnId` or `session.status==="running"`)
80
- › `error` › `snoozed` › `needs-review` (`proposedPlans`) › `settled` › `idle`;
81
- `archived`/`deleted` short-circuit.
82
-
83
- ## Cross-machine
84
-
85
- `t3ctl` is origin-agnostic, so anything that gives a host a reachable URL works:
86
-
87
- - **Tailscale** — `t3 serve --tailscale-serve` advertises `https://machine.tailnet.ts.net/`.
88
- Then `t3ctl host add <name> <url> <token>`.
89
- - **LAN** — `t3 serve --host "$(tailscale ip -4)"` or any bound interface.
90
- - **SSH launch** — desktop-only today; the desktop app starts a remote server and port-forwards.
91
- - **T3 Connect relay** (`relay.t3.codes`) — account-level environment registry, what mobile
92
- uses off-tailnet. Client-side API is `/v1/client/environment-links`, `.../dpop-token`,
93
- `.../environment-link-challenges`, `.../devices`. Not implemented here yet; see
94
- `docs/internals/t3-connect.md` and `packages/contracts/src/relay.ts`.
95
-
96
- ## Releasing
97
-
98
- Publishing runs in CI via **npm trusted publishing (OIDC)**. There is no `NPM_TOKEN`
99
- anywhere — not in the workflow, not in repo secrets. CI proves its identity with a
100
- short-lived OIDC token that npm verifies against a configured trusted publisher, so
101
- there is no long-lived credential to leak, rotate, or exfiltrate. npm also attaches a
102
- provenance attestation linking the tarball to the exact commit and workflow run.
103
-
104
- To cut a release:
105
-
106
- npm version minor # or patch/major; commits and tags
107
- git push origin main --follow-tags
108
-
109
- The `v*` tag triggers `.github/workflows/release.yml`, which **stages** the release.
110
- CI cannot make a version public: the trusted publisher is configured stage-only, so
111
- a maintainer must promote it with 2FA. Either:
112
-
113
- - **npmjs.com** → the package → **Staged Packages** tab → **Approve**, or
114
- - `npm stage list @gobius/t3ctl` then `npm stage approve <stage-id>`
115
-
116
- 2FA is required either way. `npm stage view <id>` and `npm stage download <id>` let
117
- you inspect the exact tarball before approving. Prereleases (`1.2.3-beta.0`) target
118
- the `next` dist-tag; everything else `latest`.
119
-
120
- Trust boundaries, deliberately:
121
-
122
- - **Staged, not published.** CI stages with provenance; a human promotes with 2FA.
123
- A compromised workflow cannot ship anything to users. This is npm's own hardened
124
- recommendation, and the stage subcommands can't use OIDC tokens by design.
125
- - **Tag-triggered, not push-to-main.** Merging never publishes.
126
- - **`environment: release`.** The OIDC subject npm checks includes the environment,
127
- so a workflow running outside it cannot publish even from this repo. Add required
128
- reviewers to that environment in GitHub settings to gate releases on a human.
129
- - **`permissions: {}` at the top**, with the job opting into only `contents: read`
130
- and `id-token: write`.
131
- - **Actions pinned to full commit SHAs**, so a moved tag cannot swap the code.
132
- - **`persist-credentials: false`**, so the job's token isn't left in `.git/config`.
133
- - **`--ignore-scripts`** on publish, and the package has zero dependencies, so no
134
- third-party code executes in the release job.
135
- - **Tag/version agreement is enforced** before publishing, not after.
136
-
137
- One-time setup on npmjs.com (package → Settings → Trusted Publisher):
138
- organization/user `Goobles`, repository `t3ctl`, workflow `release.yml`,
139
- environment `release`. Grant it `npm stage publish` only — not `npm publish`.
140
- Once that works, consider disallowing token-based publishes for the package entirely
141
- so this pipeline becomes the only path in.
142
-
143
- ## Caveats
144
-
145
- - Unofficial client. Built against T3 Code Nightly; pin to `snapshot` + `dispatch`.
146
- - A host is only reachable while its T3 Code server is running.
147
- - `snapshot` returns full messages/activities (~900 KB for 215 threads). Fine for `ls`,
148
- wrong for polling — use `snapshotSequence` for incremental sync.
149
- - Treat pairing tokens like passwords; revoke with `t3 auth session revoke`.
22
+ # 3. register that host — t3ctl probes it and names it after the machine
23
+ t3ctl host add http://localhost:3773 eyJ2Ijox...
24
+
25
+ # 4. see everything
26
+ t3ctl ls
27
+ ```
28
+
29
+ ```
30
+ laptop http://localhost:3773
31
+
32
+ t3ctl ●1 ◆1 ·3 ~/Code/t3ctl
33
+ api-gateway ✓2 ·1 ~/Code/api-gateway
34
+ ```
35
+
36
+ Add `-t` to expand threads:
37
+
38
+ ```sh
39
+ t3ctl ls -t
40
+ ```
41
+
42
+ ```
43
+ laptop http://localhost:3773
44
+
45
+ t3ctl ●1 ◆1 ·3 ~/Code/t3ctl
46
+ ● rewrite the readme for users main claudeAgent
47
+ ◆ add snapshotSequence polling poll claudeAgent
48
+ · flaky release workflow main claudeAgent
49
+ ```
50
+
51
+ Projects are sorted most-recently-updated first, and so are the threads inside
52
+ them. Thread titles are truncated at 62 characters.
53
+
54
+ Hosts and tokens are stored in `~/.config/t3ctl/hosts.json` (directory `0700`,
55
+ file `0600`). Tokens are stored in plaintext, so treat that file like a password
56
+ file — revoke with `npx t3 auth session revoke <session-id>` if it leaks.
57
+
58
+ ## Commands
59
+
60
+ Run `t3ctl` with no arguments for the built-in summary.
61
+
62
+ ### `t3ctl ls`
63
+
64
+ List projects and threads across **all** registered hosts, in parallel. Hosts that
65
+ don't answer are reported at the end as `unreachable` rather than failing the run.
66
+
67
+ ```sh
68
+ t3ctl ls # projects only, with a status tally per project
69
+ t3ctl ls -t # --threads: expand each project's threads
70
+ t3ctl ls -a # --all: include archived and deleted, and empty projects
71
+ t3ctl ls --json # machine-readable; always includes threads
72
+ ```
73
+
74
+ `ls` deliberately has no `--host` filter — it's the "what's happening everywhere"
75
+ view. Pipe `--json` through `jq` if you want to slice it:
76
+
77
+ ```sh
78
+ t3ctl ls --json | jq -r '.projects[].threads[] | select(.status=="running") | .title'
79
+ ```
80
+
81
+ The JSON shape is `{projects: [{host, id, title, workspaceRoot, threads: [{id,
82
+ title, branch, status, provider, updatedAt}]}], unreachable: [{host, error}]}`.
83
+
84
+ ### `t3ctl host add <origin> [token] [--name <name>]`
85
+
86
+ Register (or update) a host. `<origin>` is a scheme + host + optional port, with
87
+ any trailing slash trimmed — the scheme is required.
88
+
89
+ Before writing anything, t3ctl fetches the host's environment descriptor from
90
+ `/.well-known/t3/environment`. That endpoint is unauthenticated, so it confirms
91
+ you are pointed at a real T3 Code server *before* a token is involved: a wrong
92
+ origin fails with `not a T3 Code server` instead of a confusing 401 on your first
93
+ `ls`. The descriptor's `environmentId`, `label` and `serverVersion` are stored
94
+ alongside the token.
95
+
96
+ ```sh
97
+ t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
98
+ ```
99
+
100
+ The host is named after the machine's own label (`SPR-Gobius-D` becomes
101
+ `spr-gobius-d`); pass `--name` to choose your own. Re-running `host add` for an
102
+ origin you already have updates that entry in place and keeps the stored token if
103
+ you don't pass a new one.
104
+
105
+ t3ctl warns you when:
106
+
107
+ - the new host's `serverVersion` differs from your other hosts — the API is not a
108
+ stable public interface, so a version split is worth knowing about
109
+ - an origin you already registered now reports a **different `environmentId`**,
110
+ meaning it points at a different machine than it used to and the stored token
111
+ belongs to the old one
112
+
113
+ The token is optional so you can register a host before minting one, but reads
114
+ will fail until you add it.
115
+
116
+ The older `t3ctl host add <name> <origin> <token>` form still works and prints a
117
+ deprecation notice.
118
+
119
+ ### `t3ctl host rm <name>`
120
+
121
+ ```sh
122
+ t3ctl host rm desktop
123
+ ```
124
+
125
+ ### `t3ctl hosts`
126
+
127
+ List registered hosts, probing each one in parallel for its label, environment id
128
+ (shortened), server version and reachability. `t3ctl host` with no subcommand does
129
+ the same thing.
130
+
131
+ ```sh
132
+ t3ctl hosts
133
+ ```
134
+
135
+ ```
136
+ ● laptop SPR-Gobius-D 9d9d9921 0.0.38-nightly.20260901.1250 http://localhost:3773
137
+ ✕ desktop Desktop 11111111 0.0.31-nightly.20260801.0900 https://studio.tailnet-1234.ts.net cannot reach …
138
+ ```
139
+
140
+ Unreachable hosts are dimmed and show the values last recorded, not live ones.
141
+ `ls` deliberately does *not* probe — it stays a single request per host.
142
+
143
+ ### `t3ctl project create <title> <workspace-root>`
144
+
145
+ Register an existing directory on the host as a project. The path is resolved
146
+ locally (`~` expands) and must already exist — t3ctl will not create it.
147
+
148
+ ```sh
149
+ t3ctl project create t3ctl ~/Code/t3ctl
150
+ ```
151
+
152
+ > Note: the workspace root is interpreted on the **host**, so this really only
153
+ > makes sense for a host whose filesystem you share — i.e. `localhost`. For a
154
+ > remote host, pass the remote absolute path and skip the `~` shorthand.
155
+
156
+ ### `t3ctl thread create <project> <title>`
157
+
158
+ Create a thread. This produces an **idle thread with no messages** — it does not
159
+ start the agent. Use `thread start` for that.
160
+
161
+ ```sh
162
+ t3ctl thread create t3ctl "rewrite the readme for users" --branch docs/readme
163
+ ```
164
+
165
+ Flags:
166
+
167
+ | Flag | Default | Meaning |
168
+ |---|---|---|
169
+ | `--model <instance>/<model>` | `claudeAgent/claude-opus-5` | Provider instance and model |
170
+ | `--branch <name>` | none | Git branch for the thread |
171
+ | `--worktree <path>` | none | Explicit worktree path |
172
+ | `--runtime-mode <mode>` | `full-access` | `approval-required`, `auto-accept-edits`, `auto`, `full-access` |
173
+ | `--interaction-mode <mode>` | `default` | `default` or `plan` |
174
+ | `--host <name>` | the only host | Which host to act on |
175
+
176
+ `--model` splits on the **first** slash, so slashed model names work as-is:
177
+ `--model opencode/github-copilot/gpt-5.4`.
178
+
179
+ ### `t3ctl thread start <thread> <message...>`
180
+
181
+ Send a message and run the agent. Everything after the thread reference is the
182
+ message — no quoting needed.
183
+
184
+ ```sh
185
+ t3ctl thread start "rewrite the readme" move the endpoint tables into CONTRIBUTING.md
186
+ ```
187
+
188
+ ```
189
+ started rewrite the readme for users
190
+ id 0f5c1e2a-...
191
+ model claudeAgent/claude-opus-5
192
+ mode full-access / default
193
+ seq 4471
194
+ ```
195
+
196
+ Accepts `--model`, `--runtime-mode`, `--interaction-mode`, and `--host`. Unlike
197
+ `thread create`, `--model` has no default here: the thread's existing model is
198
+ reused unless you override it. Use `--interaction-mode plan` to make the agent
199
+ plan instead of edit:
200
+
201
+ ```sh
202
+ t3ctl thread start "flaky release workflow" --interaction-mode plan why does the tag job race?
203
+ ```
204
+
205
+ ### `t3ctl thread interrupt <thread>`
206
+
207
+ Stop the turn that's currently running.
208
+
209
+ ```sh
210
+ t3ctl thread interrupt "rewrite the readme"
211
+ ```
212
+
213
+ ### `t3ctl thread settle|archive|unarchive|unpin|delete <thread>`
214
+
215
+ Thread lifecycle. Each takes a single thread reference (plus `--host`).
216
+
217
+ ```sh
218
+ t3ctl thread settle "rewrite the readme" # mark as done, drop out of the active list
219
+ t3ctl thread archive "flaky release workflow"
220
+ t3ctl thread unarchive 0f5c1e2a-...
221
+ t3ctl thread unpin "api rate limits"
222
+ t3ctl thread delete "scratch experiment"
223
+ ```
224
+
225
+ `delete` is not prompted and not undoable from t3ctl — check with `ls -t` first.
226
+
227
+ ## Referring to projects and threads
228
+
229
+ You rarely need to paste a UUID.
230
+
231
+ **Projects** resolve by id, then exact title, then workspace root (`~` expands,
232
+ relative paths are resolved against your current directory).
233
+
234
+ **Threads** resolve by id, then exact title, then a unique case-insensitive
235
+ substring of the title. Ambiguous substrings are listed rather than guessed:
236
+
237
+ ```
238
+ error "readme" matches 3 threads:
239
+ 0f5c1e2a-... rewrite the readme for users
240
+ 7b31d004-... readme screenshots
241
+ c9e0a115-... fix readme badge
242
+ ```
243
+
244
+ Deleted threads are never resolution candidates.
245
+
246
+ ## Choosing a host
247
+
248
+ Write commands act on one host. With a single host registered, that one is
249
+ implied. With more than one, pass `--host`:
250
+
251
+ ```sh
252
+ t3ctl thread start --host desktop "api rate limits" pick this back up
253
+ ```
254
+
255
+ Otherwise you get `multiple hosts; pass --host <laptop|desktop>`.
256
+
257
+ ## Reading `ls` output
258
+
259
+ Each project line ends with a tally like `●1 ◆1 ·3` — one icon per status, with a
260
+ count. With `-t`, each thread line starts with its own icon.
261
+
262
+ | Icon | Status | What it means |
263
+ |---|---|---|
264
+ | `●` green | `running` | A turn is in flight right now. The agent is working. |
265
+ | `✕` red | `error` | The session or its most recent turn failed. Needs you. |
266
+ | `◆` yellow | `needs-review` | The agent produced a plan and is waiting for you to approve it. |
267
+ | `☾` grey | `snoozed` | Hidden on purpose until a wake time (set in the app) passes. |
268
+ | `✓` grey | `settled` | You marked it done. It stays settled until new activity un-settles it. |
269
+ | `·` grey | `idle` | Alive, nothing running, nothing waiting on you. Freshly created threads land here. |
270
+ | `▪` grey | `archived` | Archived. Hidden unless you pass `-a`. |
271
+ | `✗` grey | `deleted` | Deleted. Hidden unless you pass `-a`. |
272
+
273
+ The two worth acting on are `✕` and `◆`: red means something broke, yellow means an
274
+ agent is blocked waiting for your approval. `●` is just work in progress.
275
+
276
+ One status per thread, most urgent first — a thread that is both running and
277
+ settled shows as `running`.
278
+
279
+ ## Several machines
280
+
281
+ t3ctl only ever stores an origin string, so **any transport that gives a host a
282
+ reachable URL works.** There's nothing to configure beyond `host add`.
283
+
284
+ - **Tailscale** — on the host, `npx t3 serve --tailscale-serve` publishes it at
285
+ `https://machine.tailnet.ts.net/`. Register that URL.
286
+ - **LAN** — `npx t3 serve --host 0.0.0.0` (or a specific interface), then register
287
+ `http://192.168.1.x:3773`. Read the URL `t3 serve` prints; it picks another port
288
+ if the default is taken.
289
+ - **SSH port-forward** — `ssh -N -L 3773:localhost:3773 you@box`, then register
290
+ `http://localhost:3773`. Good for hosts you don't want exposed at all.
291
+
292
+ **One token per host.** Tokens are issued by the server they belong to, so run
293
+ `npx t3 auth session issue --label t3ctl --ttl 30d --token-only` on each machine
294
+ and give each host its own short name:
295
+
296
+ ```sh
297
+ t3ctl host add http://localhost:3773 eyJ2Ijox...
298
+ t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
299
+ t3ctl host add http://10.0.0.42:3773 eyJ2Ijox...
300
+ t3ctl ls -t
301
+ ```
302
+
303
+ `ls` then fans out to all three at once. Machines that are asleep or offline show
304
+ up as `unreachable` and don't block the rest.
305
+
306
+ T3 Code's own **T3 Connect relay** (what the mobile app uses when you're off your
307
+ tailnet) is **not planned**: the relay's `dpop-token` exchange only accepts a
308
+ Clerk *session* JWT carrying the relay audience, and its allowed scopes are keyed
309
+ by `client_id`, which is pinned to `t3-mobile` and `t3-web`. A third-party CLI has
310
+ no way to present either. The transports above are the options.
311
+
312
+ ## Limitations
313
+
314
+ Worth knowing before you build a workflow on this:
315
+
316
+ - **Unofficial.** Not affiliated with or supported by T3 Tools. Written against
317
+ T3 Code Nightly's HTTP API, which is not a documented public API — **endpoints
318
+ and payloads can change without warning** and a T3 Code update may break t3ctl
319
+ until it catches up.
320
+ - **A host is only reachable while its T3 Code server is running.** t3ctl can't
321
+ wake a machine, launch a server, or queue work for later. If the desktop app is
322
+ closed and no `t3 serve` is running, that host is `unreachable`.
323
+ - **`thread create` doesn't run anything.** It leaves an idle thread with no
324
+ messages — a state the desktop UI never produces. Follow it with `thread start`,
325
+ or the thread just sits there.
326
+ - **Not everything the API supports is wired up.** No `pin`, `unsettle`,
327
+ `snooze`/`unsnooze`, no reading message content, no live tailing of a running
328
+ turn. `unpin` exists without `pin` because only some of these share a payload
329
+ shape — see [CONTRIBUTING.md](CONTRIBUTING.md).
330
+ - **`ls` fetches full snapshots.** Fine interactively; too heavy to poll in a
331
+ loop.
332
+ - **Tokens sit in plaintext** in `~/.config/t3ctl/hosts.json`. No keychain
333
+ integration. Scope them with `--ttl` and revoke when done.
334
+
335
+ ## Contributing
336
+
337
+ Protocol notes, the command vocabulary, status-derivation rules, and the release
338
+ process are in [CONTRIBUTING.md](CONTRIBUTING.md).
339
+
340
+ ## License
341
+
342
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobius/t3ctl",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Controller CLI for T3 Code hosts — list and drive threads across machines",
6
6
  "keywords": [
package/t3ctl.mjs CHANGED
@@ -22,7 +22,7 @@ const writeHosts = (hosts) => {
22
22
 
23
23
  const snapshot = async (host) => {
24
24
  const res = await fetch(`${host.origin}/api/orchestration/snapshot`, {
25
- headers: { authorization: `Bearer ${host.token}` },
25
+ headers: host.token ? { authorization: `Bearer ${host.token}` } : {},
26
26
  signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
27
27
  });
28
28
  if (!res.ok) throw new Error(`${host.name}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
@@ -47,6 +47,12 @@ const ICON = {
47
47
  archived: '\x1b[90m▪\x1b[0m', deleted: '\x1b[90m✗\x1b[0m',
48
48
  };
49
49
  const dim = (s) => `\x1b[90m${s}\x1b[0m`;
50
+ // Usage errors are user errors: print to stderr and exit non-zero so scripts
51
+ // can tell them apart from success.
52
+ const usage = (message) => {
53
+ console.error(message);
54
+ process.exitCode = 1;
55
+ };
50
56
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
51
57
 
52
58
  const collect = async (hosts) => {
@@ -61,7 +67,7 @@ const cmdLs = async (args) => {
61
67
  const showAll = args.includes('--all') || args.includes('-a');
62
68
  const asJson = args.includes('--json');
63
69
  const hosts = readHosts();
64
- if (!hosts.length) return console.error('No hosts registered. Run: t3ctl host add <name> <origin> <token>');
70
+ if (!hosts.length) return console.error('No hosts registered. Run: t3ctl host add <origin> <token>');
65
71
 
66
72
  const { ok, failed } = await collect(hosts);
67
73
 
@@ -105,22 +111,135 @@ const cmdLs = async (args) => {
105
111
  for (const f of failed) console.error(`\n\x1b[31munreachable\x1b[0m ${f.host.name}: ${f.error}`);
106
112
  };
107
113
 
108
- const cmdHost = (args) => {
114
+ // ---- host registry ------------------------------------------------------
115
+ // The descriptor at /.well-known/t3/environment is UNAUTHENTICATED, so probing
116
+ // it answers "is anyone home, and is it T3 Code?" without a token — a wrong
117
+ // origin fails here instead of as a baffling 401 on the first real call.
118
+ // Schema: ExecutionEnvironmentDescriptor in packages/contracts/src/environment.ts.
119
+
120
+ const DESCRIPTOR_PATH = '/.well-known/t3/environment';
121
+
122
+ const probe = async (origin, timeoutMs = 5000) => {
123
+ let res;
124
+ try {
125
+ res = await fetch(`${origin}${DESCRIPTOR_PATH}`, { signal: AbortSignal.timeout(timeoutMs) });
126
+ } catch (error) {
127
+ // Node's fetch reports a bare "fetch failed"; the cause carries the real reason.
128
+ const why = error.name === 'TimeoutError' ? `no response in ${timeoutMs}ms`
129
+ : (error.cause?.message ?? error.message);
130
+ throw new Error(`cannot reach ${origin}: ${why}`);
131
+ }
132
+ const notT3 = (why) => new Error(`not a T3 Code server (${DESCRIPTOR_PATH} ${why})`);
133
+ if (!res.ok) throw notT3(`returned HTTP ${res.status}`);
134
+ let d;
135
+ try { d = await res.json(); } catch { throw notT3('is not JSON'); }
136
+ const missing = ['environmentId', 'label', 'serverVersion'].filter((k) => typeof d?.[k] !== 'string' || !d[k]);
137
+ if (missing.length) throw notT3(`is missing ${missing.join(', ')}`);
138
+ return { environmentId: d.environmentId, label: d.label, serverVersion: d.serverVersion };
139
+ };
140
+
141
+ const shortId = (id) => (id ? id.slice(0, 8) : '-');
142
+ const warn = (message) => console.error(`\x1b[33mwarning\x1b[0m ${message}`);
143
+
144
+ // The name is what you type in --host, so derive a typeable slug from the label
145
+ // rather than using the label verbatim.
146
+ const slugify = (label) => label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'host';
147
+
148
+ const uniqueName = (base, hosts) => {
149
+ if (!hosts.some((h) => h.name === base)) return base;
150
+ for (let n = 2; ; n++) if (!hosts.some((h) => h.name === `${base}-${n}`)) return `${base}-${n}`;
151
+ };
152
+
153
+ const isOrigin = (value) => /^https?:\/\//i.test(value ?? '');
154
+
155
+ // Two accepted shapes, told apart by the scheme:
156
+ // host add <origin> [token] [--name <n>] current
157
+ // host add <name> <origin> <token> legacy, still works
158
+ const parseHostAdd = (pos) => {
159
+ if (isOrigin(pos[0])) return { origin: pos[0], token: pos[1] ?? null, legacy: false };
160
+ if (isOrigin(pos[1])) return { name: pos[0], origin: pos[1], token: pos[2] ?? null, legacy: true };
161
+ return null;
162
+ };
163
+
164
+ const HOST_ADD_USAGE = 'usage: t3ctl host add <origin> [token] [--name <name>]\n' +
165
+ ' an origin needs a scheme, e.g. http://localhost:3773';
166
+
167
+ const cmdHostAdd = async (pos, flags, hosts) => {
168
+ // Validate before any network or registry work so a bare `host add` prints
169
+ // usage instead of stalling on a probe.
170
+ const parsed = parseHostAdd(pos);
171
+ if (!parsed) return usage(HOST_ADD_USAGE);
172
+ if ('name' in flags && !flags.name) return usage(HOST_ADD_USAGE);
173
+ if (parsed.legacy) warn('"host add <name> <origin> <token>" is deprecated — use: t3ctl host add <origin> [token] [--name <name>]');
174
+
175
+ const origin = parsed.origin.replace(/\/$/, '');
176
+ const descriptor = await probe(origin);
177
+ const existing = hosts.find((h) => h.origin === origin);
178
+
179
+ // A changed environmentId on a known origin means the origin now points at a
180
+ // different machine — the stored token almost certainly belongs to the old one.
181
+ if (existing?.environmentId && existing.environmentId !== descriptor.environmentId) {
182
+ warn(`${origin} is now a DIFFERENT environment\n` +
183
+ ` was ${existing.environmentId} (${existing.label ?? 'unknown'})\n` +
184
+ ` now ${descriptor.environmentId} (${descriptor.label})\n` +
185
+ ` the token stored for "${existing.name}" was issued by the old one and will likely fail`);
186
+ }
187
+
188
+ const name = flags.name ?? parsed.name ?? existing?.name ??
189
+ uniqueName(slugify(descriptor.label), hosts);
190
+ const token = parsed.token ?? existing?.token ?? null;
191
+
192
+ const others = hosts.filter((h) => h.name !== name && h.origin !== origin && h.serverVersion);
193
+ const skewed = [...new Set(others.map((h) => h.serverVersion))].filter((v) => v !== descriptor.serverVersion);
194
+ if (skewed.length) warn(`serverVersion ${descriptor.serverVersion} differs from other hosts: ${skewed.join(', ')}`);
195
+
196
+ writeHosts(hosts.filter((h) => h.name !== name && h.origin !== origin).concat({
197
+ name, origin, token,
198
+ environmentId: descriptor.environmentId,
199
+ label: descriptor.label,
200
+ serverVersion: descriptor.serverVersion,
201
+ }));
202
+ console.log(`added ${bold(name)} -> ${origin}\n label ${descriptor.label}\n` +
203
+ ` env ${descriptor.environmentId}\n version ${descriptor.serverVersion}`);
204
+ if (!token) warn(`no token stored for ${name} — reads will fail until you run: t3ctl host add ${origin} <token>`);
205
+ };
206
+
207
+ const cmdHostsList = async (hosts) => {
208
+ if (!hosts.length) return console.log('(no hosts)');
209
+ const probes = await Promise.allSettled(hosts.map((h) => probe(h.origin)));
210
+ const drifted = [];
211
+ hosts.forEach((h, i) => {
212
+ const p = probes[i];
213
+ const live = p.status === 'fulfilled' ? p.value : null;
214
+ if (live && h.environmentId && h.environmentId !== live.environmentId) drifted.push({ h, live });
215
+ const icon = live ? ICON.running : ICON.error;
216
+ const label = live?.label ?? h.label ?? '-';
217
+ const env = shortId(live?.environmentId ?? h.environmentId);
218
+ const version = live?.serverVersion ?? h.serverVersion ?? '-';
219
+ // Values for an unreachable host are whatever was last stored, so dim the
220
+ // whole row to keep remembered data visually distinct from probed data.
221
+ const cell = (text, width) => (live ? text.padEnd(width) : dim(text.padEnd(width)));
222
+ console.log(`${icon} ${live ? bold(h.name.padEnd(14)) : dim(h.name.padEnd(14))} ${cell(label, 18)} ${dim(env.padEnd(9))} ${cell(version, 28)} ${dim(h.origin)}` +
223
+ (live ? '' : ` ${dim(p.reason.message)}`));
224
+ });
225
+ for (const { h, live } of drifted) {
226
+ warn(`${h.name} (${h.origin}) is now a DIFFERENT environment\n` +
227
+ ` was ${h.environmentId} (${h.label ?? 'unknown'})\n now ${live.environmentId} (${live.label})`);
228
+ }
229
+ };
230
+
231
+ const cmdHost = async (args) => {
109
232
  const [sub, ...rest] = args;
233
+ const { flags, pos } = parseArgs(rest);
110
234
  const hosts = readHosts();
111
- if (sub === 'add') {
112
- const [name, origin, token] = rest;
113
- if (!name || !origin || !token) return console.error('usage: t3ctl host add <name> <origin> <token>');
114
- const next = hosts.filter((h) => h.name !== name).concat({ name, origin: origin.replace(/\/$/, ''), token });
115
- writeHosts(next);
116
- console.log(`added ${name} -> ${origin}`);
117
- } else if (sub === 'rm') {
118
- writeHosts(hosts.filter((h) => h.name !== rest[0]));
119
- console.log(`removed ${rest[0]}`);
120
- } else {
121
- if (!hosts.length) return console.log('(no hosts)');
122
- for (const h of hosts) console.log(`${h.name.padEnd(16)} ${h.origin} ${dim('token:' + h.token.slice(0, 8) + '…')}`);
235
+ if (sub === 'add') return cmdHostAdd(pos, flags, hosts);
236
+ if (sub === 'rm') {
237
+ if (!pos[0]) return usage('usage: t3ctl host rm <name>');
238
+ writeHosts(hosts.filter((h) => h.name !== pos[0]));
239
+ console.log(`removed ${pos[0]}`);
240
+ return;
123
241
  }
242
+ return cmdHostsList(hosts);
124
243
  };
125
244
 
126
245
 
@@ -129,7 +248,7 @@ const cmdHost = (args) => {
129
248
  // commandId is the idempotency key, so retries are safe.
130
249
  // Schemas: packages/contracts/src/orchestration.ts in pingdotgg/t3code.
131
250
 
132
- const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree'];
251
+ const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree', 'name'];
133
252
 
134
253
  const parseArgs = (argv) => {
135
254
  const flags = {}, pos = [];
@@ -149,7 +268,7 @@ const pickHost = (flags) => {
149
268
  if (!h) throw new Error(`no such host: ${flags.host}`);
150
269
  return h;
151
270
  }
152
- if (!hosts.length) throw new Error('no hosts registered — run: t3ctl host add <name> <origin> <token>');
271
+ if (!hosts.length) throw new Error('no hosts registered — run: t3ctl host add <origin> <token>');
153
272
  if (hosts.length > 1) throw new Error(`multiple hosts; pass --host <${hosts.map((h) => h.name).join('|')}>`);
154
273
  return hosts[0];
155
274
  };
@@ -157,7 +276,7 @@ const pickHost = (flags) => {
157
276
  const dispatch = async (host, command) => {
158
277
  const res = await fetch(`${host.origin}/api/orchestration/dispatch`, {
159
278
  method: 'POST',
160
- headers: { authorization: `Bearer ${host.token}`, 'content-type': 'application/json' },
279
+ headers: { ...(host.token ? { authorization: `Bearer ${host.token}` } : {}), 'content-type': 'application/json' },
161
280
  body: JSON.stringify(command),
162
281
  signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
163
282
  });
@@ -231,9 +350,9 @@ const cmdThreadInterrupt = async (thread, host) => {
231
350
  const cmdProject = async (args) => {
232
351
  const [sub, ...rest] = args;
233
352
  const { flags, pos } = parseArgs(rest);
234
- if (sub !== 'create') return console.error('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
353
+ if (sub !== 'create') return usage('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
235
354
  const [title, root] = pos;
236
- if (!title || !root) return console.error('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
355
+ if (!title || !root) return usage('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
237
356
  const host = pickHost(flags);
238
357
  const workspaceRoot = path.resolve(root.replace(/^~/, os.homedir()));
239
358
  if (!fs.existsSync(workspaceRoot)) return console.error(`workspace root does not exist: ${workspaceRoot}`);
@@ -249,16 +368,18 @@ const cmdThread = async (args) => {
249
368
  const [sub, ...rest] = args;
250
369
  const { flags, pos } = parseArgs(rest);
251
370
  if (sub === 'start' || sub === 'interrupt') {
252
- const host = pickHost(flags);
371
+ // Validate arguments before touching the host registry, so `t3ctl thread
372
+ // start` with no args prints usage instead of "no hosts registered".
253
373
  const [ref, ...rest2] = pos;
254
- if (!ref) return console.error(`usage: t3ctl thread ${sub} <thread>` + (sub === 'start' ? ' <message...>' : ''));
374
+ if (!ref) return usage(`usage: t3ctl thread ${sub} <thread>` + (sub === 'start' ? ' <message...>' : ''));
375
+ if (sub === 'start' && rest2.length === 0) return usage('usage: t3ctl thread start <thread> <message...>');
376
+ const host = pickHost(flags);
255
377
  const thread = resolveThread(await snapshot(host), ref);
256
378
  if (sub === 'interrupt') return cmdThreadInterrupt(thread, host);
257
- const text = rest2.join(' ');
258
- if (!text) return console.error('usage: t3ctl thread start <thread> <message...>');
259
- return cmdThreadStart(thread, host, text, flags);
379
+ return cmdThreadStart(thread, host, rest2.join(' '), flags);
260
380
  }
261
381
  if (SIMPLE_THREAD_COMMANDS.includes(sub)) {
382
+ if (!pos.length) return usage(`usage: t3ctl thread ${sub} <thread>`);
262
383
  const host = pickHost(flags);
263
384
  const thread = resolveThread(await snapshot(host), pos.join(' '));
264
385
  const { sequence } = await dispatch(host, {
@@ -267,10 +388,10 @@ const cmdThread = async (args) => {
267
388
  console.log(`${sub}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
268
389
  return;
269
390
  }
270
- if (sub !== 'create') return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]\n t3ctl thread <settle|archive|unarchive|unpin|delete> <thread>');
391
+ if (sub !== 'create') return usage('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]\n t3ctl thread <settle|archive|unarchive|unpin|delete> <thread>');
271
392
  const [projectRef, ...titleParts] = pos;
272
393
  const title = titleParts.join(' ');
273
- if (!projectRef || !title) return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
394
+ if (!projectRef || !title) return usage('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
274
395
  const host = pickHost(flags);
275
396
  const project = resolveProject(await snapshot(host), projectRef);
276
397
  if (!project) throw new Error(`no project matching "${projectRef}" on ${host.name}`);
@@ -301,9 +422,9 @@ if (!commands[cmd]) {
301
422
  console.log(`t3ctl — control T3 Code hosts
302
423
 
303
424
  t3ctl ls [-t|--threads] [-a|--all] [--json] list projects and threads across hosts
304
- t3ctl host add <name> <origin> <token> register a host
425
+ t3ctl host add <origin> [token] [--name <n>] register a host (probes it first)
305
426
  t3ctl host rm <name> remove a host
306
- t3ctl hosts list registered hosts
427
+ t3ctl hosts list hosts and probe each one
307
428
 
308
429
  t3ctl project create <title> <root> create a project for an existing dir
309
430
  t3ctl thread create <project> <title> start a thread (--model inst/model,
@@ -314,4 +435,9 @@ if (!commands[cmd]) {
314
435
  unarchive, unpin, delete)`);
315
436
  process.exit(cmd ? 1 : 0);
316
437
  }
317
- await commands[cmd](rest);
438
+ try {
439
+ await commands[cmd](rest);
440
+ } catch (error) {
441
+ console.error(`\x1b[31merror\x1b[0m ${error instanceof Error ? error.message : String(error)}`);
442
+ process.exit(1);
443
+ }