@gobius/t3ctl 0.2.0 → 0.2.1

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 +302 -141
  2. package/package.json +1 -1
  3. package/t3ctl.mjs +24 -11
package/README.md CHANGED
@@ -1,149 +1,310 @@
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 under a short name of your choosing
23
+ t3ctl host add laptop 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 <name> <origin> <token>`
85
+
86
+ Register (or overwrite) a host. `<name>` is whatever you want to call it locally;
87
+ `<origin>` is a scheme + host + optional port, with any trailing slash trimmed.
88
+
89
+ ```sh
90
+ t3ctl host add desktop https://studio.tailnet-1234.ts.net eyJ2Ijox...
91
+ ```
92
+
93
+ ### `t3ctl host rm <name>`
94
+
95
+ ```sh
96
+ t3ctl host rm desktop
97
+ ```
98
+
99
+ ### `t3ctl hosts`
100
+
101
+ List registered hosts with truncated tokens. `t3ctl host` with no subcommand does
102
+ the same thing.
103
+
104
+ ```sh
105
+ t3ctl hosts
106
+ ```
107
+
108
+ ```
109
+ laptop http://localhost:3773 token:eyJ2Ijox…
110
+ desktop https://studio.tailnet-1234.ts.net token:eyJ2Ijox…
111
+ ```
112
+
113
+ ### `t3ctl project create <title> <workspace-root>`
114
+
115
+ Register an existing directory on the host as a project. The path is resolved
116
+ locally (`~` expands) and must already exist — t3ctl will not create it.
117
+
118
+ ```sh
119
+ t3ctl project create t3ctl ~/Code/t3ctl
120
+ ```
121
+
122
+ > Note: the workspace root is interpreted on the **host**, so this really only
123
+ > makes sense for a host whose filesystem you share — i.e. `localhost`. For a
124
+ > remote host, pass the remote absolute path and skip the `~` shorthand.
125
+
126
+ ### `t3ctl thread create <project> <title>`
127
+
128
+ Create a thread. This produces an **idle thread with no messages** — it does not
129
+ start the agent. Use `thread start` for that.
130
+
131
+ ```sh
132
+ t3ctl thread create t3ctl "rewrite the readme for users" --branch docs/readme
133
+ ```
134
+
135
+ Flags:
136
+
137
+ | Flag | Default | Meaning |
138
+ |---|---|---|
139
+ | `--model <instance>/<model>` | `claudeAgent/claude-opus-5` | Provider instance and model |
140
+ | `--branch <name>` | none | Git branch for the thread |
141
+ | `--worktree <path>` | none | Explicit worktree path |
142
+ | `--runtime-mode <mode>` | `full-access` | `approval-required`, `auto-accept-edits`, `auto`, `full-access` |
143
+ | `--interaction-mode <mode>` | `default` | `default` or `plan` |
144
+ | `--host <name>` | the only host | Which host to act on |
145
+
146
+ `--model` splits on the **first** slash, so slashed model names work as-is:
147
+ `--model opencode/github-copilot/gpt-5.4`.
148
+
149
+ ### `t3ctl thread start <thread> <message...>`
150
+
151
+ Send a message and run the agent. Everything after the thread reference is the
152
+ message — no quoting needed.
153
+
154
+ ```sh
155
+ t3ctl thread start "rewrite the readme" move the endpoint tables into CONTRIBUTING.md
156
+ ```
157
+
158
+ ```
159
+ started rewrite the readme for users
160
+ id 0f5c1e2a-...
161
+ model claudeAgent/claude-opus-5
162
+ mode full-access / default
163
+ seq 4471
164
+ ```
165
+
166
+ Accepts `--model`, `--runtime-mode`, `--interaction-mode`, and `--host`. Unlike
167
+ `thread create`, `--model` has no default here: the thread's existing model is
168
+ reused unless you override it. Use `--interaction-mode plan` to make the agent
169
+ plan instead of edit:
170
+
171
+ ```sh
172
+ t3ctl thread start "flaky release workflow" --interaction-mode plan why does the tag job race?
173
+ ```
174
+
175
+ ### `t3ctl thread interrupt <thread>`
176
+
177
+ Stop the turn that's currently running.
178
+
179
+ ```sh
180
+ t3ctl thread interrupt "rewrite the readme"
181
+ ```
182
+
183
+ ### `t3ctl thread settle|archive|unarchive|unpin|delete <thread>`
184
+
185
+ Thread lifecycle. Each takes a single thread reference (plus `--host`).
186
+
187
+ ```sh
188
+ t3ctl thread settle "rewrite the readme" # mark as done, drop out of the active list
189
+ t3ctl thread archive "flaky release workflow"
190
+ t3ctl thread unarchive 0f5c1e2a-...
191
+ t3ctl thread unpin "api rate limits"
192
+ t3ctl thread delete "scratch experiment"
193
+ ```
194
+
195
+ `delete` is not prompted and not undoable from t3ctl — check with `ls -t` first.
196
+
197
+ ## Referring to projects and threads
198
+
199
+ You rarely need to paste a UUID.
200
+
201
+ **Projects** resolve by id, then exact title, then workspace root (`~` expands,
202
+ relative paths are resolved against your current directory).
203
+
204
+ **Threads** resolve by id, then exact title, then a unique case-insensitive
205
+ substring of the title. Ambiguous substrings are listed rather than guessed:
206
+
207
+ ```
208
+ error "readme" matches 3 threads:
209
+ 0f5c1e2a-... rewrite the readme for users
210
+ 7b31d004-... readme screenshots
211
+ c9e0a115-... fix readme badge
212
+ ```
213
+
214
+ Deleted threads are never resolution candidates.
215
+
216
+ ## Choosing a host
217
+
218
+ Write commands act on one host. With a single host registered, that one is
219
+ implied. With more than one, pass `--host`:
220
+
221
+ ```sh
222
+ t3ctl thread start --host desktop "api rate limits" pick this back up
223
+ ```
224
+
225
+ Otherwise you get `multiple hosts; pass --host <laptop|desktop>`.
226
+
227
+ ## Reading `ls` output
228
+
229
+ Each project line ends with a tally like `●1 ◆1 ·3` — one icon per status, with a
230
+ count. With `-t`, each thread line starts with its own icon.
231
+
232
+ | Icon | Status | What it means |
233
+ |---|---|---|
234
+ | `●` green | `running` | A turn is in flight right now. The agent is working. |
235
+ | `✕` red | `error` | The session or its most recent turn failed. Needs you. |
236
+ | `◆` yellow | `needs-review` | The agent produced a plan and is waiting for you to approve it. |
237
+ | `☾` grey | `snoozed` | Hidden on purpose until a wake time (set in the app) passes. |
238
+ | `✓` grey | `settled` | You marked it done. It stays settled until new activity un-settles it. |
239
+ | `·` grey | `idle` | Alive, nothing running, nothing waiting on you. Freshly created threads land here. |
240
+ | `▪` grey | `archived` | Archived. Hidden unless you pass `-a`. |
241
+ | `✗` grey | `deleted` | Deleted. Hidden unless you pass `-a`. |
242
+
243
+ The two worth acting on are `✕` and `◆`: red means something broke, yellow means an
244
+ agent is blocked waiting for your approval. `●` is just work in progress.
245
+
246
+ One status per thread, most urgent first — a thread that is both running and
247
+ settled shows as `running`.
248
+
249
+ ## Several machines
250
+
251
+ t3ctl only ever stores an origin string, so **any transport that gives a host a
252
+ reachable URL works.** There's nothing to configure beyond `host add`.
253
+
254
+ - **Tailscale** — on the host, `npx t3 serve --tailscale-serve` publishes it at
255
+ `https://machine.tailnet.ts.net/`. Register that URL.
256
+ - **LAN** — `npx t3 serve --host 0.0.0.0` (or a specific interface), then register
257
+ `http://192.168.1.x:3773`. Read the URL `t3 serve` prints; it picks another port
258
+ if the default is taken.
259
+ - **SSH port-forward** — `ssh -N -L 3773:localhost:3773 you@box`, then register
260
+ `http://localhost:3773`. Good for hosts you don't want exposed at all.
261
+
262
+ **One token per host.** Tokens are issued by the server they belong to, so run
263
+ `npx t3 auth session issue --label t3ctl --ttl 30d --token-only` on each machine
264
+ and give each host its own short name:
265
+
266
+ ```sh
267
+ t3ctl host add laptop http://localhost:3773 eyJ2Ijox...
268
+ t3ctl host add desktop https://studio.tailnet-1234.ts.net eyJ2Ijox...
269
+ t3ctl host add builder http://10.0.0.42:3773 eyJ2Ijox...
270
+ t3ctl ls -t
271
+ ```
272
+
273
+ `ls` then fans out to all three at once. Machines that are asleep or offline show
274
+ up as `unreachable` and don't block the rest.
275
+
276
+ T3 Code's own **T3 Connect relay** (what the mobile app uses when you're off your
277
+ tailnet) is **not implemented in t3ctl** — the transports above are the options
278
+ today.
279
+
280
+ ## Limitations
281
+
282
+ Worth knowing before you build a workflow on this:
283
+
284
+ - **Unofficial.** Not affiliated with or supported by T3 Tools. Written against
285
+ T3 Code Nightly's HTTP API, which is not a documented public API — **endpoints
286
+ and payloads can change without warning** and a T3 Code update may break t3ctl
287
+ until it catches up.
288
+ - **A host is only reachable while its T3 Code server is running.** t3ctl can't
289
+ wake a machine, launch a server, or queue work for later. If the desktop app is
290
+ closed and no `t3 serve` is running, that host is `unreachable`.
291
+ - **`thread create` doesn't run anything.** It leaves an idle thread with no
292
+ messages — a state the desktop UI never produces. Follow it with `thread start`,
293
+ or the thread just sits there.
294
+ - **Not everything the API supports is wired up.** No `pin`, `unsettle`,
295
+ `snooze`/`unsnooze`, no reading message content, no live tailing of a running
296
+ turn. `unpin` exists without `pin` because only some of these share a payload
297
+ shape — see [CONTRIBUTING.md](CONTRIBUTING.md).
298
+ - **`ls` fetches full snapshots.** Fine interactively; too heavy to poll in a
299
+ loop.
300
+ - **Tokens sit in plaintext** in `~/.config/t3ctl/hosts.json`. No keychain
301
+ integration. Scope them with `--ttl` and revoke when done.
302
+
303
+ ## Contributing
304
+
305
+ Protocol notes, the command vocabulary, status-derivation rules, and the release
306
+ process are in [CONTRIBUTING.md](CONTRIBUTING.md).
307
+
308
+ ## License
309
+
310
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobius/t3ctl",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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
@@ -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) => {
@@ -110,7 +116,7 @@ const cmdHost = (args) => {
110
116
  const hosts = readHosts();
111
117
  if (sub === 'add') {
112
118
  const [name, origin, token] = rest;
113
- if (!name || !origin || !token) return console.error('usage: t3ctl host add <name> <origin> <token>');
119
+ if (!name || !origin || !token) return usage('usage: t3ctl host add <name> <origin> <token>');
114
120
  const next = hosts.filter((h) => h.name !== name).concat({ name, origin: origin.replace(/\/$/, ''), token });
115
121
  writeHosts(next);
116
122
  console.log(`added ${name} -> ${origin}`);
@@ -231,9 +237,9 @@ const cmdThreadInterrupt = async (thread, host) => {
231
237
  const cmdProject = async (args) => {
232
238
  const [sub, ...rest] = args;
233
239
  const { flags, pos } = parseArgs(rest);
234
- if (sub !== 'create') return console.error('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
240
+ if (sub !== 'create') return usage('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
235
241
  const [title, root] = pos;
236
- if (!title || !root) return console.error('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
242
+ if (!title || !root) return usage('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
237
243
  const host = pickHost(flags);
238
244
  const workspaceRoot = path.resolve(root.replace(/^~/, os.homedir()));
239
245
  if (!fs.existsSync(workspaceRoot)) return console.error(`workspace root does not exist: ${workspaceRoot}`);
@@ -249,16 +255,18 @@ const cmdThread = async (args) => {
249
255
  const [sub, ...rest] = args;
250
256
  const { flags, pos } = parseArgs(rest);
251
257
  if (sub === 'start' || sub === 'interrupt') {
252
- const host = pickHost(flags);
258
+ // Validate arguments before touching the host registry, so `t3ctl thread
259
+ // start` with no args prints usage instead of "no hosts registered".
253
260
  const [ref, ...rest2] = pos;
254
- if (!ref) return console.error(`usage: t3ctl thread ${sub} <thread>` + (sub === 'start' ? ' <message...>' : ''));
261
+ if (!ref) return usage(`usage: t3ctl thread ${sub} <thread>` + (sub === 'start' ? ' <message...>' : ''));
262
+ if (sub === 'start' && rest2.length === 0) return usage('usage: t3ctl thread start <thread> <message...>');
263
+ const host = pickHost(flags);
255
264
  const thread = resolveThread(await snapshot(host), ref);
256
265
  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);
266
+ return cmdThreadStart(thread, host, rest2.join(' '), flags);
260
267
  }
261
268
  if (SIMPLE_THREAD_COMMANDS.includes(sub)) {
269
+ if (!pos.length) return usage(`usage: t3ctl thread ${sub} <thread>`);
262
270
  const host = pickHost(flags);
263
271
  const thread = resolveThread(await snapshot(host), pos.join(' '));
264
272
  const { sequence } = await dispatch(host, {
@@ -267,10 +275,10 @@ const cmdThread = async (args) => {
267
275
  console.log(`${sub}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
268
276
  return;
269
277
  }
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>');
278
+ 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
279
  const [projectRef, ...titleParts] = pos;
272
280
  const title = titleParts.join(' ');
273
- if (!projectRef || !title) return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
281
+ if (!projectRef || !title) return usage('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
274
282
  const host = pickHost(flags);
275
283
  const project = resolveProject(await snapshot(host), projectRef);
276
284
  if (!project) throw new Error(`no project matching "${projectRef}" on ${host.name}`);
@@ -314,4 +322,9 @@ if (!commands[cmd]) {
314
322
  unarchive, unpin, delete)`);
315
323
  process.exit(cmd ? 1 : 0);
316
324
  }
317
- await commands[cmd](rest);
325
+ try {
326
+ await commands[cmd](rest);
327
+ } catch (error) {
328
+ console.error(`\x1b[31merror\x1b[0m ${error instanceof Error ? error.message : String(error)}`);
329
+ process.exit(1);
330
+ }