@gobius/t3ctl 0.4.0 → 0.6.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 +133 -10
- package/dist/t3ctl.js +1213 -0
- package/dist/t3ctl.js.map +1 -0
- package/package.json +15 -3
- package/t3ctl.mjs +0 -520
package/t3ctl.mjs
DELETED
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// t3ctl — a controller CLI for T3 Code hosts.
|
|
3
|
-
// Peer of the mobile app: pairs once per host, then reads/controls remotely.
|
|
4
|
-
// Spike scope: host registry + read-only listing.
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import os from 'node:os';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
|
|
10
|
-
const CONFIG_DIR = path.join(os.homedir(), '.config', 't3ctl');
|
|
11
|
-
const HOSTS_FILE = path.join(CONFIG_DIR, 'hosts.json');
|
|
12
|
-
|
|
13
|
-
const readHosts = () => {
|
|
14
|
-
if (!fs.existsSync(HOSTS_FILE)) return [];
|
|
15
|
-
return JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8')).hosts ?? [];
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const writeHosts = (hosts) => {
|
|
19
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
20
|
-
fs.writeFileSync(HOSTS_FILE, JSON.stringify({ hosts }, null, 2), { mode: 0o600 });
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const snapshot = async (host) => {
|
|
24
|
-
const res = await fetch(`${host.origin}/api/orchestration/snapshot`, {
|
|
25
|
-
headers: host.token ? { authorization: `Bearer ${host.token}` } : {},
|
|
26
|
-
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
27
|
-
});
|
|
28
|
-
if (!res.ok) throw new Error(`${host.name}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
|
|
29
|
-
return res.json();
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
// Derived status. Order matters: most urgent wins.
|
|
33
|
-
const threadStatus = (t) => {
|
|
34
|
-
if (t.deletedAt) return 'deleted';
|
|
35
|
-
if (t.archivedAt) return 'archived';
|
|
36
|
-
if (t.session?.activeTurnId || t.session?.status === 'running') return 'running';
|
|
37
|
-
if (t.session?.status === 'error' || t.latestTurn?.state === 'error') return 'error';
|
|
38
|
-
if (t.snoozedUntil && new Date(t.snoozedUntil) > new Date()) return 'snoozed';
|
|
39
|
-
if (t.proposedPlans?.length) return 'needs-review';
|
|
40
|
-
if (t.settledAt && !t.unsettledAt) return 'settled';
|
|
41
|
-
return 'idle';
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const ICON = {
|
|
45
|
-
running: '\x1b[32m●\x1b[0m', error: '\x1b[31m✕\x1b[0m', 'needs-review': '\x1b[33m◆\x1b[0m',
|
|
46
|
-
snoozed: '\x1b[90m☾\x1b[0m', settled: '\x1b[90m✓\x1b[0m', idle: '\x1b[90m·\x1b[0m',
|
|
47
|
-
archived: '\x1b[90m▪\x1b[0m', deleted: '\x1b[90m✗\x1b[0m',
|
|
48
|
-
};
|
|
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
|
-
};
|
|
56
|
-
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
57
|
-
|
|
58
|
-
const collect = async (hosts) => {
|
|
59
|
-
const results = await Promise.allSettled(hosts.map(async (h) => ({ host: h, snap: await snapshot(h) })));
|
|
60
|
-
const ok = [], failed = [];
|
|
61
|
-
results.forEach((r, i) => r.status === 'fulfilled' ? ok.push(r.value) : failed.push({ host: hosts[i], error: r.reason.message }));
|
|
62
|
-
return { ok, failed };
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const cmdLs = async (args) => {
|
|
66
|
-
const showThreads = args.includes('--threads') || args.includes('-t');
|
|
67
|
-
const showAll = args.includes('--all') || args.includes('-a');
|
|
68
|
-
const asJson = args.includes('--json');
|
|
69
|
-
const hosts = readHosts();
|
|
70
|
-
if (!hosts.length) return console.error('No hosts registered. Run: t3ctl host add <origin> <token>');
|
|
71
|
-
|
|
72
|
-
const { ok, failed } = await collect(hosts);
|
|
73
|
-
|
|
74
|
-
if (asJson) {
|
|
75
|
-
const out = ok.flatMap(({ host, snap }) => snap.projects
|
|
76
|
-
.filter((p) => showAll || !p.deletedAt)
|
|
77
|
-
.map((p) => ({
|
|
78
|
-
host: host.name, id: p.id, title: p.title, workspaceRoot: p.workspaceRoot,
|
|
79
|
-
threads: snap.threads.filter((t) => t.projectId === p.id)
|
|
80
|
-
.filter((t) => showAll || (!t.deletedAt && !t.archivedAt))
|
|
81
|
-
.map((t) => ({ id: t.id, title: t.title, branch: t.branch, status: threadStatus(t), provider: t.session?.providerName ?? null, updatedAt: t.updatedAt })),
|
|
82
|
-
})));
|
|
83
|
-
console.log(JSON.stringify({ projects: out, unreachable: failed.map((f) => ({ host: f.host.name, error: f.error })) }, null, 2));
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
for (const { host, snap } of ok) {
|
|
88
|
-
console.log(`\n${bold(host.name)} ${dim(host.origin)}`);
|
|
89
|
-
const projects = snap.projects.filter((p) => showAll || !p.deletedAt)
|
|
90
|
-
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
91
|
-
|
|
92
|
-
for (const p of projects) {
|
|
93
|
-
const threads = snap.threads.filter((t) => t.projectId === p.id)
|
|
94
|
-
.filter((t) => showAll || (!t.deletedAt && !t.archivedAt))
|
|
95
|
-
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
96
|
-
if (!threads.length && !showAll) continue;
|
|
97
|
-
|
|
98
|
-
const counts = {};
|
|
99
|
-
for (const t of threads) counts[threadStatus(t)] = (counts[threadStatus(t)] ?? 0) + 1;
|
|
100
|
-
const badge = Object.entries(counts).map(([k, v]) => `${ICON[k] ?? '?'}${v}`).join(' ');
|
|
101
|
-
|
|
102
|
-
console.log(` ${bold(p.title)} ${badge} ${dim(p.workspaceRoot.replace(os.homedir(), '~'))}`);
|
|
103
|
-
if (!showThreads) continue;
|
|
104
|
-
for (const t of threads) {
|
|
105
|
-
const s = threadStatus(t);
|
|
106
|
-
const meta = [t.branch, t.session?.providerName].filter(Boolean).join(' ');
|
|
107
|
-
console.log(` ${ICON[s] ?? '?'} ${(t.title || '(untitled)').slice(0, 62).padEnd(62)} ${dim(meta)}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
for (const f of failed) console.error(`\n\x1b[31munreachable\x1b[0m ${f.host.name}: ${f.error}`);
|
|
112
|
-
};
|
|
113
|
-
|
|
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) => {
|
|
232
|
-
const [sub, ...rest] = args;
|
|
233
|
-
const { flags, pos } = parseArgs(rest);
|
|
234
|
-
const hosts = readHosts();
|
|
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;
|
|
241
|
-
}
|
|
242
|
-
return cmdHostsList(hosts);
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// ---- writes -------------------------------------------------------------
|
|
247
|
-
// Commands are dispatched directly (no envelope). The CLIENT mints every id;
|
|
248
|
-
// commandId is the idempotency key, so retries are safe.
|
|
249
|
-
// Schemas: packages/contracts/src/orchestration.ts in pingdotgg/t3code.
|
|
250
|
-
|
|
251
|
-
const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree', 'name'];
|
|
252
|
-
|
|
253
|
-
const parseArgs = (argv) => {
|
|
254
|
-
const flags = {}, pos = [];
|
|
255
|
-
for (let i = 0; i < argv.length; i++) {
|
|
256
|
-
const a = argv[i];
|
|
257
|
-
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
258
|
-
const k = a.slice(2);
|
|
259
|
-
flags[k] = VALUE_FLAGS.includes(k) ? argv[++i] : true;
|
|
260
|
-
}
|
|
261
|
-
return { flags, pos };
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const pickHost = (flags) => {
|
|
265
|
-
const hosts = readHosts();
|
|
266
|
-
if (flags.host) {
|
|
267
|
-
const h = hosts.find((x) => x.name === flags.host);
|
|
268
|
-
if (!h) throw new Error(`no such host: ${flags.host}`);
|
|
269
|
-
return h;
|
|
270
|
-
}
|
|
271
|
-
if (!hosts.length) throw new Error('no hosts registered — run: t3ctl host add <origin> <token>');
|
|
272
|
-
if (hosts.length > 1) throw new Error(`multiple hosts; pass --host <${hosts.map((h) => h.name).join('|')}>`);
|
|
273
|
-
return hosts[0];
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
const dispatch = async (host, command) => {
|
|
277
|
-
const res = await fetch(`${host.origin}/api/orchestration/dispatch`, {
|
|
278
|
-
method: 'POST',
|
|
279
|
-
headers: { ...(host.token ? { authorization: `Bearer ${host.token}` } : {}), 'content-type': 'application/json' },
|
|
280
|
-
body: JSON.stringify(command),
|
|
281
|
-
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
282
|
-
});
|
|
283
|
-
const body = await res.text();
|
|
284
|
-
if (!res.ok) throw new Error(`${command.type} failed: HTTP ${res.status} ${body}`);
|
|
285
|
-
return body ? JSON.parse(body) : {};
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const resolveProject = (snap, ref) =>
|
|
289
|
-
snap.projects.find((p) => p.id === ref) ??
|
|
290
|
-
snap.projects.find((p) => !p.deletedAt && p.title === ref) ??
|
|
291
|
-
snap.projects.find((p) => !p.deletedAt && p.workspaceRoot === path.resolve(ref.replace(/^~/, os.homedir())));
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// Commands whose entire payload is {commandId, threadId}. Verified against
|
|
295
|
-
// packages/contracts/src/orchestration.ts — note `unsettle` is NOT one of
|
|
296
|
-
// these (it carries extra fields), so it is deliberately absent.
|
|
297
|
-
const SIMPLE_THREAD_COMMANDS = ['settle', 'archive', 'unarchive', 'unpin', 'delete'];
|
|
298
|
-
|
|
299
|
-
const resolveThread = (snap, ref) => {
|
|
300
|
-
const live = snap.threads.filter((t) => !t.deletedAt);
|
|
301
|
-
const byId = live.find((t) => t.id === ref);
|
|
302
|
-
if (byId) return byId;
|
|
303
|
-
const exact = live.filter((t) => t.title === ref);
|
|
304
|
-
if (exact.length === 1) return exact[0];
|
|
305
|
-
const fuzzy = live.filter((t) => (t.title ?? '').toLowerCase().includes(ref.toLowerCase()));
|
|
306
|
-
if (fuzzy.length === 1) return fuzzy[0];
|
|
307
|
-
if (fuzzy.length > 1) {
|
|
308
|
-
throw new Error(`"${ref}" matches ${fuzzy.length} threads:\n` +
|
|
309
|
-
fuzzy.slice(0, 8).map((t) => ` ${t.id} ${t.title}`).join('\n'));
|
|
310
|
-
}
|
|
311
|
-
throw new Error(`no thread matching "${ref}"`);
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// thread.turn.start is ONE command carrying the first message inline — this is
|
|
316
|
-
// what the UI fires immediately after thread.create, which is why a thread with
|
|
317
|
-
// no messages is a state the UI never produces. The client-side schema requires
|
|
318
|
-
// runtimeMode/interactionMode explicitly (the server-side one defaults them).
|
|
319
|
-
const cmdThreadStart = async (thread, host, text, flags) => {
|
|
320
|
-
const command = {
|
|
321
|
-
type: 'thread.turn.start',
|
|
322
|
-
commandId: crypto.randomUUID(),
|
|
323
|
-
threadId: thread.id,
|
|
324
|
-
message: { messageId: crypto.randomUUID(), role: 'user', text, attachments: [] },
|
|
325
|
-
runtimeMode: flags['runtime-mode'] ?? thread.runtimeMode ?? 'full-access',
|
|
326
|
-
interactionMode: flags['interaction-mode'] ?? 'default',
|
|
327
|
-
createdAt: new Date().toISOString(),
|
|
328
|
-
};
|
|
329
|
-
if (flags.model) {
|
|
330
|
-
const slash = flags.model.indexOf('/');
|
|
331
|
-
if (slash < 1) throw new Error(`--model must be <instance>/<model>, got "${flags.model}"`);
|
|
332
|
-
command.modelSelection = { instanceId: flags.model.slice(0, slash), model: flags.model.slice(slash + 1) };
|
|
333
|
-
} else if (thread.modelSelection) {
|
|
334
|
-
command.modelSelection = thread.modelSelection;
|
|
335
|
-
}
|
|
336
|
-
const { sequence } = await dispatch(host, command);
|
|
337
|
-
const m = command.modelSelection;
|
|
338
|
-
console.log(`started ${bold(thread.title || thread.id)}\n id ${thread.id}` +
|
|
339
|
-
(m ? `\n model ${m.instanceId}/${m.model}` : '') +
|
|
340
|
-
`\n mode ${command.runtimeMode} / ${command.interactionMode}\n seq ${sequence}`);
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const cmdThreadInterrupt = async (thread, host) => {
|
|
344
|
-
const { sequence } = await dispatch(host, {
|
|
345
|
-
type: 'thread.turn.interrupt', commandId: crypto.randomUUID(), threadId: thread.id,
|
|
346
|
-
});
|
|
347
|
-
console.log(`interrupted ${bold(thread.title || thread.id)}\n seq ${sequence}`);
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
const cmdProject = async (args) => {
|
|
351
|
-
const [sub, ...rest] = args;
|
|
352
|
-
const { flags, pos } = parseArgs(rest);
|
|
353
|
-
if (sub !== 'create') return usage('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
|
|
354
|
-
const [title, root] = pos;
|
|
355
|
-
if (!title || !root) return usage('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
|
|
356
|
-
const host = pickHost(flags);
|
|
357
|
-
const workspaceRoot = path.resolve(root.replace(/^~/, os.homedir()));
|
|
358
|
-
if (!fs.existsSync(workspaceRoot)) return console.error(`workspace root does not exist: ${workspaceRoot}`);
|
|
359
|
-
const projectId = crypto.randomUUID();
|
|
360
|
-
const { sequence } = await dispatch(host, {
|
|
361
|
-
type: 'project.create', commandId: crypto.randomUUID(),
|
|
362
|
-
projectId, title, workspaceRoot, createdAt: new Date().toISOString(),
|
|
363
|
-
});
|
|
364
|
-
console.log(`created project ${bold(title)} on ${host.name}\n id ${projectId}\n root ${workspaceRoot}\n seq ${sequence}`);
|
|
365
|
-
};
|
|
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
|
-
|
|
423
|
-
const cmdThread = async (args) => {
|
|
424
|
-
const [sub, ...rest] = args;
|
|
425
|
-
const { flags, pos } = parseArgs(rest);
|
|
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') {
|
|
444
|
-
// Validate arguments before touching the host registry, so `t3ctl thread
|
|
445
|
-
// send` with no args prints usage instead of "no hosts registered".
|
|
446
|
-
const [ref, ...rest2] = pos;
|
|
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...>`);
|
|
449
|
-
const host = pickHost(flags);
|
|
450
|
-
const thread = resolveThread(await snapshot(host), ref);
|
|
451
|
-
if (sub === 'interrupt') return cmdThreadInterrupt(thread, host);
|
|
452
|
-
return cmdThreadStart(thread, host, rest2.join(' '), flags);
|
|
453
|
-
}
|
|
454
|
-
if (SIMPLE_THREAD_COMMANDS.includes(sub)) {
|
|
455
|
-
if (!pos.length) return usage(`usage: t3ctl thread ${sub} <thread>`);
|
|
456
|
-
const host = pickHost(flags);
|
|
457
|
-
const thread = resolveThread(await snapshot(host), pos.join(' '));
|
|
458
|
-
const { sequence } = await dispatch(host, {
|
|
459
|
-
type: `thread.${sub}`, commandId: crypto.randomUUID(), threadId: thread.id,
|
|
460
|
-
});
|
|
461
|
-
console.log(`${sub}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
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>');
|
|
465
|
-
const [projectRef, ...titleParts] = pos;
|
|
466
|
-
const title = titleParts.join(' ');
|
|
467
|
-
if (!projectRef || !title) return usage('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
|
|
468
|
-
const host = pickHost(flags);
|
|
469
|
-
const project = resolveProject(await snapshot(host), projectRef);
|
|
470
|
-
if (!project) throw new Error(`no project matching "${projectRef}" on ${host.name}`);
|
|
471
|
-
|
|
472
|
-
// instanceId is the segment before the first slash; model keeps the rest
|
|
473
|
-
// (opencode models look like "github-copilot/gpt-5.4").
|
|
474
|
-
const raw = flags.model ?? 'claudeAgent/claude-opus-5';
|
|
475
|
-
const slash = raw.indexOf('/');
|
|
476
|
-
if (slash < 1) throw new Error(`--model must be <instance>/<model>, got "${raw}"`);
|
|
477
|
-
const modelSelection = { instanceId: raw.slice(0, slash), model: raw.slice(slash + 1) };
|
|
478
|
-
|
|
479
|
-
const threadId = crypto.randomUUID();
|
|
480
|
-
const { sequence } = await dispatch(host, {
|
|
481
|
-
type: 'thread.create', commandId: crypto.randomUUID(),
|
|
482
|
-
threadId, projectId: project.id, title, modelSelection,
|
|
483
|
-
runtimeMode: flags['runtime-mode'] ?? 'full-access',
|
|
484
|
-
interactionMode: flags['interaction-mode'] ?? 'default',
|
|
485
|
-
branch: flags.branch ?? null,
|
|
486
|
-
worktreePath: flags.worktree ?? null,
|
|
487
|
-
createdAt: new Date().toISOString(),
|
|
488
|
-
});
|
|
489
|
-
console.log(`created thread ${bold(title)} in ${project.title} on ${host.name}\n id ${threadId}\n model ${modelSelection.instanceId}/${modelSelection.model}\n seq ${sequence}`);
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
const [cmd, ...rest] = process.argv.slice(2);
|
|
493
|
-
const commands = { ls: cmdLs, host: cmdHost, hosts: () => cmdHost([]), project: cmdProject, thread: cmdThread };
|
|
494
|
-
if (!commands[cmd]) {
|
|
495
|
-
console.log(`t3ctl — control T3 Code hosts
|
|
496
|
-
|
|
497
|
-
t3ctl ls [-t|--threads] [-a|--all] [--json] list projects and threads across hosts
|
|
498
|
-
t3ctl host add <origin> [token] [--name <n>] register a host (probes it first)
|
|
499
|
-
t3ctl host rm <name> remove a host
|
|
500
|
-
t3ctl hosts list hosts and probe each one
|
|
501
|
-
|
|
502
|
-
t3ctl project create <title> <root> create a project for an existing dir
|
|
503
|
-
t3ctl thread create <project> <title> start a thread (--model inst/model,
|
|
504
|
-
--branch, --host)
|
|
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)
|
|
510
|
-
t3ctl thread interrupt <thread> stop the running turn
|
|
511
|
-
t3ctl thread settle <thread> settle a thread (also: archive,
|
|
512
|
-
unarchive, unpin, delete)`);
|
|
513
|
-
process.exit(cmd ? 1 : 0);
|
|
514
|
-
}
|
|
515
|
-
try {
|
|
516
|
-
await commands[cmd](rest);
|
|
517
|
-
} catch (error) {
|
|
518
|
-
console.error(`\x1b[31merror\x1b[0m ${error instanceof Error ? error.message : String(error)}`);
|
|
519
|
-
process.exit(1);
|
|
520
|
-
}
|