@gobius/t3ctl 0.1.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 +284 -70
  2. package/package.json +1 -1
  3. package/t3ctl.mjs +100 -7
package/README.md CHANGED
@@ -1,96 +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>]
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
21
21
 
22
- `<project>` resolves by id, title, or workspace root. `--model` defaults to
23
- `claudeAgent/claude-opus-5`; `instanceId` is the segment before the first slash
24
- (opencode models are themselves slashed, e.g. `opencode/github-copilot/gpt-5.4`).
22
+ # 3. register that host under a short name of your choosing
23
+ t3ctl host add laptop http://localhost:3773 eyJ2Ijox...
25
24
 
26
- `thread create` creates an *idle* thread with no messages — it does not start the
27
- agent. The UI never produces this state: it always fires `thread.create` immediately
28
- followed by `thread.turn.start`, a single command that carries the first message
29
- inline (`message: {messageId, role, text, attachments}` plus a `titleSeed`).
30
- `thread.message-sent` and `thread.turn-start-requested` are the resulting *events*,
31
- not commands. Starting a turn is not implemented here yet.
25
+ # 4. see everything
26
+ t3ctl ls
27
+ ```
32
28
 
33
- ## Auth
29
+ ```
30
+ laptop http://localhost:3773
34
31
 
35
- The server advertises its own policy at `GET /api/auth/session`; `bearer-access-token`
36
- is a supported session method. `t3 auth` is the documented way to manage access.
32
+ t3ctl ●1 ◆1 ·3 ~/Code/t3ctl
33
+ api-gateway ✓2 ·1 ~/Code/api-gateway
34
+ ```
37
35
 
38
- npx t3 auth session issue --label t3ctl --ttl 30d --token-only
39
- npx t3 auth session list
40
- npx t3 auth session revoke <session-id>
36
+ Add `-t` to expand threads:
41
37
 
42
- `t3` is published on npm (the `apps/server` package); `npx t3` works. Note that npx
43
- resolves the latest *published* version, which may lag the Nightly server you're running
44
- — T3 Code warns about client/server version skew. The same CLI ships inside the desktop
45
- app and needs no install, which guarantees an exact version match:
38
+ ```sh
39
+ t3ctl ls -t
40
+ ```
46
41
 
47
- APP="/Applications/T3 Code (Nightly).app"
48
- ELECTRON_RUN_AS_NODE=1 "$APP/Contents/MacOS/T3 Code (Nightly)" \
49
- "$APP/Contents/Resources/app.asar/apps/server/dist/bin.mjs" auth session issue --token-only
42
+ ```
43
+ laptop http://localhost:3773
50
44
 
51
- Note `@t3tools/contracts` and `@t3tools/client-runtime` are `private: true` — readable in
52
- the repo, but not installable from npm. Hence the hand-rolled HTTP client here.
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
+ ```
53
50
 
54
- ## Endpoints
51
+ Projects are sorted most-recently-updated first, and so are the threads inside
52
+ them. Thread titles are truncated at 62 characters.
55
53
 
56
- | Purpose | Endpoint |
57
- |---|---|
58
- | List everything | `GET /api/orchestration/snapshot` |
59
- | Per-thread | `GET /api/orchestration/threads/:threadId` |
60
- | Writes (commands) | `POST /api/orchestration/dispatch` |
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.
61
57
 
62
- Commands are imperative, events past-tense (`thread.create` → `thread.created`).
63
- **The client generates `commandId`, `threadId`, and `projectId`**; `commandId` is the
64
- idempotency key. Exact schemas: `packages/contracts/src/orchestration.ts`.
58
+ ## Commands
65
59
 
66
- project.create { commandId, projectId, title, workspaceRoot, createdAt,
67
- createWorkspaceRootIfMissing? }
68
- thread.create { commandId, threadId, projectId, title, modelSelection,
69
- runtimeMode, interactionMode?, branch, worktreePath, createdAt }
60
+ Run `t3ctl` with no arguments for the built-in summary.
70
61
 
71
- ### Derived thread status
62
+ ### `t3ctl ls`
72
63
 
73
- Most-urgent-first: `running` (`session.activeTurnId` or `session.status==="running"`)
74
- › `error` › `snoozed` › `needs-review` (`proposedPlans`) › `settled` › `idle`;
75
- `archived`/`deleted` short-circuit.
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.
76
66
 
77
- ## Cross-machine
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
+ ```
78
73
 
