@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.
- package/README.md +284 -70
- package/package.json +1 -1
- package/t3ctl.mjs +100 -7
package/README.md
CHANGED
|
@@ -1,96 +1,310 @@
|
|
|
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>]
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
29
|
+
```
|
|
30
|
+
laptop http://localhost:3773
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
t3ctl ●1 ◆1 ·3 ~/Code/t3ctl
|
|
33
|
+
api-gateway ✓2 ·1 ~/Code/api-gateway
|
|
34
|
+
```
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
npx t3 auth session list
|
|
40
|
-
npx t3 auth session revoke <session-id>
|
|
36
|
+
Add `-t` to expand threads:
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
app and needs no install, which guarantees an exact version match:
|
|
38
|
+
```sh
|
|
39
|
+
t3ctl ls -t
|
|
40
|
+
```
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
62
|
+
### `t3ctl ls`
|
|
72
63
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
81
|
+
The JSON shape is `{projects: [{host, id, title, workspaceRoot, threads: [{id,
|
|
82
|
+
title, branch, status, provider, updatedAt}]}], unreachable: [{host, error}]}`.
|
|
91
83
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|