@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.
- package/README.md +334 -141
- package/package.json +1 -1
- package/t3ctl.mjs +156 -30
package/README.md
CHANGED
|
@@ -1,149 +1,342 @@
|
|
|
1
1
|
# t3ctl
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
10
|
+
## Quick start
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
You need a running T3 Code server (`t3 serve`, or the desktop app, which runs one)
|
|
13
|
+
and Node 22+.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
```sh
|
|
16
|
+
# 1. install
|
|
17
|
+
npm i -g @gobius/t3ctl
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
a
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
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 <
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 <
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|