79
- `t3ctl` is origin-agnostic, so anything that gives a host a reachable URL works:
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:
80
76
 
81
- - **Tailscale** — `t3 serve --tailscale-serve` advertises `https://machine.tailnet.ts.net/`.
82
- Then `t3ctl host add <name> <url> <token>`.
83
- - **LAN** — `t3 serve --host "$(tailscale ip -4)"` or any bound interface.
84
- - **SSH launch** — desktop-only today; the desktop app starts a remote server and port-forwards.
85
- - **T3 Connect relay** (`relay.t3.codes`) — account-level environment registry, what mobile
86
- uses off-tailnet. Client-side API is `/v1/client/environment-links`, `.../dpop-token`,
87
- `.../environment-link-challenges`, `.../devices`. Not implemented here yet; see
88
- `docs/internals/t3-connect.md` and `packages/contracts/src/relay.ts`.
77
+ ```sh
78
+ t3ctl ls --json | jq -r '.projects[].threads[] | select(.status=="running") | .title'
79
+ ```
89
80
 
90
- ## Caveats
81
+ The JSON shape is `{projects: [{host, id, title, workspaceRoot, threads: [{id,
82
+ title, branch, status, provider, updatedAt}]}], unreachable: [{host, error}]}`.
91
83
 
92
- - Unofficial client. Built against T3 Code Nightly; pin to `snapshot` + `dispatch`.
93
- - A host is only reachable while its T3 Code server is running.
94
- - `snapshot` returns full messages/activities (~900 KB for 215 threads). Fine for `ls`,
95
- wrong for polling — use `snapshotSequence` for incremental sync.
96
- - Treat pairing tokens like passwords; revoke with `t3 auth session revoke`.
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.1.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}`);
@@ -171,12 +177,69 @@ const resolveProject = (snap, ref) =>
171
177
  snap.projects.find((p) => !p.deletedAt && p.title === ref) ??
172
178
  snap.projects.find((p) => !p.deletedAt && p.workspaceRoot === path.resolve(ref.replace(/^~/, os.homedir())));
173
179
 
180
+
181
+ // Commands whose entire payload is {commandId, threadId}. Verified against
182
+ // packages/contracts/src/orchestration.ts — note `unsettle` is NOT one of
183
+ // these (it carries extra fields), so it is deliberately absent.
184
+ const SIMPLE_THREAD_COMMANDS = ['settle', 'archive', 'unarchive', 'unpin', 'delete'];
185
+
186
+ const resolveThread = (snap, ref) => {
187
+ const live = snap.threads.filter((t) => !t.deletedAt);
188
+ const byId = live.find((t) => t.id === ref);
189
+ if (byId) return byId;
190
+ const exact = live.filter((t) => t.title === ref);
191
+ if (exact.length === 1) return exact[0];
192
+ const fuzzy = live.filter((t) => (t.title ?? '').toLowerCase().includes(ref.toLowerCase()));
193
+ if (fuzzy.length === 1) return fuzzy[0];
194
+ if (fuzzy.length > 1) {
195
+ throw new Error(`"${ref}" matches ${fuzzy.length} threads:\n` +
196
+ fuzzy.slice(0, 8).map((t) => ` ${t.id} ${t.title}`).join('\n'));
197
+ }
198
+ throw new Error(`no thread matching "${ref}"`);
199
+ };
200
+
201
+
202
+ // thread.turn.start is ONE command carrying the first message inline — this is
203
+ // what the UI fires immediately after thread.create, which is why a thread with
204
+ // no messages is a state the UI never produces. The client-side schema requires
205
+ // runtimeMode/interactionMode explicitly (the server-side one defaults them).
206
+ const cmdThreadStart = async (thread, host, text, flags) => {
207
+ const command = {
208
+ type: 'thread.turn.start',
209
+ commandId: crypto.randomUUID(),
210
+ threadId: thread.id,
211
+ message: { messageId: crypto.randomUUID(), role: 'user', text, attachments: [] },
212
+ runtimeMode: flags['runtime-mode'] ?? thread.runtimeMode ?? 'full-access',
213
+ interactionMode: flags['interaction-mode'] ?? 'default',
214
+ createdAt: new Date().toISOString(),
215
+ };
216
+ if (flags.model) {
217
+ const slash = flags.model.indexOf('/');
218
+ if (slash < 1) throw new Error(`--model must be <instance>/<model>, got "${flags.model}"`);
219
+ command.modelSelection = { instanceId: flags.model.slice(0, slash), model: flags.model.slice(slash + 1) };
220
+ } else if (thread.modelSelection) {
221
+ command.modelSelection = thread.modelSelection;
222
+ }
223
+ const { sequence } = await dispatch(host, command);
224
+ const m = command.modelSelection;
225
+ console.log(`started ${bold(thread.title || thread.id)}\n id ${thread.id}` +
226
+ (m ? `\n model ${m.instanceId}/${m.model}` : '') +
227
+ `\n mode ${command.runtimeMode} / ${command.interactionMode}\n seq ${sequence}`);
228
+ };
229
+
230
+ const cmdThreadInterrupt = async (thread, host) => {
231
+ const { sequence } = await dispatch(host, {
232
+ type: 'thread.turn.interrupt', commandId: crypto.randomUUID(), threadId: thread.id,
233
+ });
234
+ console.log(`interrupted ${bold(thread.title || thread.id)}\n seq ${sequence}`);
235
+ };
236
+
174
237
  const cmdProject = async (args) => {
175
238
  const [sub, ...rest] = args;
176
239
  const { flags, pos } = parseArgs(rest);
177
- 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>]');
178
241
  const [title, root] = pos;
179
- 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>]');
180
243
  const host = pickHost(flags);
181
244
  const workspaceRoot = path.resolve(root.replace(/^~/, os.homedir()));
182
245
  if (!fs.existsSync(workspaceRoot)) return console.error(`workspace root does not exist: ${workspaceRoot}`);
@@ -191,10 +254,31 @@ const cmdProject = async (args) => {
191
254
  const cmdThread = async (args) => {
192
255
  const [sub, ...rest] = args;
193
256
  const { flags, pos } = parseArgs(rest);
194
- if (sub !== 'create') return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
257
+ if (sub === 'start' || sub === 'interrupt') {
258
+ // Validate arguments before touching the host registry, so `t3ctl thread
259
+ // start` with no args prints usage instead of "no hosts registered".
260
+ const [ref, ...rest2] = pos;
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);
264
+ const thread = resolveThread(await snapshot(host), ref);
265
+ if (sub === 'interrupt') return cmdThreadInterrupt(thread, host);
266
+ return cmdThreadStart(thread, host, rest2.join(' '), flags);
267
+ }
268
+ if (SIMPLE_THREAD_COMMANDS.includes(sub)) {
269
+ if (!pos.length) return usage(`usage: t3ctl thread ${sub} <thread>`);
270
+ const host = pickHost(flags);
271
+ const thread = resolveThread(await snapshot(host), pos.join(' '));
272
+ const { sequence } = await dispatch(host, {
273
+ type: `thread.${sub}`, commandId: crypto.randomUUID(), threadId: thread.id,
274
+ });
275
+ console.log(`${sub}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
276
+ return;
277
+ }
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>');
195
279
  const [projectRef, ...titleParts] = pos;
196
280
  const title = titleParts.join(' ');
197
- 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>]');
198
282
  const host = pickHost(flags);
199
283
  const project = resolveProject(await snapshot(host), projectRef);
200
284
  if (!project) throw new Error(`no project matching "${projectRef}" on ${host.name}`);
@@ -231,7 +315,16 @@ if (!commands[cmd]) {
231
315
 
232
316
  t3ctl project create <title> <root> create a project for an existing dir
233
317
  t3ctl thread create <project> <title> start a thread (--model inst/model,
234
- --branch, --host)`);
318
+ --branch, --host)
319
+ t3ctl thread start <thread> <message...> send a message and run the agent
320
+ t3ctl thread interrupt <thread> stop the running turn
321
+ t3ctl thread settle <thread> settle a thread (also: archive,
322
+ unarchive, unpin, delete)`);
235
323
  process.exit(cmd ? 1 : 0);
236
324
  }
237
- 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
+ }