@gobius/t3ctl 0.2.1 → 0.4.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 +85 -22
- package/package.json +1 -1
- package/t3ctl.mjs +215 -25
package/README.md
CHANGED
|
@@ -19,8 +19,8 @@ npm i -g @gobius/t3ctl
|
|
|
19
19
|
# 2. mint a token — run this on the machine hosting T3 Code
|
|
20
20
|
npx t3 auth session issue --label t3ctl --ttl 30d --token-only
|
|
21
21
|
|
|
22
|
-
# 3. register that host
|
|
23
|
-
t3ctl host add
|
|
22
|
+
# 3. register that host — t3ctl probes it and names it after the machine
|
|
23
|
+
t3ctl host add http://localhost:3773 eyJ2Ijox...
|
|
24
24
|
|
|
25
25
|
# 4. see everything
|
|
26
26
|
t3ctl ls
|
|
@@ -81,15 +81,41 @@ t3ctl ls --json | jq -r '.projects[].threads[] | select(.status=="running") | .t
|
|
|
81
81
|
The JSON shape is `{projects: [{host, id, title, workspaceRoot, threads: [{id,
|
|
82
82
|
title, branch, status, provider, updatedAt}]}], unreachable: [{host, error}]}`.
|
|
83
83
|
|
|
84
|
-
### `t3ctl host add <
|
|
84
|
+
### `t3ctl host add <origin> [token] [--name <name>]`
|
|
85
85
|
|
|
86
|
-
Register (or
|
|
87
|
-
|
|
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.
|
|
88
95
|
|
|
89
96
|
```sh
|
|
90
|
-
t3ctl host add
|
|
97
|
+
t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
|
|
91
98
|
```
|
|
92
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
|
+
|
|
93
119
|
### `t3ctl host rm <name>`
|
|
94
120
|
|
|
95
121
|
```sh
|
|
@@ -98,7 +124,8 @@ t3ctl host rm desktop
|
|
|
98
124
|
|
|
99
125
|
### `t3ctl hosts`
|
|
100
126
|
|
|
101
|
-
List registered hosts
|
|
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
|
|
102
129
|
the same thing.
|
|
103
130
|
|
|
104
131
|
```sh
|
|
@@ -106,10 +133,13 @@ t3ctl hosts
|
|
|
106
133
|
```
|
|
107
134
|
|
|
108
135
|
```
|
|
109
|
-
laptop
|
|
110
|
-
desktop
|
|
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 …
|
|
111
138
|
```
|
|
112
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
|
+
|
|
113
143
|
### `t3ctl project create <title> <workspace-root>`
|
|
114
144
|
|
|
115
145
|
Register an existing directory on the host as a project. The path is resolved
|
|
@@ -126,7 +156,7 @@ t3ctl project create t3ctl ~/Code/t3ctl
|
|
|
126
156
|
### `t3ctl thread create <project> <title>`
|
|
127
157
|
|
|
128
158
|
Create a thread. This produces an **idle thread with no messages** — it does not
|
|
129
|
-
start the agent. Use `thread
|
|
159
|
+
start the agent. Use `thread send` for that.
|
|
130
160
|
|
|
131
161
|
```sh
|
|
132
162
|
t3ctl thread create t3ctl "rewrite the readme for users" --branch docs/readme
|
|
@@ -146,13 +176,15 @@ Flags:
|
|
|
146
176
|
`--model` splits on the **first** slash, so slashed model names work as-is:
|
|
147
177
|
`--model opencode/github-copilot/gpt-5.4`.
|
|
148
178
|
|
|
149
|
-
### `t3ctl thread
|
|
179
|
+
### `t3ctl thread send <thread> <message...>`
|
|
150
180
|
|
|
151
|
-
Send a message and run the agent.
|
|
181
|
+
Send a message to a thread and run the agent. This is how you continue a
|
|
182
|
+
conversation — it is the same thing the app does for every message you type,
|
|
183
|
+
first or fiftieth. Aliased as `thread start`, the name it shipped under in 0.2.0. Everything after the thread reference is the
|
|
152
184
|
message — no quoting needed.
|
|
153
185
|
|
|
154
186
|
```sh
|
|
155
|
-
t3ctl thread
|
|
187
|
+
t3ctl thread send "rewrite the readme" move the endpoint tables into CONTRIBUTING.md
|
|
156
188
|
```
|
|
157
189
|
|
|
158
190
|
```
|
|
@@ -165,13 +197,42 @@ started rewrite the readme for users
|
|
|
165
197
|
|
|
166
198
|
Accepts `--model`, `--runtime-mode`, `--interaction-mode`, and `--host`. Unlike
|
|
167
199
|
`thread create`, `--model` has no default here: the thread's existing model is
|
|
168
|
-
reused unless you override it.
|
|
169
|
-
|
|
200
|
+
reused unless you override it.
|
|
201
|
+
|
|
202
|
+
### `t3ctl thread rename <thread> <new title...>`
|
|
203
|
+
|
|
204
|
+
Rename a thread. Useful when a thread was created from a script and ended up with
|
|
205
|
+
a title like a filename.
|
|
170
206
|
|
|
171
207
|
```sh
|
|
172
|
-
t3ctl thread
|
|
208
|
+
t3ctl thread rename "probe well-known" Verify hosts with the environment descriptor
|
|
173
209
|
```
|
|
174
210
|
|
|
211
|
+
### `t3ctl thread retitle <thread> [--timeout <seconds>]`
|
|
212
|
+
|
|
213
|
+
Ask the server to derive a title from the thread's own content, instead of
|
|
214
|
+
supplying one.
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
t3ctl thread retitle "probe well-known"
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
This is a **request, not a guarantee**. The server records the intent and expects
|
|
221
|
+
something downstream to generate the title; in practice it sometimes clears the
|
|
222
|
+
request a few seconds later without producing one, and reports no error when it
|
|
223
|
+
does. So `retitle` watches the thread afterwards and tells you which happened:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
asked the server to retitle Build t3ctl: a CLI for controlling T3 Code hosts (seq 677390)
|
|
227
|
+
no title was generated — the server cleared the request without producing one.
|
|
228
|
+
The title is still "Build t3ctl: a CLI for controlling T3 Code hosts". Set one directly:
|
|
229
|
+
t3ctl thread rename 861645a3-... <title...>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
It exits non-zero when no title appears, so a script can tell. Waits 30s by
|
|
233
|
+
default; `--timeout` changes that. If you need the rename to be certain, use
|
|
234
|
+
`thread rename`, which sets the title directly.
|
|
235
|
+
|
|
175
236
|
### `t3ctl thread interrupt <thread>`
|
|
176
237
|
|
|
177
238
|
Stop the turn that's currently running.
|
|
@@ -219,7 +280,7 @@ Write commands act on one host. With a single host registered, that one is
|
|
|
219
280
|
implied. With more than one, pass `--host`:
|
|
220
281
|
|
|
221
282
|
```sh
|
|
222
|
-
t3ctl thread
|
|
283
|
+
t3ctl thread send --host desktop "api rate limits" pick this back up
|
|
223
284
|
```
|
|
224
285
|
|
|
225
286
|
Otherwise you get `multiple hosts; pass --host <laptop|desktop>`.
|
|
@@ -264,9 +325,9 @@ reachable URL works.** There's nothing to configure beyond `host add`.
|
|
|
264
325
|
and give each host its own short name:
|
|
265
326
|
|
|
266
327
|
```sh
|
|
267
|
-
t3ctl host add
|
|
268
|
-
t3ctl host add
|
|
269
|
-
t3ctl host add
|
|
328
|
+
t3ctl host add http://localhost:3773 eyJ2Ijox...
|
|
329
|
+
t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
|
|
330
|
+
t3ctl host add http://10.0.0.42:3773 eyJ2Ijox...
|
|
270
331
|
t3ctl ls -t
|
|
271
332
|
```
|
|
272
333
|
|
|
@@ -274,8 +335,10 @@ t3ctl ls -t
|
|
|
274
335
|
up as `unreachable` and don't block the rest.
|
|
275
336
|
|
|
276
337
|
T3 Code's own **T3 Connect relay** (what the mobile app uses when you're off your
|
|
277
|
-
tailnet) is **not
|
|
278
|
-
|
|
338
|
+
tailnet) is **not planned**: the relay's `dpop-token` exchange only accepts a
|
|
339
|
+
Clerk *session* JWT carrying the relay audience, and its allowed scopes are keyed
|
|
340
|
+
by `client_id`, which is pinned to `t3-mobile` and `t3-web`. A third-party CLI has
|
|
341
|
+
no way to present either. The transports above are the options.
|
|
279
342
|
|
|
280
343
|
## Limitations
|
|
281
344
|
|
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());
|
|
@@ -67,7 +67,7 @@ const cmdLs = async (args) => {
|
|
|
67
67
|
const showAll = args.includes('--all') || args.includes('-a');
|
|
68
68
|
const asJson = args.includes('--json');
|
|
69
69
|
const hosts = readHosts();
|
|
70
|
-
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>');
|
|
71
71
|
|
|
72
72
|
const { ok, failed } = await collect(hosts);
|
|
73
73
|
|
|
@@ -111,22 +111,135 @@ const cmdLs = async (args) => {
|
|
|
111
111
|
for (const f of failed) console.error(`\n\x1b[31munreachable\x1b[0m ${f.host.name}: ${f.error}`);
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
-
|
|
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) => {
|
|
115
232
|
const [sub, ...rest] = args;
|
|
233
|
+
const { flags, pos } = parseArgs(rest);
|
|
116
234
|
const hosts = readHosts();
|
|
117
|
-
if (sub === 'add')
|
|
118
|
-
|
|
119
|
-
if (!
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
} else if (sub === 'rm') {
|
|
124
|
-
writeHosts(hosts.filter((h) => h.name !== rest[0]));
|
|
125
|
-
console.log(`removed ${rest[0]}`);
|
|
126
|
-
} else {
|
|
127
|
-
if (!hosts.length) return console.log('(no hosts)');
|
|
128
|
-
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;
|
|
129
241
|
}
|
|
242
|
+
return cmdHostsList(hosts);
|
|
130
243
|
};
|
|
131
244
|
|
|
132
245
|
|
|
@@ -135,7 +248,7 @@ const cmdHost = (args) => {
|
|
|
135
248
|
// commandId is the idempotency key, so retries are safe.
|
|
136
249
|
// Schemas: packages/contracts/src/orchestration.ts in pingdotgg/t3code.
|
|
137
250
|
|
|
138
|
-
const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree'];
|
|
251
|
+
const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree', 'name'];
|
|
139
252
|
|
|
140
253
|
const parseArgs = (argv) => {
|
|
141
254
|
const flags = {}, pos = [];
|
|
@@ -155,7 +268,7 @@ const pickHost = (flags) => {
|
|
|
155
268
|
if (!h) throw new Error(`no such host: ${flags.host}`);
|
|
156
269
|
return h;
|
|
157
270
|
}
|
|
158
|
-
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>');
|
|
159
272
|
if (hosts.length > 1) throw new Error(`multiple hosts; pass --host <${hosts.map((h) => h.name).join('|')}>`);
|
|
160
273
|
return hosts[0];
|
|
161
274
|
};
|
|
@@ -163,7 +276,7 @@ const pickHost = (flags) => {
|
|
|
163
276
|
const dispatch = async (host, command) => {
|
|
164
277
|
const res = await fetch(`${host.origin}/api/orchestration/dispatch`, {
|
|
165
278
|
method: 'POST',
|
|
166
|
-
headers: { authorization: `Bearer ${host.token}
|
|
279
|
+
headers: { ...(host.token ? { authorization: `Bearer ${host.token}` } : {}), 'content-type': 'application/json' },
|
|
167
280
|
body: JSON.stringify(command),
|
|
168
281
|
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
169
282
|
});
|
|
@@ -251,15 +364,88 @@ const cmdProject = async (args) => {
|
|
|
251
364
|
console.log(`created project ${bold(title)} on ${host.name}\n id ${projectId}\n root ${workspaceRoot}\n seq ${sequence}`);
|
|
252
365
|
};
|
|
253
366
|
|
|
367
|
+
|
|
368
|
+
// thread.meta.update also accepts regenerateTitle:true, which asks the server to
|
|
369
|
+
// derive a title from the thread's own content instead of taking one from us.
|
|
370
|
+
const cmdThreadRename = async (thread, host, title) => {
|
|
371
|
+
const { sequence } = await dispatch(host, {
|
|
372
|
+
type: 'thread.meta.update', commandId: crypto.randomUUID(), threadId: thread.id, title,
|
|
373
|
+
});
|
|
374
|
+
console.log(`renamed ${dim(thread.title || thread.id)} -> ${bold(title)}\n seq ${sequence}`);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Lighter than the full snapshot; retitle polls this so it does not refetch every
|
|
378
|
+
// thread's history once a second.
|
|
379
|
+
const threadDetail = async (host, threadId) => {
|
|
380
|
+
const res = await fetch(`${host.origin}/api/orchestration/threads/${threadId}?turnLimit=1`, {
|
|
381
|
+
headers: { authorization: `Bearer ${host.token}` },
|
|
382
|
+
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
383
|
+
});
|
|
384
|
+
if (!res.ok) throw new Error(`${host.name}: HTTP ${res.status}`);
|
|
385
|
+
return (await res.json()).thread;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// `regenerateTitle` does NOT rename anything by itself. The server records an
|
|
389
|
+
// intent marker (`titleRegeneration: {requestId, startedAt}`) and expects something
|
|
390
|
+
// downstream to generate a title and write it back. Observed on a live server: the
|
|
391
|
+
// marker is sometimes cleared a few seconds later with the title untouched, and no
|
|
392
|
+
// error is reported anywhere — not in the event, the command receipt, or the server
|
|
393
|
+
// trace. So we dispatch, then watch, and say what actually happened.
|
|
394
|
+
const cmdThreadRetitle = async (thread, host, timeoutSeconds) => {
|
|
395
|
+
const before = thread.title;
|
|
396
|
+
const { sequence } = await dispatch(host, {
|
|
397
|
+
type: 'thread.meta.update', commandId: crypto.randomUUID(), threadId: thread.id,
|
|
398
|
+
regenerateTitle: true,
|
|
399
|
+
});
|
|
400
|
+
console.log(`asked the server to retitle ${bold(before || thread.id)}${dim(` (seq ${sequence})`)}`);
|
|
401
|
+
|
|
402
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
403
|
+
let sawMarker = false;
|
|
404
|
+
while (Date.now() < deadline) {
|
|
405
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
406
|
+
const current = await threadDetail(host, thread.id);
|
|
407
|
+
if (current.title && current.title !== before) {
|
|
408
|
+
console.log(`retitled -> ${bold(current.title)}`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (current.titleRegeneration) { sawMarker = true; continue; }
|
|
412
|
+
if (sawMarker) break; // marker appeared and was cleared, title unchanged
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.error(
|
|
416
|
+
`\x1b[33mno title was generated\x1b[0m — the server ${sawMarker ? 'cleared the request without producing one' : `did not act on it within ${timeoutSeconds}s`}.\n` +
|
|
417
|
+
` The title is still "${before}". Set one directly:\n` +
|
|
418
|
+
` t3ctl thread rename ${thread.id} <title...>`,
|
|
419
|
+
);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
};
|
|
422
|
+
|
|
254
423
|
const cmdThread = async (args) => {
|
|
255
424
|
const [sub, ...rest] = args;
|
|
256
425
|
const { flags, pos } = parseArgs(rest);
|
|
257
|
-
|
|
426
|
+
// `send` is the accurate name: thread.turn.start posts a message to a thread and
|
|
427
|
+
// runs a turn, which is exactly what the UI does for every message, first or not.
|
|
428
|
+
// `start` stays as an alias because it shipped in 0.2.0.
|
|
429
|
+
const isSend = sub === 'send' || sub === 'start';
|
|
430
|
+
|
|
431
|
+
if (sub === 'rename' || sub === 'retitle') {
|
|
432
|
+
const [ref, ...titleParts] = pos;
|
|
433
|
+
if (!ref) return usage(`usage: t3ctl thread ${sub} <thread>` + (sub === 'rename' ? ' <new title...>' : ''));
|
|
434
|
+
const title = titleParts.join(' ');
|
|
435
|
+
if (sub === 'rename' && !title) return usage('usage: t3ctl thread rename <thread> <new title...>');
|
|
436
|
+
const host = pickHost(flags);
|
|
437
|
+
const target = resolveThread(await snapshot(host), ref);
|
|
438
|
+
return sub === 'retitle'
|
|
439
|
+
? cmdThreadRetitle(target, host, Number(flags.timeout) > 0 ? Number(flags.timeout) : 30)
|
|
440
|
+
: cmdThreadRename(target, host, title);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (isSend || sub === 'interrupt') {
|
|
258
444
|
// Validate arguments before touching the host registry, so `t3ctl thread
|
|
259
|
-
//
|
|
445
|
+
// send` with no args prints usage instead of "no hosts registered".
|
|
260
446
|
const [ref, ...rest2] = pos;
|
|
261
|
-
if (!ref) return usage(`usage: t3ctl thread ${sub} <thread>` + (
|
|
262
|
-
if (
|
|
447
|
+
if (!ref) return usage(`usage: t3ctl thread ${sub} <thread>` + (isSend ? ' <message...>' : ''));
|
|
448
|
+
if (isSend && rest2.length === 0) return usage(`usage: t3ctl thread ${sub} <thread> <message...>`);
|
|
263
449
|
const host = pickHost(flags);
|
|
264
450
|
const thread = resolveThread(await snapshot(host), ref);
|
|
265
451
|
if (sub === 'interrupt') return cmdThreadInterrupt(thread, host);
|
|
@@ -309,14 +495,18 @@ if (!commands[cmd]) {
|
|
|
309
495
|
console.log(`t3ctl — control T3 Code hosts
|
|
310
496
|
|
|
311
497
|
t3ctl ls [-t|--threads] [-a|--all] [--json] list projects and threads across hosts
|
|
312
|
-
t3ctl host add <
|
|
498
|
+
t3ctl host add <origin> [token] [--name <n>] register a host (probes it first)
|
|
313
499
|
t3ctl host rm <name> remove a host
|
|
314
|
-
t3ctl hosts list
|
|
500
|
+
t3ctl hosts list hosts and probe each one
|
|
315
501
|
|
|
316
502
|
t3ctl project create <title> <root> create a project for an existing dir
|
|
317
503
|
t3ctl thread create <project> <title> start a thread (--model inst/model,
|
|
318
504
|
--branch, --host)
|
|
319
|
-
t3ctl thread
|
|
505
|
+
t3ctl thread send <thread> <message...> send a message and run the agent
|
|
506
|
+
(alias: start)
|
|
507
|
+
t3ctl thread rename <thread> <title...> rename a thread
|
|
508
|
+
t3ctl thread retitle <thread> [--timeout n] ask the server to derive a title
|
|
509
|
+
(warns if it produces none)
|
|
320
510
|
t3ctl thread interrupt <thread> stop the running turn
|
|
321
511
|
t3ctl thread settle <thread> settle a thread (also: archive,
|
|
322
512
|
unarchive, unpin, delete)`);
|