@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.
Files changed (3) hide show
  1. package/README.md +85 -22
  2. package/package.json +1 -1
  3. 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 under a short name of your choosing
23
- t3ctl host add laptop http://localhost:3773 eyJ2Ijox...
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 <name> <origin> <token>`
84
+ ### `t3ctl host add <origin> [token] [--name <name>]`
85
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.
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 desktop https://studio.tailnet-1234.ts.net eyJ2Ijox...
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 with truncated tokens. `t3ctl host` with no subcommand does
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 http://localhost:3773 token:eyJ2Ijox…
110
- desktop https://studio.tailnet-1234.ts.net token:eyJ2Ijox…
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 start` for that.
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 start <thread> <message...>`
179
+ ### `t3ctl thread send <thread> <message...>`
150
180
 
151
- Send a message and run the agent. Everything after the thread reference is the
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 start "rewrite the readme" move the endpoint tables into CONTRIBUTING.md
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. Use `--interaction-mode plan` to make the agent
169
- plan instead of edit:
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 start "flaky release workflow" --interaction-mode plan why does the tag job race?
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 start --host desktop "api rate limits" pick this back up
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 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...
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 implemented in t3ctl** — the transports above are the options
278
- today.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobius/t3ctl",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
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
@@ -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 <name> <origin> <token>');
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
- const cmdHost = (args) => {
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
- const [name, origin, token] = rest;
119
- if (!name || !origin || !token) return usage('usage: t3ctl host add <name> <origin> <token>');
120
- const next = hosts.filter((h) => h.name !== name).concat({ name, origin: origin.replace(/\/$/, ''), token });
121
- writeHosts(next);
122
- console.log(`added ${name} -> ${origin}`);
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 <name> <origin> <token>');
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}`, 'content-type': 'application/json' },
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
- if (sub === 'start' || sub === 'interrupt') {
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
- // start` with no args prints usage instead of "no hosts registered".
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>` + (sub === 'start' ? ' <message...>' : ''));
262
- if (sub === 'start' && rest2.length === 0) return usage('usage: t3ctl thread start <thread> <message...>');
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 <name> <origin> <token> register a host
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 registered hosts
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 start <thread> <message...> send a message and run the agent
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)`);