@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/dist/t3ctl.js
ADDED
|
@@ -0,0 +1,1213 @@
|
|
|
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
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import { Command } from 'commander';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 't3ctl');
|
|
13
|
+
const HOSTS_FILE = path.join(CONFIG_DIR, 'hosts.json');
|
|
14
|
+
const readHosts = () => {
|
|
15
|
+
if (!fs.existsSync(HOSTS_FILE))
|
|
16
|
+
return [];
|
|
17
|
+
return JSON.parse(fs.readFileSync(HOSTS_FILE, 'utf8')).hosts ?? [];
|
|
18
|
+
};
|
|
19
|
+
const writeHosts = (hosts) => {
|
|
20
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
fs.writeFileSync(HOSTS_FILE, JSON.stringify({ hosts }, null, 2), { mode: 0o600 });
|
|
22
|
+
};
|
|
23
|
+
const snapshot = async (host) => {
|
|
24
|
+
await ensureTunnel(host);
|
|
25
|
+
const res = await fetch(`${host.origin}/api/orchestration/snapshot`, {
|
|
26
|
+
headers: host.token ? { authorization: `Bearer ${host.token}` } : {},
|
|
27
|
+
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new Error(`${host.name}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim());
|
|
31
|
+
return (await res.json());
|
|
32
|
+
};
|
|
33
|
+
// Derived status. Order matters: most urgent wins.
|
|
34
|
+
const threadStatus = (t) => {
|
|
35
|
+
if (t.deletedAt)
|
|
36
|
+
return 'deleted';
|
|
37
|
+
if (t.archivedAt)
|
|
38
|
+
return 'archived';
|
|
39
|
+
if (t.session?.activeTurnId || t.session?.status === 'running')
|
|
40
|
+
return 'running';
|
|
41
|
+
if (t.session?.status === 'error' || t.latestTurn?.state === 'error')
|
|
42
|
+
return 'error';
|
|
43
|
+
if (t.snoozedUntil && new Date(t.snoozedUntil) > new Date())
|
|
44
|
+
return 'snoozed';
|
|
45
|
+
if (t.proposedPlans?.length)
|
|
46
|
+
return 'needs-review';
|
|
47
|
+
if (t.settledAt && !t.unsettledAt)
|
|
48
|
+
return 'settled';
|
|
49
|
+
return 'idle';
|
|
50
|
+
};
|
|
51
|
+
const ICON = {
|
|
52
|
+
running: '\x1b[32m●\x1b[0m', error: '\x1b[31m✕\x1b[0m', 'needs-review': '\x1b[33m◆\x1b[0m',
|
|
53
|
+
snoozed: '\x1b[90m☾\x1b[0m', settled: '\x1b[90m✓\x1b[0m', idle: '\x1b[90m·\x1b[0m',
|
|
54
|
+
archived: '\x1b[90m▪\x1b[0m', deleted: '\x1b[90m✗\x1b[0m',
|
|
55
|
+
};
|
|
56
|
+
/** `catch` binds `unknown`; every call site wants the same string out of it. */
|
|
57
|
+
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
58
|
+
const dim = (s) => `\x1b[90m${s}\x1b[0m`;
|
|
59
|
+
// Usage errors are user errors: print to stderr and exit non-zero so scripts
|
|
60
|
+
// can tell them apart from success.
|
|
61
|
+
const usage = (message) => {
|
|
62
|
+
console.error(message);
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
};
|
|
65
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
66
|
+
const collect = async (hosts) => {
|
|
67
|
+
const results = await Promise.allSettled(hosts.map(async (h) => ({ host: h, snap: await snapshot(h) })));
|
|
68
|
+
const ok = [];
|
|
69
|
+
const failed = [];
|
|
70
|
+
results.forEach((r, i) => {
|
|
71
|
+
const host = hosts[i];
|
|
72
|
+
if (!host)
|
|
73
|
+
return;
|
|
74
|
+
if (r.status === 'fulfilled')
|
|
75
|
+
ok.push(r.value);
|
|
76
|
+
else
|
|
77
|
+
failed.push({ host, error: errorMessage(r.reason) });
|
|
78
|
+
});
|
|
79
|
+
return { ok, failed };
|
|
80
|
+
};
|
|
81
|
+
const cmdLs = async ({ threads: showThreads, all: showAll, json: asJson }) => {
|
|
82
|
+
const hosts = readHosts();
|
|
83
|
+
if (!hosts.length)
|
|
84
|
+
return console.error('No hosts registered. Run: t3ctl host add <origin> <token>');
|
|
85
|
+
const { ok, failed } = await collect(hosts);
|
|
86
|
+
if (asJson) {
|
|
87
|
+
const out = ok.flatMap(({ host, snap }) => snap.projects
|
|
88
|
+
.filter((p) => showAll || !p.deletedAt)
|
|
89
|
+
.map((p) => ({
|
|
90
|
+
host: host.name, id: p.id, title: p.title, workspaceRoot: p.workspaceRoot,
|
|
91
|
+
threads: snap.threads.filter((t) => t.projectId === p.id)
|
|
92
|
+
.filter((t) => showAll || (!t.deletedAt && !t.archivedAt))
|
|
93
|
+
.map((t) => ({ id: t.id, title: t.title, branch: t.branch, status: threadStatus(t), provider: t.session?.providerName ?? null, updatedAt: t.updatedAt })),
|
|
94
|
+
})));
|
|
95
|
+
console.log(JSON.stringify({ projects: out, unreachable: failed.map((f) => ({ host: f.host.name, error: f.error })) }, null, 2));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const { host, snap } of ok) {
|
|
99
|
+
console.log(`\n${bold(host.name)} ${dim(host.origin)}`);
|
|
100
|
+
const projects = snap.projects.filter((p) => showAll || !p.deletedAt)
|
|
101
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
102
|
+
for (const p of projects) {
|
|
103
|
+
const threads = snap.threads.filter((t) => t.projectId === p.id)
|
|
104
|
+
.filter((t) => showAll || (!t.deletedAt && !t.archivedAt))
|
|
105
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
106
|
+
if (!threads.length && !showAll)
|
|
107
|
+
continue;
|
|
108
|
+
const counts = {};
|
|
109
|
+
for (const t of threads)
|
|
110
|
+
counts[threadStatus(t)] = (counts[threadStatus(t)] ?? 0) + 1;
|
|
111
|
+
const badge = Object.entries(counts).map(([k, v]) => `${ICON[k] ?? '?'}${v}`).join(' ');
|
|
112
|
+
console.log(` ${bold(p.title)} ${badge} ${dim(p.workspaceRoot.replace(os.homedir(), '~'))}`);
|
|
113
|
+
if (!showThreads)
|
|
114
|
+
continue;
|
|
115
|
+
for (const t of threads) {
|
|
116
|
+
const s = threadStatus(t);
|
|
117
|
+
const meta = [t.branch, t.session?.providerName].filter(Boolean).join(' ');
|
|
118
|
+
console.log(` ${ICON[s] ?? '?'} ${(t.title || '(untitled)').slice(0, 62).padEnd(62)} ${dim(meta)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const f of failed)
|
|
123
|
+
console.error(`\n\x1b[31munreachable\x1b[0m ${f.host.name}: ${f.error}`);
|
|
124
|
+
};
|
|
125
|
+
// ---- host registry ------------------------------------------------------
|
|
126
|
+
// The descriptor at /.well-known/t3/environment is UNAUTHENTICATED, so probing
|
|
127
|
+
// it answers "is anyone home, and is it T3 Code?" without a token — a wrong
|
|
128
|
+
// origin fails here instead of as a baffling 401 on the first real call.
|
|
129
|
+
// Schema: ExecutionEnvironmentDescriptor in packages/contracts/src/environment.ts.
|
|
130
|
+
const DESCRIPTOR_PATH = '/.well-known/t3/environment';
|
|
131
|
+
const probe = async (origin, timeoutMs = 5000) => {
|
|
132
|
+
let res;
|
|
133
|
+
try {
|
|
134
|
+
res = await fetch(`${origin}${DESCRIPTOR_PATH}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Node's fetch reports a bare "fetch failed"; the cause carries the real reason.
|
|
138
|
+
const e = error;
|
|
139
|
+
const why = e.name === 'TimeoutError' ? `no response in ${timeoutMs}ms`
|
|
140
|
+
: (e.cause?.message ?? e.message ?? String(error));
|
|
141
|
+
throw new Error(`cannot reach ${origin}: ${why}`);
|
|
142
|
+
}
|
|
143
|
+
const notT3 = (why) => new Error(`not a T3 Code server (${DESCRIPTOR_PATH} ${why})`);
|
|
144
|
+
if (!res.ok)
|
|
145
|
+
throw notT3(`returned HTTP ${res.status}`);
|
|
146
|
+
let d;
|
|
147
|
+
try {
|
|
148
|
+
d = (await res.json());
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw notT3('is not JSON');
|
|
152
|
+
}
|
|
153
|
+
const required = ['environmentId', 'label', 'serverVersion'];
|
|
154
|
+
const missing = required.filter((k) => typeof d[k] !== 'string' || !d[k]);
|
|
155
|
+
if (missing.length)
|
|
156
|
+
throw notT3(`is missing ${missing.join(', ')}`);
|
|
157
|
+
return {
|
|
158
|
+
environmentId: d['environmentId'],
|
|
159
|
+
label: d['label'],
|
|
160
|
+
serverVersion: d['serverVersion'],
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
const shortId = (id) => (id ? id.slice(0, 8) : '-');
|
|
164
|
+
const warn = (message) => console.error(`\x1b[33mwarning\x1b[0m ${message}`);
|
|
165
|
+
// The name is what you type in --host, so derive a typeable slug from the label
|
|
166
|
+
// rather than using the label verbatim.
|
|
167
|
+
const slugify = (label) => label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'host';
|
|
168
|
+
const uniqueName = (base, hosts) => {
|
|
169
|
+
if (!hosts.some((h) => h.name === base))
|
|
170
|
+
return base;
|
|
171
|
+
for (let n = 2;; n++)
|
|
172
|
+
if (!hosts.some((h) => h.name === `${base}-${n}`))
|
|
173
|
+
return `${base}-${n}`;
|
|
174
|
+
};
|
|
175
|
+
const isOrigin = (value) => /^https?:\/\//i.test(value ?? '');
|
|
176
|
+
const parseHostAdd = (pos) => {
|
|
177
|
+
const [origin, token] = pos;
|
|
178
|
+
return origin && isOrigin(origin) ? { origin, token: token ?? null } : null;
|
|
179
|
+
};
|
|
180
|
+
const HOST_ADD_USAGE = 'usage: t3ctl host add <origin> [token] [--name <name>]\n' +
|
|
181
|
+
' an origin needs a scheme, e.g. http://localhost:3773';
|
|
182
|
+
const cmdHostAdd = async (pos, flags, hosts) => {
|
|
183
|
+
// Validate before any network or registry work so a bare `host add` prints
|
|
184
|
+
// usage instead of stalling on a probe.
|
|
185
|
+
const parsed = parseHostAdd(pos);
|
|
186
|
+
if (!parsed)
|
|
187
|
+
return usage(HOST_ADD_USAGE);
|
|
188
|
+
const origin = parsed.origin.replace(/\/$/, '');
|
|
189
|
+
const descriptor = await probe(origin);
|
|
190
|
+
const existing = hosts.find((h) => h.origin === origin);
|
|
191
|
+
// A changed environmentId on a known origin means the origin now points at a
|
|
192
|
+
// different machine — the stored token almost certainly belongs to the old one.
|
|
193
|
+
if (existing?.environmentId && existing.environmentId !== descriptor.environmentId) {
|
|
194
|
+
warn(`${origin} is now a DIFFERENT environment\n` +
|
|
195
|
+
` was ${existing.environmentId} (${existing.label ?? 'unknown'})\n` +
|
|
196
|
+
` now ${descriptor.environmentId} (${descriptor.label})\n` +
|
|
197
|
+
` the token stored for "${existing.name}" was issued by the old one and will likely fail`);
|
|
198
|
+
}
|
|
199
|
+
const name = flags.name ?? existing?.name ??
|
|
200
|
+
uniqueName(slugify(descriptor.label), hosts);
|
|
201
|
+
const token = parsed.token ?? existing?.token ?? null;
|
|
202
|
+
const others = hosts.filter((h) => h.name !== name && h.origin !== origin && h.serverVersion);
|
|
203
|
+
const skewed = [...new Set(others.flatMap((h) => (h.serverVersion ? [h.serverVersion] : [])))]
|
|
204
|
+
.filter((v) => v !== descriptor.serverVersion);
|
|
205
|
+
if (skewed.length)
|
|
206
|
+
warn(`serverVersion ${descriptor.serverVersion} differs from other hosts: ${skewed.join(', ')}`);
|
|
207
|
+
writeHosts(hosts.filter((h) => h.name !== name && h.origin !== origin).concat({
|
|
208
|
+
name, origin, token,
|
|
209
|
+
environmentId: descriptor.environmentId,
|
|
210
|
+
label: descriptor.label,
|
|
211
|
+
serverVersion: descriptor.serverVersion,
|
|
212
|
+
}));
|
|
213
|
+
console.log(`added ${bold(name)} -> ${origin}\n label ${descriptor.label}\n` +
|
|
214
|
+
` env ${descriptor.environmentId}\n version ${descriptor.serverVersion}`);
|
|
215
|
+
if (!token)
|
|
216
|
+
warn(`no token stored for ${name} — reads will fail until you run: t3ctl host add ${origin} <token>`);
|
|
217
|
+
};
|
|
218
|
+
const cmdHostsList = async (hosts) => {
|
|
219
|
+
if (!hosts.length)
|
|
220
|
+
return console.log('(no hosts)');
|
|
221
|
+
await Promise.allSettled(hosts.filter((h) => h.ssh).map((h) => ensureTunnel(h)));
|
|
222
|
+
const probes = await Promise.allSettled(hosts.map((h) => probe(h.origin)));
|
|
223
|
+
const drifted = [];
|
|
224
|
+
hosts.forEach((h, i) => {
|
|
225
|
+
const p = probes[i];
|
|
226
|
+
const live = p?.status === 'fulfilled' ? p.value : null;
|
|
227
|
+
if (live && h.environmentId && h.environmentId !== live.environmentId)
|
|
228
|
+
drifted.push({ h, live });
|
|
229
|
+
const icon = live ? ICON.running : ICON.error;
|
|
230
|
+
const label = live?.label ?? h.label ?? '-';
|
|
231
|
+
const env = shortId(live?.environmentId ?? h.environmentId);
|
|
232
|
+
const version = live?.serverVersion ?? h.serverVersion ?? '-';
|
|
233
|
+
// Values for an unreachable host are whatever was last stored, so dim the
|
|
234
|
+
// whole row to keep remembered data visually distinct from probed data.
|
|
235
|
+
const cell = (text, width) => (live ? text.padEnd(width) : dim(text.padEnd(width)));
|
|
236
|
+
const where = dim(h.ssh ? `${h.ssh} -> ${h.origin}` : h.origin);
|
|
237
|
+
console.log(`${icon} ${live ? bold(h.name.padEnd(14)) : dim(h.name.padEnd(14))} ${cell(label, 18)} ${dim(env.padEnd(9))} ${cell(version, 28)} ${where}` +
|
|
238
|
+
(live || !p || p.status !== 'rejected' ? '' : ` ${dim(errorMessage(p.reason))}`));
|
|
239
|
+
});
|
|
240
|
+
for (const { h, live } of drifted) {
|
|
241
|
+
warn(`${h.name} (${h.origin}) is now a DIFFERENT environment\n` +
|
|
242
|
+
` was ${h.environmentId} (${h.label ?? 'unknown'})\n now ${live.environmentId} (${live.label})`);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
// ---- ssh hosts ----------------------------------------------------------
|
|
246
|
+
// `t3ctl host add agent@box` fully bootstraps a remote T3 Code host: detect a
|
|
247
|
+
// running server, install the boot service if none, mint a token, and keep a
|
|
248
|
+
// local ssh port-forward alive. Three properties of T3 Code make "just an ssh
|
|
249
|
+
// login" enough:
|
|
250
|
+
// * the server records {pid, port} in ~/.t3/userdata/server-runtime.json, so
|
|
251
|
+
// the port is discoverable, never hardcoded (3773 or an ephemeral fallback,
|
|
252
|
+
// rewritten on every start);
|
|
253
|
+
// * `t3 auth session issue` mints a bearer token filesystem-locally — no HTTP,
|
|
254
|
+
// no --port — valid for the already-running server sharing ~/.t3, so t3ctl
|
|
255
|
+
// can mint its own token over ssh;
|
|
256
|
+
// * `t3 service install` is per-user launchd/systemd (no sudo, macOS/Linux).
|
|
257
|
+
// Remote shell scripts pass data back on stdout as lines starting with a
|
|
258
|
+
// marker, parsed bottom-up: `t3 auth` can leak Effect error logs onto stdout
|
|
259
|
+
// (the desktop's own SSH path parses the same way, tunnel.ts:791).
|
|
260
|
+
// npm package is literally `t3`; exact versions are pinned because the boot
|
|
261
|
+
// service installs that same version into its pinned runtime.
|
|
262
|
+
const SSH_READY_MS = 90_000; // server readiness after install (npm download can be slow)
|
|
263
|
+
const TUNNEL_WAIT_MS = 30_000; // ssh auth may prompt interactively; give it time
|
|
264
|
+
const PROBE_MS = 2500; // liveness probe of the local tunnel end
|
|
265
|
+
const T3_PACKAGE = 't3';
|
|
266
|
+
const SSH_SCRIPT = `set -eu
|
|
267
|
+
# PATH discovery for NON-INTERACTIVE ssh shells, trimmed from T3 Code's own
|
|
268
|
+
# remote runner (packages/ssh/src/tunnel.ts REMOTE_NODE_ENV_SCRIPT): plain PATH
|
|
269
|
+
# additions, version-manager shims, nvm. Engine checks and the "prefer installed
|
|
270
|
+
# t3" branch are dropped on purpose — t3ctl pins an exact version and runs npx.
|
|
271
|
+
prepend_path_if_dir() {
|
|
272
|
+
if [ -d "$1" ]; then
|
|
273
|
+
case ":$PATH:" in
|
|
274
|
+
*":$1:"*) ;;
|
|
275
|
+
*) PATH="$1:$PATH" ;;
|
|
276
|
+
esac
|
|
277
|
+
fi
|
|
278
|
+
}
|
|
279
|
+
ensure_node_path() {
|
|
280
|
+
command -v node >/dev/null 2>&1 && return 0
|
|
281
|
+
prepend_path_if_dir "$HOME/.local/bin"
|
|
282
|
+
prepend_path_if_dir "$HOME/bin"
|
|
283
|
+
prepend_path_if_dir "/opt/homebrew/bin"
|
|
284
|
+
prepend_path_if_dir "/usr/local/bin"
|
|
285
|
+
prepend_path_if_dir "/usr/bin"
|
|
286
|
+
prepend_path_if_dir "/bin"
|
|
287
|
+
if [ -z "\${VOLTA_HOME:-}" ]; then VOLTA_HOME="$HOME/.volta"; fi
|
|
288
|
+
prepend_path_if_dir "$VOLTA_HOME/bin"
|
|
289
|
+
prepend_path_if_dir "$HOME/.asdf/shims"
|
|
290
|
+
prepend_path_if_dir "$HOME/.asdf/bin"
|
|
291
|
+
if [ ! -x "$HOME/.asdf/shims/node" ] && [ -s "$HOME/.asdf/asdf.sh" ]; then . "$HOME/.asdf/asdf.sh"; fi
|
|
292
|
+
prepend_path_if_dir "$HOME/.local/share/mise/shims"
|
|
293
|
+
prepend_path_if_dir "$HOME/.mise/shims"
|
|
294
|
+
if ! command -v node >/dev/null 2>&1 && command -v mise >/dev/null 2>&1; then eval "$(mise activate sh)" >/dev/null 2>&1 || true; fi
|
|
295
|
+
if [ -z "\${FNM_DIR:-}" ]; then FNM_DIR="$HOME/.local/share/fnm"; fi
|
|
296
|
+
prepend_path_if_dir "$FNM_DIR"
|
|
297
|
+
prepend_path_if_dir "$HOME/.fnm"
|
|
298
|
+
if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then
|
|
299
|
+
eval "$(fnm env --shell bash)" >/dev/null 2>&1 || true
|
|
300
|
+
fnm use --silent-if-unchanged >/dev/null 2>&1 || fnm use default >/dev/null 2>&1 || true
|
|
301
|
+
fi
|
|
302
|
+
prepend_path_if_dir "$HOME/.nodenv/bin"
|
|
303
|
+
prepend_path_if_dir "$HOME/.nodenv/shims"
|
|
304
|
+
if ! command -v node >/dev/null 2>&1 && command -v nodenv >/dev/null 2>&1; then eval "$(nodenv init -)" >/dev/null 2>&1 || true; fi
|
|
305
|
+
if [ -z "\${NVM_DIR:-}" ]; then NVM_DIR="$HOME/.nvm"; fi
|
|
306
|
+
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
|
307
|
+
. "$NVM_DIR/nvm.sh"
|
|
308
|
+
if ! command -v node >/dev/null 2>&1 && command -v nvm >/dev/null 2>&1; then
|
|
309
|
+
nvm use --silent default >/dev/null 2>&1 || nvm use --silent node >/dev/null 2>&1 || nvm use --silent --lts >/dev/null 2>&1 || true
|
|
310
|
+
fi
|
|
311
|
+
fi
|
|
312
|
+
if ! command -v node >/dev/null 2>&1 && [ -d "$NVM_DIR/versions/node" ]; then
|
|
313
|
+
for NODE_BIN in "$NVM_DIR"/versions/node/*/bin; do
|
|
314
|
+
if [ -x "$NODE_BIN/node" ]; then PATH="$NODE_BIN:$PATH"; fi
|
|
315
|
+
done
|
|
316
|
+
fi
|
|
317
|
+
command -v node >/dev/null 2>&1
|
|
318
|
+
}
|
|
319
|
+
# npm extracts a package before running its deps' native builds; a failed
|
|
320
|
+
# node-pty build leaves the npx cache WITHOUT a t3 bin while \`npx --yes\` still
|
|
321
|
+
# exits 0 (same guard as T3 Code's require_installed_t3_cli). Resolve up front
|
|
322
|
+
# so the failure is reported here, with npm's own output on stderr.
|
|
323
|
+
require_t3_cli() {
|
|
324
|
+
T3_CLI_PATH="$(npx --yes --package t3@"$1" -- sh -c 'command -v t3' 2>/dev/null || true)"
|
|
325
|
+
if [ -n "$T3_CLI_PATH" ]; then return 0; fi
|
|
326
|
+
printf 'npm installed t3@"%s" but produced no t3 executable — usually a native dependency (node-pty) failed to build. Install a C toolchain on this host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and retry.\\n' "$1" >&2
|
|
327
|
+
return 1
|
|
328
|
+
}
|
|
329
|
+
# detect: is there a T3 Code server running on this machine? The runtime file is
|
|
330
|
+
# rewritten on every server start and cleared on shutdown, so its pid is
|
|
331
|
+
# checked before its port is trusted (stale file, reused port).
|
|
332
|
+
RUNTIME_JSON="$HOME/.t3/userdata/server-runtime.json"
|
|
333
|
+
detect() {
|
|
334
|
+
if ! ensure_node_path; then
|
|
335
|
+
printf 'T3CTL {"node":false}\\n'
|
|
336
|
+
return 0
|
|
337
|
+
fi
|
|
338
|
+
node - "$RUNTIME_JSON" <<'NODE'
|
|
339
|
+
const fs = require('node:fs');
|
|
340
|
+
try {
|
|
341
|
+
const r = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
|
342
|
+
const pid = Number(r.pid), port = Number(r.port);
|
|
343
|
+
if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) throw 0;
|
|
344
|
+
const origin = new URL(String(r.origin ?? ''));
|
|
345
|
+
if (origin.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(origin.hostname)) throw 0;
|
|
346
|
+
process.kill(pid, 0);
|
|
347
|
+
process.stdout.write('T3CTL {"running":true,"port":' + port + '}\\n');
|
|
348
|
+
} catch {
|
|
349
|
+
process.stdout.write('T3CTL {"running":false}\\n');
|
|
350
|
+
}
|
|
351
|
+
NODE
|
|
352
|
+
}
|
|
353
|
+
case "$1" in
|
|
354
|
+
detect) detect ;;
|
|
355
|
+
install)
|
|
356
|
+
ensure_node_path || { printf 'no node on this machine — install Node 22+ (non-interactive shells may need a version manager configured)\\n' >&2; exit 1; }
|
|
357
|
+
require_t3_cli "$2" || exit 1
|
|
358
|
+
exec npx --yes --package t3@"$2" t3 service install
|
|
359
|
+
;;
|
|
360
|
+
token)
|
|
361
|
+
ensure_node_path || { printf 'no node on this machine\\n' >&2; exit 1; }
|
|
362
|
+
require_t3_cli "$2" || exit 1
|
|
363
|
+
exec npx --yes --package t3@"$2" t3 auth session issue --json --label "$3" --ttl "$4"
|
|
364
|
+
;;
|
|
365
|
+
*) printf 'unknown op: %s\\n' "$1" >&2; exit 2 ;;
|
|
366
|
+
esac
|
|
367
|
+
`;
|
|
368
|
+
const sshRun = (target, args, { streamStderr = false } = {}) => {
|
|
369
|
+
const res = spawnSync('ssh', ['-o', 'ConnectTimeout=15', target, 'sh', '-s', '--', ...args], {
|
|
370
|
+
input: SSH_SCRIPT,
|
|
371
|
+
encoding: 'utf8',
|
|
372
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
373
|
+
stdio: streamStderr ? ['pipe', 'pipe', 'inherit'] : 'pipe',
|
|
374
|
+
});
|
|
375
|
+
if (res.error?.code === 'ENOENT')
|
|
376
|
+
throw new Error('ssh not found locally');
|
|
377
|
+
return { status: res.status ?? 1, stdout: String(res.stdout ?? ''), stderr: String(res.stderr ?? '') };
|
|
378
|
+
};
|
|
379
|
+
// Bottom-up scan for the last T3CTL JSON marker — immune to npm chatter above it.
|
|
380
|
+
const sshDetect = (target) => {
|
|
381
|
+
const res = sshRun(target, ['detect']);
|
|
382
|
+
if (res.status !== 0)
|
|
383
|
+
throw new Error(`ssh to ${target} failed (exit ${res.status})${res.stderr.trim() ? `: ${res.stderr.trim().split('\n').slice(-2).join('\n')}` : ''}`);
|
|
384
|
+
const line = res.stdout.split('\n').reverse().find((l) => l.startsWith('T3CTL {'));
|
|
385
|
+
if (!line)
|
|
386
|
+
throw new Error(`detect on ${target} produced no answer`);
|
|
387
|
+
return JSON.parse(line.slice('T3CTL '.length));
|
|
388
|
+
};
|
|
389
|
+
const sshToken = (target, version, label, ttl) => {
|
|
390
|
+
// First run downloads t3@<version> into the remote npx cache — minutes with
|
|
391
|
+
// nothing to show unless npm's progress on stderr streams through.
|
|
392
|
+
const res = sshRun(target, ['token', version, label, ttl], { streamStderr: true });
|
|
393
|
+
if (res.status !== 0)
|
|
394
|
+
throw new Error(`token minting failed on ${target}${res.stderr.trim() ? `: ${res.stderr.trim().split('\n').slice(-3).join('\n')}` : ''}`);
|
|
395
|
+
// The session JSON is pretty-printed over many lines and stray Effect error
|
|
396
|
+
// lines can precede it, so accumulate bottom-up until an object with a token
|
|
397
|
+
// field parses — the token is the only reliable anchor.
|
|
398
|
+
const lines = res.stdout.split('\n').filter((l) => l.trim() !== '');
|
|
399
|
+
let buf = '';
|
|
400
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
401
|
+
buf = `${lines[i]}\n${buf}`;
|
|
402
|
+
try {
|
|
403
|
+
const parsed = JSON.parse(buf);
|
|
404
|
+
if (typeof parsed.token === 'string' && parsed.token)
|
|
405
|
+
return { token: parsed.token, sessionId: parsed.sessionId };
|
|
406
|
+
}
|
|
407
|
+
catch { /* keep accumulating */ }
|
|
408
|
+
}
|
|
409
|
+
throw new Error(`no token in output from ${target}`);
|
|
410
|
+
};
|
|
411
|
+
// --t3-version wins; else ask npm locally for the exact latest, nightly only as
|
|
412
|
+
// a fallback. An exact version is required by the boot service's pinned runtime.
|
|
413
|
+
const resolveT3Version = (flags) => {
|
|
414
|
+
if (flags['t3-version'])
|
|
415
|
+
return flags['t3-version'];
|
|
416
|
+
if (spawnSync('npm', ['--version'], { stdio: 'ignore' }).status !== 0) {
|
|
417
|
+
throw new Error('npm not found locally — install npm or pass --t3-version <version-or-tag>');
|
|
418
|
+
}
|
|
419
|
+
const res = spawnSync('npm', ['view', `${T3_PACKAGE}@latest`, 'version'], { encoding: 'utf8' });
|
|
420
|
+
if (res.status === 0 && res.stdout.trim())
|
|
421
|
+
return res.stdout.trim();
|
|
422
|
+
const nightly = spawnSync('npm', ['view', `${T3_PACKAGE}@nightly`, 'version'], { encoding: 'utf8' });
|
|
423
|
+
if (nightly.status === 0 && nightly.stdout.trim())
|
|
424
|
+
return nightly.stdout.trim();
|
|
425
|
+
throw new Error(`cannot resolve a t3 CLI version via npm — pass --t3-version (tried: ${res.stderr?.trim().split('\n')[0] ?? 'npm failed'})`);
|
|
426
|
+
};
|
|
427
|
+
const freePort = async () =>
|
|
428
|
+
// TOCTOU between this probe and ssh binding the port is real; ExitOnForwardFailure
|
|
429
|
+
// turns the loss into a nonzero ssh exit, which spawnMaster retries on.
|
|
430
|
+
new Promise((resolve, reject) => {
|
|
431
|
+
const s = net.createServer();
|
|
432
|
+
s.listen(0, '127.0.0.1', () => {
|
|
433
|
+
const p = s.address();
|
|
434
|
+
s.close(() => resolve(p && typeof p === 'object' ? p.port : 0));
|
|
435
|
+
});
|
|
436
|
+
s.on('error', reject);
|
|
437
|
+
});
|
|
438
|
+
const ctlPath = (target, port) => path.join(CONFIG_DIR, `ssh-${createHash('sha256').update(`${target}:${port ?? ''}`).digest('hex').slice(0, 12)}`);
|
|
439
|
+
// ssh -fN + ControlPersist=yes keeps the master alive after t3ctl exits
|
|
440
|
+
// (ssh_config(5)); -f with ExitOnForwardFailure=yes means a clean return from
|
|
441
|
+
// spawnSync implies the local listener exists. stderr stays visible so
|
|
442
|
+
// passphrase prompts (ssh opens /dev/tty itself) and bind errors surface.
|
|
443
|
+
const spawnMaster = async (ssh, sshRemotePort) => {
|
|
444
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
445
|
+
const port = await freePort();
|
|
446
|
+
const res = spawnSync('ssh', [
|
|
447
|
+
'-M', '-S', ctlPath(ssh, port), '-fN',
|
|
448
|
+
'-o', 'ConnectTimeout=15', '-o', 'ControlPersist=yes',
|
|
449
|
+
'-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
|
|
450
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
451
|
+
'-L', `${port}:127.0.0.1:${sshRemotePort}`, ssh,
|
|
452
|
+
], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
453
|
+
if (res.status === 0)
|
|
454
|
+
return port;
|
|
455
|
+
if (res.error?.code === 'ENOENT')
|
|
456
|
+
throw new Error('ssh not found locally');
|
|
457
|
+
}
|
|
458
|
+
throw new Error(`could not establish the ssh tunnel to ${ssh} (3 attempts)`);
|
|
459
|
+
};
|
|
460
|
+
const waitForOrigin = async (origin, deadline) => {
|
|
461
|
+
while (Date.now() < deadline) {
|
|
462
|
+
if (await probe(origin, PROBE_MS).then(() => true, () => false))
|
|
463
|
+
return true;
|
|
464
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
};
|
|
468
|
+
// Single-flight per host name: cmdLs fans out concurrently and every write
|
|
469
|
+
// command re-enters here.
|
|
470
|
+
const tunnelJobs = new Map();
|
|
471
|
+
const ensureTunnel = (host) => {
|
|
472
|
+
if (!host.ssh)
|
|
473
|
+
return Promise.resolve();
|
|
474
|
+
const inFlight = tunnelJobs.get(host.name);
|
|
475
|
+
if (inFlight)
|
|
476
|
+
return inFlight;
|
|
477
|
+
const job = (async () => {
|
|
478
|
+
const target = host.ssh; // captured: TS cannot narrow the optional inside the closure
|
|
479
|
+
if (!target)
|
|
480
|
+
return;
|
|
481
|
+
// Liveness is the HTTP probe, never `ssh -O check` — a live master does not
|
|
482
|
+
// prove the forward works, and the remote port can change on server restart.
|
|
483
|
+
if (await probe(host.origin, PROBE_MS).then(() => true, () => false))
|
|
484
|
+
return;
|
|
485
|
+
spawnSync('ssh', ['-O', 'exit', '-S', ctlPath(target, host.sshLocalPort), target], { stdio: 'ignore' });
|
|
486
|
+
const detect = sshDetect(target);
|
|
487
|
+
if (!detect.running)
|
|
488
|
+
throw new Error(`${target}: no T3 Code server is running — rerun: t3ctl host add ${target}`);
|
|
489
|
+
// Re-pick the local port if something else grabbed it; the origin changes
|
|
490
|
+
// with it, so persist both.
|
|
491
|
+
const localPort = await spawnMaster(target, detect.port ?? 3773);
|
|
492
|
+
const origin = `http://127.0.0.1:${localPort}`;
|
|
493
|
+
writeHosts(readHosts().map((h) => h.name === host.name ? { ...h, sshLocalPort: localPort, sshRemotePort: detect.port ?? 3773, origin } : h));
|
|
494
|
+
if (!(await waitForOrigin(origin, Date.now() + TUNNEL_WAIT_MS))) {
|
|
495
|
+
throw new Error(`${host.ssh}: the ssh tunnel is up but ${origin} is not answering (is the server still running on the remote port?)`);
|
|
496
|
+
}
|
|
497
|
+
})().finally(() => tunnelJobs.delete(host.name));
|
|
498
|
+
tunnelJobs.set(host.name, job);
|
|
499
|
+
return job;
|
|
500
|
+
};
|
|
501
|
+
// `t3ctl host add agent@box` — the full bootstrap. Idempotent: rerunning
|
|
502
|
+
// refreshes the tunnel and keeps the stored token unless the machine behind
|
|
503
|
+
// the login changed (different environmentId).
|
|
504
|
+
const cmdHostAddSsh = async (target, flags, hosts) => {
|
|
505
|
+
const version = resolveT3Version(flags);
|
|
506
|
+
console.log(`probing ${bold(target)} over ssh`);
|
|
507
|
+
let detect = sshDetect(target);
|
|
508
|
+
if (detect.node === false) {
|
|
509
|
+
throw new Error(`${target} has no node on PATH for non-interactive ssh — install Node 22+ there (a version manager may need configuring for non-login shells)`);
|
|
510
|
+
}
|
|
511
|
+
if (!detect.running) {
|
|
512
|
+
console.log(`no T3 Code server on ${target} — installing the t3 boot service (t3@${version})`);
|
|
513
|
+
console.log(dim(` this downloads packages on ${target} and can take a few minutes; its output follows`));
|
|
514
|
+
// No timeout: t3code itself allows 10 minutes for the pinned-runtime npm
|
|
515
|
+
// install, and the user watches the output stream by.
|
|
516
|
+
const res = sshRun(target, ['install', version], { streamStderr: true });
|
|
517
|
+
if (res.status !== 0)
|
|
518
|
+
throw new Error(`t3 service install failed on ${target} (exit ${res.status})`);
|
|
519
|
+
const deadline = Date.now() + SSH_READY_MS;
|
|
520
|
+
let ready = null;
|
|
521
|
+
while (Date.now() < deadline) {
|
|
522
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
523
|
+
try {
|
|
524
|
+
ready = await sshDetect(target);
|
|
525
|
+
if (ready.running)
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
catch { /* transient ssh hiccups while the service boots */ }
|
|
529
|
+
}
|
|
530
|
+
if (!ready?.running) {
|
|
531
|
+
// Nothing is written to the registry — the origin is not proven yet
|
|
532
|
+
// (probe-first discipline). A tunnel master is only spawned after the
|
|
533
|
+
// probe below, so there is nothing to clean up here either.
|
|
534
|
+
usage(`the boot service was installed on ${target} but no server became ready within ${SSH_READY_MS / 1000}s.\n` +
|
|
535
|
+
` Check its service log on ${target}: ~/.t3/userdata/logs/boot-service.log\n` +
|
|
536
|
+
` On a Mac this usually means nobody is logged in at that console — a launchd\n` +
|
|
537
|
+
` agent starts at login, and an ssh install alone cannot start it.\n` +
|
|
538
|
+
` Rerunning t3ctl host add ${target} is safe once the server is up.`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
detect = ready;
|
|
542
|
+
}
|
|
543
|
+
// The stored local port may be taken by now; spawnMaster retries with fresh
|
|
544
|
+
// ports and returns whichever won.
|
|
545
|
+
const existing = hosts.find((h) => h.ssh === target);
|
|
546
|
+
const localPort = await spawnMaster(target, detect.port ?? 3773);
|
|
547
|
+
const origin = `http://127.0.0.1:${localPort}`;
|
|
548
|
+
try {
|
|
549
|
+
if (!(await waitForOrigin(origin, Date.now() + TUNNEL_WAIT_MS))) {
|
|
550
|
+
throw new Error(`the tunnel is up but ${origin} is not answering — is the server listening on 127.0.0.1:${detect.port ?? 3773} on ${target}?`);
|
|
551
|
+
}
|
|
552
|
+
const descriptor = await probe(origin);
|
|
553
|
+
// A changed environmentId on a known login means it now lands on a
|
|
554
|
+
// different machine — same warning as a changed origin, same consequence.
|
|
555
|
+
if (existing?.environmentId && existing.environmentId !== descriptor.environmentId) {
|
|
556
|
+
warn(`${target} is now a DIFFERENT environment\n` +
|
|
557
|
+
` was ${existing.environmentId} (${existing.label ?? 'unknown'})\n` +
|
|
558
|
+
` now ${descriptor.environmentId} (${descriptor.label})\n` +
|
|
559
|
+
` the token stored for "${existing.name}" was issued by the old one and will be replaced`);
|
|
560
|
+
}
|
|
561
|
+
const name = flags.name ?? existing?.name ?? uniqueName(slugify(descriptor.label), hosts);
|
|
562
|
+
let token = existing?.token ?? null;
|
|
563
|
+
let session = null;
|
|
564
|
+
if (!token || (existing?.environmentId && existing.environmentId !== descriptor.environmentId)) {
|
|
565
|
+
const issued = sshToken(target, version, `t3ctl:${name}`, flags.ttl ?? '30d');
|
|
566
|
+
token = issued.token;
|
|
567
|
+
session = issued;
|
|
568
|
+
}
|
|
569
|
+
const others = hosts.filter((h) => h.name !== name && h.origin !== origin && h.serverVersion);
|
|
570
|
+
const skewed = [...new Set(others.flatMap((h) => (h.serverVersion ? [h.serverVersion] : [])))]
|
|
571
|
+
.filter((v) => v !== descriptor.serverVersion);
|
|
572
|
+
if (skewed.length)
|
|
573
|
+
warn(`serverVersion ${descriptor.serverVersion} differs from other hosts: ${skewed.join(', ')}`);
|
|
574
|
+
// A master on a superseded local port would linger forever otherwise.
|
|
575
|
+
if (existing?.sshLocalPort && existing.sshLocalPort !== localPort) {
|
|
576
|
+
spawnSync('ssh', ['-O', 'exit', '-S', ctlPath(target, existing.sshLocalPort), target], { stdio: 'ignore' });
|
|
577
|
+
}
|
|
578
|
+
writeHosts(hosts.filter((h) => h.name !== name && h.ssh !== target).concat({
|
|
579
|
+
name, origin, token,
|
|
580
|
+
environmentId: descriptor.environmentId,
|
|
581
|
+
label: descriptor.label,
|
|
582
|
+
serverVersion: descriptor.serverVersion,
|
|
583
|
+
ssh: target, sshLocalPort: localPort, sshRemotePort: detect.port ?? 3773, t3Version: version,
|
|
584
|
+
}));
|
|
585
|
+
console.log(`added ${bold(name)} -> ${origin} (via ssh)\n label ${descriptor.label}\n` +
|
|
586
|
+
` env ${descriptor.environmentId}\n version ${descriptor.serverVersion}\n` +
|
|
587
|
+
` tunnel 127.0.0.1:${localPort} -> 127.0.0.1:${detect.port ?? 3773} on ${target}`);
|
|
588
|
+
if (session) {
|
|
589
|
+
console.log(` session ${session.sessionId ?? '?'} (label t3ctl:${name})\n` +
|
|
590
|
+
` revoke on ${target} with: npx t3 auth session revoke ${session.sessionId ?? '?'}`);
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
console.log(' token reusing the stored session');
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
// Keep no half-registered host: the master would outlive t3ctl otherwise.
|
|
598
|
+
spawnSync('ssh', ['-O', 'exit', '-S', ctlPath(target, localPort), target], { stdio: 'ignore' });
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
// ---- writes -------------------------------------------------------------
|
|
603
|
+
// Commands are dispatched directly (no envelope). The CLIENT mints every id;
|
|
604
|
+
// commandId is the idempotency key, so retries are safe.
|
|
605
|
+
// Schemas: packages/contracts/src/orchestration.ts in pingdotgg/t3code.
|
|
606
|
+
const pickHost = (flags) => {
|
|
607
|
+
const hosts = readHosts();
|
|
608
|
+
if (flags.host) {
|
|
609
|
+
const h = hosts.find((x) => x.name === flags.host);
|
|
610
|
+
if (!h)
|
|
611
|
+
throw new Error(`no such host: ${flags.host}`);
|
|
612
|
+
return h;
|
|
613
|
+
}
|
|
614
|
+
if (!hosts.length)
|
|
615
|
+
throw new Error('no hosts registered — run: t3ctl host add <origin> <token>');
|
|
616
|
+
if (hosts.length > 1)
|
|
617
|
+
throw new Error(`multiple hosts; pass --host <${hosts.map((h) => h.name).join('|')}>`);
|
|
618
|
+
const only = hosts[0];
|
|
619
|
+
if (!only)
|
|
620
|
+
throw new Error('no hosts registered — run: t3ctl host add <origin> <token>');
|
|
621
|
+
return only;
|
|
622
|
+
};
|
|
623
|
+
const dispatch = async (host, command) => {
|
|
624
|
+
await ensureTunnel(host);
|
|
625
|
+
const res = await fetch(`${host.origin}/api/orchestration/dispatch`, {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: { ...(host.token ? { authorization: `Bearer ${host.token}` } : {}), 'content-type': 'application/json' },
|
|
628
|
+
body: JSON.stringify(command),
|
|
629
|
+
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
630
|
+
});
|
|
631
|
+
const body = await res.text();
|
|
632
|
+
if (!res.ok)
|
|
633
|
+
throw new Error(`${command.type} failed: HTTP ${res.status} ${body}`);
|
|
634
|
+
return body ? JSON.parse(body) : { sequence: 0 };
|
|
635
|
+
};
|
|
636
|
+
const resolveProject = (snap, ref) => snap.projects.find((p) => p.id === ref) ??
|
|
637
|
+
snap.projects.find((p) => !p.deletedAt && p.title === ref) ??
|
|
638
|
+
snap.projects.find((p) => !p.deletedAt && p.workspaceRoot === path.resolve(ref.replace(/^~/, os.homedir())));
|
|
639
|
+
// Commands whose entire payload is {commandId, threadId}. Verified against
|
|
640
|
+
// packages/contracts/src/orchestration.ts — note `unsettle` is NOT one of
|
|
641
|
+
// these (it carries extra fields), so it is deliberately absent.
|
|
642
|
+
const SIMPLE_THREAD_COMMANDS = ['settle', 'archive', 'unarchive', 'unpin', 'delete'];
|
|
643
|
+
const resolveThread = (snap, ref) => {
|
|
644
|
+
const live = snap.threads.filter((t) => !t.deletedAt);
|
|
645
|
+
const byId = live.find((t) => t.id === ref);
|
|
646
|
+
if (byId)
|
|
647
|
+
return byId;
|
|
648
|
+
const exact = live.filter((t) => t.title === ref);
|
|
649
|
+
if (exact[0] && exact.length === 1)
|
|
650
|
+
return exact[0];
|
|
651
|
+
const fuzzy = live.filter((t) => (t.title ?? '').toLowerCase().includes(ref.toLowerCase()));
|
|
652
|
+
if (fuzzy[0] && fuzzy.length === 1)
|
|
653
|
+
return fuzzy[0];
|
|
654
|
+
if (fuzzy.length > 1) {
|
|
655
|
+
throw new Error(`"${ref}" matches ${fuzzy.length} threads:\n` +
|
|
656
|
+
fuzzy.slice(0, 8).map((t) => ` ${t.id} ${t.title}`).join('\n'));
|
|
657
|
+
}
|
|
658
|
+
throw new Error(`no thread matching "${ref}"`);
|
|
659
|
+
};
|
|
660
|
+
// thread.turn.start is ONE command carrying the first message inline — this is
|
|
661
|
+
// what the UI fires immediately after thread.create, which is why a thread with
|
|
662
|
+
// no messages is a state the UI never produces. The client-side schema requires
|
|
663
|
+
// runtimeMode/interactionMode explicitly (the server-side one defaults them).
|
|
664
|
+
const cmdThreadStart = async (thread, host, text, flags) => {
|
|
665
|
+
const command = {
|
|
666
|
+
type: 'thread.turn.start',
|
|
667
|
+
commandId: crypto.randomUUID(),
|
|
668
|
+
threadId: thread.id,
|
|
669
|
+
message: { messageId: crypto.randomUUID(), role: 'user', text, attachments: [] },
|
|
670
|
+
runtimeMode: flags['runtime-mode'] ?? thread.runtimeMode ?? 'full-access',
|
|
671
|
+
interactionMode: flags['interaction-mode'] ?? 'default',
|
|
672
|
+
createdAt: new Date().toISOString(),
|
|
673
|
+
};
|
|
674
|
+
if (flags.model) {
|
|
675
|
+
const slash = flags.model.indexOf('/');
|
|
676
|
+
if (slash < 1)
|
|
677
|
+
throw new Error(`--model must be <instance>/<model>, got "${flags.model}"`);
|
|
678
|
+
command.modelSelection = { instanceId: flags.model.slice(0, slash), model: flags.model.slice(slash + 1) };
|
|
679
|
+
}
|
|
680
|
+
else if (thread.modelSelection) {
|
|
681
|
+
command.modelSelection = thread.modelSelection;
|
|
682
|
+
}
|
|
683
|
+
const { sequence } = await dispatch(host, command);
|
|
684
|
+
const m = command['modelSelection'];
|
|
685
|
+
console.log(`started ${bold(thread.title || thread.id)}\n id ${thread.id}` +
|
|
686
|
+
(m ? `\n model ${m.instanceId}/${m.model}` : '') +
|
|
687
|
+
`\n mode ${command.runtimeMode} / ${command.interactionMode}\n seq ${sequence}`);
|
|
688
|
+
};
|
|
689
|
+
const cmdThreadInterrupt = async (thread, host) => {
|
|
690
|
+
const { sequence } = await dispatch(host, {
|
|
691
|
+
type: 'thread.turn.interrupt', commandId: crypto.randomUUID(), threadId: thread.id,
|
|
692
|
+
});
|
|
693
|
+
console.log(`interrupted ${bold(thread.title || thread.id)}\n seq ${sequence}`);
|
|
694
|
+
};
|
|
695
|
+
// thread.meta.update also accepts regenerateTitle:true, which asks the server to
|
|
696
|
+
// derive a title from the thread's own content instead of taking one from us.
|
|
697
|
+
const cmdThreadRename = async (thread, host, title) => {
|
|
698
|
+
const { sequence } = await dispatch(host, {
|
|
699
|
+
type: 'thread.meta.update', commandId: crypto.randomUUID(), threadId: thread.id, title,
|
|
700
|
+
});
|
|
701
|
+
console.log(`renamed ${dim(thread.title || thread.id)} -> ${bold(title)}\n seq ${sequence}`);
|
|
702
|
+
};
|
|
703
|
+
// Lighter than the full snapshot; retitle polls this so it does not refetch every
|
|
704
|
+
// thread's history once a second.
|
|
705
|
+
// `turnLimit` is null for callers that want the whole history (export), 1 for
|
|
706
|
+
// callers that only want to poll a field (retitle).
|
|
707
|
+
const threadDetail = async (host, threadId, turnLimit = 1) => {
|
|
708
|
+
await ensureTunnel(host);
|
|
709
|
+
const query = turnLimit === null ? '' : `?turnLimit=${turnLimit}`;
|
|
710
|
+
const res = await fetch(`${host.origin}/api/orchestration/threads/${threadId}${query}`, {
|
|
711
|
+
headers: { authorization: `Bearer ${host.token}` },
|
|
712
|
+
signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
|
|
713
|
+
});
|
|
714
|
+
if (!res.ok)
|
|
715
|
+
throw new Error(`${host.name}: HTTP ${res.status}`);
|
|
716
|
+
return (await res.json()).thread;
|
|
717
|
+
};
|
|
718
|
+
// `regenerateTitle` does NOT rename anything by itself. The server records an
|
|
719
|
+
// intent marker (`titleRegeneration: {requestId, startedAt}`) and expects something
|
|
720
|
+
// downstream to generate a title and write it back. Observed on a live server: the
|
|
721
|
+
// marker is sometimes cleared a few seconds later with the title untouched, and no
|
|
722
|
+
// error is reported anywhere — not in the event, the command receipt, or the server
|
|
723
|
+
// trace. So we dispatch, then watch, and say what actually happened.
|
|
724
|
+
const cmdThreadRetitle = async (thread, host, timeoutSeconds) => {
|
|
725
|
+
const before = thread.title;
|
|
726
|
+
const { sequence } = await dispatch(host, {
|
|
727
|
+
type: 'thread.meta.update', commandId: crypto.randomUUID(), threadId: thread.id,
|
|
728
|
+
regenerateTitle: true,
|
|
729
|
+
});
|
|
730
|
+
console.log(`asked the server to retitle ${bold(before || thread.id)}${dim(` (seq ${sequence})`)}`);
|
|
731
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
732
|
+
let sawMarker = false;
|
|
733
|
+
while (Date.now() < deadline) {
|
|
734
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
735
|
+
const current = await threadDetail(host, thread.id);
|
|
736
|
+
if (current.title && current.title !== before) {
|
|
737
|
+
console.log(`retitled -> ${bold(current.title)}`);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (current.titleRegeneration) {
|
|
741
|
+
sawMarker = true;
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (sawMarker)
|
|
745
|
+
break; // marker appeared and was cleared, title unchanged
|
|
746
|
+
}
|
|
747
|
+
console.error(`\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` +
|
|
748
|
+
` The title is still "${before}". Set one directly:\n` +
|
|
749
|
+
` t3ctl thread rename ${thread.id} <title...>`);
|
|
750
|
+
process.exitCode = 1;
|
|
751
|
+
};
|
|
752
|
+
const cmdProjectCreate = async (title, root, flags) => {
|
|
753
|
+
const host = pickHost(flags);
|
|
754
|
+
const workspaceRoot = path.resolve(root.replace(/^~/, os.homedir()));
|
|
755
|
+
if (!fs.existsSync(workspaceRoot))
|
|
756
|
+
throw new Error(`workspace root does not exist: ${workspaceRoot}`);
|
|
757
|
+
const projectId = crypto.randomUUID();
|
|
758
|
+
const { sequence } = await dispatch(host, {
|
|
759
|
+
type: 'project.create', commandId: crypto.randomUUID(),
|
|
760
|
+
projectId, title, workspaceRoot, createdAt: new Date().toISOString(),
|
|
761
|
+
});
|
|
762
|
+
console.log(`created project ${bold(title)} on ${host.name}\n id ${projectId}\n root ${workspaceRoot}\n seq ${sequence}`);
|
|
763
|
+
};
|
|
764
|
+
const cmdThreadCreate = async (projectRef, title, flags) => {
|
|
765
|
+
const host = pickHost(flags);
|
|
766
|
+
const project = resolveProject(await snapshot(host), projectRef);
|
|
767
|
+
if (!project)
|
|
768
|
+
throw new Error(`no project matching "${projectRef}" on ${host.name}`);
|
|
769
|
+
// instanceId is the segment before the FIRST slash; the model keeps the rest,
|
|
770
|
+
// because opencode model ids are themselves slashed ("github-copilot/gpt-5.4").
|
|
771
|
+
const raw = flags.model ?? 'claudeAgent/claude-opus-5';
|
|
772
|
+
const slash = raw.indexOf('/');
|
|
773
|
+
if (slash < 1)
|
|
774
|
+
throw new Error(`--model must be <instance>/<model>, got "${raw}"`);
|
|
775
|
+
const modelSelection = { instanceId: raw.slice(0, slash), model: raw.slice(slash + 1) };
|
|
776
|
+
const threadId = crypto.randomUUID();
|
|
777
|
+
const { sequence } = await dispatch(host, {
|
|
778
|
+
type: 'thread.create', commandId: crypto.randomUUID(),
|
|
779
|
+
threadId, projectId: project.id, title, modelSelection,
|
|
780
|
+
runtimeMode: flags['runtime-mode'] ?? 'full-access',
|
|
781
|
+
interactionMode: flags['interaction-mode'] ?? 'default',
|
|
782
|
+
branch: flags.branch ?? null,
|
|
783
|
+
worktreePath: flags.worktree ?? null,
|
|
784
|
+
createdAt: new Date().toISOString(),
|
|
785
|
+
});
|
|
786
|
+
console.log(`created thread ${bold(title)} in ${project.title} on ${host.name}\n id ${threadId}\n model ${modelSelection.instanceId}/${modelSelection.model}\n seq ${sequence}`);
|
|
787
|
+
};
|
|
788
|
+
const cmdThreadSimple = async (verb, ref, flags) => {
|
|
789
|
+
const host = pickHost(flags);
|
|
790
|
+
const thread = resolveThread(await snapshot(host), ref);
|
|
791
|
+
const { sequence } = await dispatch(host, {
|
|
792
|
+
type: `thread.${verb}`, commandId: crypto.randomUUID(), threadId: thread.id,
|
|
793
|
+
});
|
|
794
|
+
console.log(`${verb}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
|
|
795
|
+
};
|
|
796
|
+
const STRATEGY_LABEL = {
|
|
797
|
+
sqlite: 'local state.sqlite',
|
|
798
|
+
http: 'snapshot + per-thread fetch',
|
|
799
|
+
};
|
|
800
|
+
/** Fixed by T3 Code. Read relative to whichever machine's home directory applies. */
|
|
801
|
+
const STATE_DB = '.t3/userdata/state.sqlite';
|
|
802
|
+
const expandHome = (p) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir()));
|
|
803
|
+
/**
|
|
804
|
+
* Where a prompt happened, named the way a human would: the workspace path made
|
|
805
|
+
* relative to the watched root it sits under. Longest matching root wins, so
|
|
806
|
+
* `~/Code` and `~/Code/@clients` both configured gives `afa`, not `@clients/afa`.
|
|
807
|
+
* A workspace under no watched root has no marker and the prompt is dropped.
|
|
808
|
+
*/
|
|
809
|
+
const markerForWorkspace = (workspaceRoot, watched) => {
|
|
810
|
+
const ws = workspaceRoot.replace(/\/+$/, '');
|
|
811
|
+
let best = null;
|
|
812
|
+
let bestLen = -1;
|
|
813
|
+
for (const root of watched) {
|
|
814
|
+
const r = root.replace(/\/+$/, '');
|
|
815
|
+
if (ws === r)
|
|
816
|
+
return ws.split('/').pop() ?? ws;
|
|
817
|
+
if (ws.startsWith(`${r}/`) && r.length > bestLen) {
|
|
818
|
+
best = ws.slice(r.length + 1);
|
|
819
|
+
bestLen = r.length;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return best;
|
|
823
|
+
};
|
|
824
|
+
/**
|
|
825
|
+
* Prompts arrive with the harness's wrapping still on them. Mirrors
|
|
826
|
+
* `cleanPrompt` in spr-time-entrier, which is the consumer this shape is for:
|
|
827
|
+
* unwrap `<user_query>` when present, then collapse whitespace to one line.
|
|
828
|
+
*/
|
|
829
|
+
const cleanPrompt = (text) => {
|
|
830
|
+
const tagged = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
|
|
831
|
+
return (tagged?.[1] ?? text).replace(/\s+/g, ' ').trim();
|
|
832
|
+
};
|
|
833
|
+
// One statement, shared by every strategy that reads the local store directly,
|
|
834
|
+
// so it cannot drift from what the HTTP path reconstructs.
|
|
835
|
+
const PROMPT_SQL = `SELECT m.message_id, m.thread_id, m.text, m.created_at, p.workspace_root
|
|
836
|
+
FROM projection_thread_messages m
|
|
837
|
+
JOIN projection_threads t ON t.thread_id = m.thread_id
|
|
838
|
+
JOIN projection_projects p ON p.project_id = t.project_id
|
|
839
|
+
WHERE m.role = 'user'
|
|
840
|
+
AND t.deleted_at IS NULL
|
|
841
|
+
AND p.deleted_at IS NULL
|
|
842
|
+
AND m.created_at >= ? AND m.created_at < ?
|
|
843
|
+
ORDER BY m.created_at`;
|
|
844
|
+
/**
|
|
845
|
+
* A bare `--since 2026-09-14` is the UTC day boundary, not local midnight: the
|
|
846
|
+
* rows being filtered are UTC, and an export should describe the same window
|
|
847
|
+
* whichever machine runs it. Anything else is handed to `Date` as written.
|
|
848
|
+
*/
|
|
849
|
+
const isoBound = (value, flag) => {
|
|
850
|
+
const raw = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value;
|
|
851
|
+
const at = new Date(raw);
|
|
852
|
+
if (Number.isNaN(at.getTime()))
|
|
853
|
+
throw new Error(`${flag}: not a date I can read: ${value}`);
|
|
854
|
+
return at.toISOString();
|
|
855
|
+
};
|
|
856
|
+
/**
|
|
857
|
+
* node:sqlite arrived in Node 22.5 behind `--experimental-sqlite`. Current
|
|
858
|
+
* releases have it unflagged (verified on 22.23 and 24), but `engines` allows
|
|
859
|
+
* `>=22`, which still admits the flagged ones. Importing it lazily means only
|
|
860
|
+
* this command fails there, instead of the whole CLI failing to start.
|
|
861
|
+
*/
|
|
862
|
+
const loadSqlite = async () => {
|
|
863
|
+
try {
|
|
864
|
+
return (await import('node:sqlite')).DatabaseSync;
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
throw new Error('reading state.sqlite needs node:sqlite — upgrade to Node >= 22.13, or pass --experimental-sqlite on 22.5-22.12');
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
const queryLocal = async (since, until) => {
|
|
871
|
+
const file = path.join(os.homedir(), STATE_DB);
|
|
872
|
+
if (!fs.existsSync(file))
|
|
873
|
+
throw new Error(`no T3 Code database at ${file}`);
|
|
874
|
+
const DatabaseSync = await loadSqlite();
|
|
875
|
+
// The database is in WAL mode, so this reader neither blocks the running app
|
|
876
|
+
// nor is blocked by it, and no copy is needed — it is ~500 MB.
|
|
877
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
878
|
+
try {
|
|
879
|
+
return db.prepare(PROMPT_SQL).all(since, until);
|
|
880
|
+
}
|
|
881
|
+
finally {
|
|
882
|
+
db.close();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
/** `Date.parse` returns NaN for junk and for null; both mean "cannot compare". */
|
|
886
|
+
const instant = (value) => {
|
|
887
|
+
const t = value ? Date.parse(value) : Number.NaN;
|
|
888
|
+
return Number.isNaN(t) ? null : t;
|
|
889
|
+
};
|
|
890
|
+
/**
|
|
891
|
+
* Which threads are worth a request. `updatedAt` moves with every new message,
|
|
892
|
+
* so a thread untouched since the window opened cannot hold one, and a thread
|
|
893
|
+
* created after it closed cannot either. Anything unknown is fetched.
|
|
894
|
+
*/
|
|
895
|
+
const mayHavePrompts = (t, since, until) => {
|
|
896
|
+
if (t.deletedAt)
|
|
897
|
+
return false;
|
|
898
|
+
const updated = instant(t.updatedAt);
|
|
899
|
+
if (updated !== null && updated < since)
|
|
900
|
+
return false;
|
|
901
|
+
const created = instant(t.createdAt);
|
|
902
|
+
if (created !== null && created >= until)
|
|
903
|
+
return false;
|
|
904
|
+
return true;
|
|
905
|
+
};
|
|
906
|
+
/** Bounded fan-out: a host with 200 live threads should not get 200 sockets at once. */
|
|
907
|
+
const mapPool = async (items, limit, fn) => {
|
|
908
|
+
const out = new Array(items.length);
|
|
909
|
+
// Every worker pulls from the same iterator, so each entry goes to exactly one
|
|
910
|
+
// of them and the last to finish is the last item, not the last worker.
|
|
911
|
+
const queue = items.entries();
|
|
912
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
913
|
+
for (const [i, item] of queue)
|
|
914
|
+
out[i] = await fn(item);
|
|
915
|
+
}));
|
|
916
|
+
return out;
|
|
917
|
+
};
|
|
918
|
+
const queryOverHttp = async (host, since, until) => {
|
|
919
|
+
const snap = await snapshot(host);
|
|
920
|
+
const roots = new Map(snap.projects.filter((p) => !p.deletedAt).map((p) => [p.id, p.workspaceRoot]));
|
|
921
|
+
const from = Date.parse(since);
|
|
922
|
+
const to = Date.parse(until);
|
|
923
|
+
const threads = snap.threads.filter((t) => roots.has(t.projectId) && mayHavePrompts(t, from, to));
|
|
924
|
+
const perThread = await mapPool(threads, 8, async (t) => {
|
|
925
|
+
const workspaceRoot = roots.get(t.projectId);
|
|
926
|
+
if (workspaceRoot === undefined)
|
|
927
|
+
return [];
|
|
928
|
+
const detail = await threadDetail(host, t.id, null);
|
|
929
|
+
const rows = [];
|
|
930
|
+
for (const m of detail.messages ?? []) {
|
|
931
|
+
if (m.role !== 'user')
|
|
932
|
+
continue;
|
|
933
|
+
const at = instant(m.createdAt);
|
|
934
|
+
if (at === null || at < from || at >= to)
|
|
935
|
+
continue;
|
|
936
|
+
const messageId = m.messageId ?? m.id;
|
|
937
|
+
if (messageId === undefined)
|
|
938
|
+
continue;
|
|
939
|
+
rows.push({
|
|
940
|
+
message_id: messageId,
|
|
941
|
+
thread_id: t.id,
|
|
942
|
+
text: m.text ?? '',
|
|
943
|
+
created_at: new Date(at).toISOString(),
|
|
944
|
+
workspace_root: workspaceRoot,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return rows;
|
|
948
|
+
});
|
|
949
|
+
return perThread.flat();
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* Loopback means the database this process can already open is the very one the
|
|
953
|
+
* host serves, so read it and skip the network entirely. Everything else goes
|
|
954
|
+
* over HTTP.
|
|
955
|
+
*/
|
|
956
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
957
|
+
const strategyFor = (host) => {
|
|
958
|
+
let hostname = '';
|
|
959
|
+
try {
|
|
960
|
+
hostname = new URL(host.origin).hostname;
|
|
961
|
+
}
|
|
962
|
+
catch { /* an unparseable origin is not loopback; let the HTTP path report it */ }
|
|
963
|
+
return LOOPBACK.has(hostname) ? 'sqlite' : 'http';
|
|
964
|
+
};
|
|
965
|
+
const readPrompts = (host, strategy, since, until) => {
|
|
966
|
+
if (strategy === 'sqlite')
|
|
967
|
+
return queryLocal(since, until);
|
|
968
|
+
return queryOverHttp(host, since, until);
|
|
969
|
+
};
|
|
970
|
+
const cmdExportPrompts = async (o) => {
|
|
971
|
+
const since = isoBound(o.since, '--since');
|
|
972
|
+
const until = o.until ? isoBound(o.until, '--until') : new Date().toISOString();
|
|
973
|
+
if (until <= since)
|
|
974
|
+
throw new Error(`--until (${until}) must be after --since (${since})`);
|
|
975
|
+
const registered = readHosts();
|
|
976
|
+
if (!registered.length)
|
|
977
|
+
throw new Error('no hosts registered — run: t3ctl host add <origin> <token>');
|
|
978
|
+
const hosts = o.host ? registered.filter((h) => h.name === o.host) : registered;
|
|
979
|
+
if (!hosts.length)
|
|
980
|
+
throw new Error(`no such host: ${o.host}`);
|
|
981
|
+
const watched = (o.watch?.length ? o.watch : ['~/Code']).map(expandHome);
|
|
982
|
+
const settled = await Promise.allSettled(hosts.map(async (h) => {
|
|
983
|
+
const strategy = strategyFor(h);
|
|
984
|
+
return { strategy, rows: await readPrompts(h, strategy, since, until) };
|
|
985
|
+
}));
|
|
986
|
+
const messages = [];
|
|
987
|
+
const unreachable = [];
|
|
988
|
+
const reached = [];
|
|
989
|
+
settled.forEach((result, i) => {
|
|
990
|
+
const host = hosts[i];
|
|
991
|
+
if (!host)
|
|
992
|
+
return;
|
|
993
|
+
if (result.status === 'rejected') {
|
|
994
|
+
unreachable.push({ host: host.name, error: errorMessage(result.reason) });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const { strategy, rows } = result.value;
|
|
998
|
+
let count = 0;
|
|
999
|
+
for (const row of rows) {
|
|
1000
|
+
const marker = markerForWorkspace(row.workspace_root, watched);
|
|
1001
|
+
if (marker === null)
|
|
1002
|
+
continue; // outside every watched root
|
|
1003
|
+
messages.push({
|
|
1004
|
+
host: host.name,
|
|
1005
|
+
threadId: row.thread_id,
|
|
1006
|
+
messageId: row.message_id,
|
|
1007
|
+
createdAt: row.created_at,
|
|
1008
|
+
text: cleanPrompt(row.text),
|
|
1009
|
+
workspaceRoot: row.workspace_root,
|
|
1010
|
+
// Markers have to stay unique across machines, and two hosts routinely
|
|
1011
|
+
// hold a checkout of the same repo at the same path. The local host is
|
|
1012
|
+
// the unprefixed one so single-machine consumers see plain names.
|
|
1013
|
+
marker: strategy === 'sqlite' ? marker : `${host.name}/${marker}`,
|
|
1014
|
+
});
|
|
1015
|
+
count++;
|
|
1016
|
+
}
|
|
1017
|
+
reached.push({ host: host.name, strategy, count });
|
|
1018
|
+
});
|
|
1019
|
+
messages.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.messageId.localeCompare(b.messageId));
|
|
1020
|
+
if (o.json) {
|
|
1021
|
+
console.log(JSON.stringify({ messages, unreachable }, null, 2));
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
const n = messages.length;
|
|
1025
|
+
console.log(`${bold(String(n))} prompt${n === 1 ? '' : 's'} ${dim(`${since} -> ${until}`)}`);
|
|
1026
|
+
for (const { host, strategy, count } of reached) {
|
|
1027
|
+
console.log(`\n${bold(host)} ${count} ${dim(STRATEGY_LABEL[strategy])}`);
|
|
1028
|
+
const tally = new Map();
|
|
1029
|
+
for (const m of messages) {
|
|
1030
|
+
if (m.host === host)
|
|
1031
|
+
tally.set(m.marker, (tally.get(m.marker) ?? 0) + 1);
|
|
1032
|
+
}
|
|
1033
|
+
const width = Math.max(0, ...[...tally.keys()].map((k) => k.length));
|
|
1034
|
+
for (const [marker, hits] of [...tally].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) {
|
|
1035
|
+
console.log(` ${marker.padEnd(width)} ${hits}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
for (const u of unreachable)
|
|
1040
|
+
console.error(`${ICON.error} ${u.host} ${dim(u.error)}`);
|
|
1041
|
+
// Partial results are still useful and `unreachable` reports what is missing,
|
|
1042
|
+
// so only a total failure is an error.
|
|
1043
|
+
if (unreachable.length === hosts.length)
|
|
1044
|
+
process.exitCode = 1;
|
|
1045
|
+
};
|
|
1046
|
+
// ---- cli ----------------------------------------------------------------
|
|
1047
|
+
// commander's option names arrive camelCased; the command implementations were
|
|
1048
|
+
// written against the kebab-case spellings, so translate once here rather than
|
|
1049
|
+
// touching every call site.
|
|
1050
|
+
const FLAG_NAMES = {
|
|
1051
|
+
host: 'host', model: 'model', branch: 'branch', worktree: 'worktree',
|
|
1052
|
+
name: 'name', timeout: 'timeout',
|
|
1053
|
+
runtimeMode: 'runtime-mode', interactionMode: 'interaction-mode',
|
|
1054
|
+
};
|
|
1055
|
+
// Only carry options that were actually supplied. Emitting every key
|
|
1056
|
+
// unconditionally made `'name' in flags` always true, which made host add bail
|
|
1057
|
+
// on every invocation.
|
|
1058
|
+
const toFlags = (o) => Object.fromEntries(Object.entries(FLAG_NAMES)
|
|
1059
|
+
.filter(([from]) => o[from] !== undefined)
|
|
1060
|
+
.map(([from, to]) => [to, o[from]]));
|
|
1061
|
+
const resolve = async (ref, o) => {
|
|
1062
|
+
const host = pickHost(toFlags(o));
|
|
1063
|
+
return { host, thread: resolveThread(await snapshot(host), ref) };
|
|
1064
|
+
};
|
|
1065
|
+
// Compiled to dist/t3ctl.js, so package.json is one level up — true in the repo
|
|
1066
|
+
// and in the published tarball, which ships dist/ alongside package.json.
|
|
1067
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
1068
|
+
const program = new Command();
|
|
1069
|
+
program
|
|
1070
|
+
.name('t3ctl')
|
|
1071
|
+
.description('Control T3 Code hosts — list and drive coding-agent threads across every machine you run T3 Code on.')
|
|
1072
|
+
.version(pkg.version, '-v, --version', 'print the t3ctl version')
|
|
1073
|
+
.showHelpAfterError('(run `t3ctl --help` or `t3ctl <command> --help`)')
|
|
1074
|
+
.configureHelp({ showGlobalOptions: true });
|
|
1075
|
+
const hostOption = (cmd) => cmd.option('--host <name>', 'which registered host to talk to (required when several are registered)');
|
|
1076
|
+
program.command('ls')
|
|
1077
|
+
.description('list projects and threads across all registered hosts')
|
|
1078
|
+
.option('-t, --threads', 'expand the threads under each project')
|
|
1079
|
+
.option('-a, --all', 'include archived and deleted items')
|
|
1080
|
+
.option('--json', 'emit JSON instead of a table')
|
|
1081
|
+
.action((o) => cmdLs(o));
|
|
1082
|
+
const host = program.command('host').description('manage the host registry');
|
|
1083
|
+
host.command('add')
|
|
1084
|
+
.argument('<origin>', 'base URL of the host, e.g. https://box.tailnet.ts.net:3773 — or an ssh login like agent@box to bootstrap that machine end to end')
|
|
1085
|
+
.argument('[token]', 'bearer token from `t3 auth session issue` on that host (origin form only)')
|
|
1086
|
+
.description('register a host — from a URL, or from its ssh login alone')
|
|
1087
|
+
.option('--name <name>', 'override the label detected from the host')
|
|
1088
|
+
.option('--ttl <duration>', 'session lifetime for the ssh form, e.g. 30d (default 30d)', '30d')
|
|
1089
|
+
.option('--t3-version <version>', 'exact t3 CLI version to install remotely (default: npm-resolved latest, nightly as fallback)')
|
|
1090
|
+
.action(async (origin, token, o) => {
|
|
1091
|
+
const flags = toFlags(o);
|
|
1092
|
+
const hosts = readHosts();
|
|
1093
|
+
// Two shapes, told apart by scheme: an http(s) origin pairs with an existing
|
|
1094
|
+
// server; anything else is an ssh login that bootstraps one.
|
|
1095
|
+
if (isOrigin(origin)) {
|
|
1096
|
+
return cmdHostAdd([origin, ...(token ? [token] : [])], flags, hosts);
|
|
1097
|
+
}
|
|
1098
|
+
// An origin missing its scheme is still the old, deliberate rejection — not
|
|
1099
|
+
// guessed at, and not misread as an ssh target (ssh targets never carry a
|
|
1100
|
+
// colon; ports belong to origins).
|
|
1101
|
+
if (origin.includes(':')) {
|
|
1102
|
+
return usage('an origin needs a scheme, e.g. http://localhost:3773');
|
|
1103
|
+
}
|
|
1104
|
+
if (token) {
|
|
1105
|
+
throw new Error('a token argument only makes sense with an <origin>; the ssh form mints its own');
|
|
1106
|
+
}
|
|
1107
|
+
return cmdHostAddSsh(origin, flags, hosts);
|
|
1108
|
+
});
|
|
1109
|
+
host.command('rm')
|
|
1110
|
+
.argument('<name>', 'registered host name')
|
|
1111
|
+
.description('remove a host from the registry')
|
|
1112
|
+
.action((name) => {
|
|
1113
|
+
const hosts = readHosts();
|
|
1114
|
+
const gone = hosts.find((h) => h.name === name);
|
|
1115
|
+
if (!gone)
|
|
1116
|
+
throw new Error(`no such host: ${name}`);
|
|
1117
|
+
// Taking the ssh master down with the entry; a missing socket just exits 255.
|
|
1118
|
+
if (gone.ssh) {
|
|
1119
|
+
spawnSync('ssh', ['-O', 'exit', '-S', ctlPath(gone.ssh, gone.sshLocalPort), gone.ssh], { stdio: 'ignore' });
|
|
1120
|
+
}
|
|
1121
|
+
writeHosts(hosts.filter((h) => h.name !== name));
|
|
1122
|
+
console.log(`removed ${name}`);
|
|
1123
|
+
});
|
|
1124
|
+
host.command('ls').description('list registered hosts and probe each one')
|
|
1125
|
+
.action(() => cmdHostsList(readHosts()));
|
|
1126
|
+
program.command('hosts').description('list registered hosts and probe each one (alias of `host ls`)')
|
|
1127
|
+
.action(() => cmdHostsList(readHosts()));
|
|
1128
|
+
// Repeatable, because a workspace tree is not always one root. No default value
|
|
1129
|
+
// is handed to commander: it would print `(default: [])` in the help, which is
|
|
1130
|
+
// not what happens when the flag is omitted.
|
|
1131
|
+
const addWatch = (value, previous) => [...(previous ?? []), value];
|
|
1132
|
+
const exportGroup = program.command('export').description('read-only exports for other tools');
|
|
1133
|
+
exportGroup.command('prompts')
|
|
1134
|
+
.description('every prompt a human typed, across hosts, with the project it happened in')
|
|
1135
|
+
.requiredOption('--since <date>', 'window start, inclusive — a bare YYYY-MM-DD is a UTC day boundary')
|
|
1136
|
+
.option('--until <date>', 'window end, exclusive (default: now)')
|
|
1137
|
+
.option('--host <name>', 'only this registered host (default: every one of them)')
|
|
1138
|
+
.option('--watch <path>', 'only prompts in projects under this root; repeatable (default: ~/Code)', addWatch)
|
|
1139
|
+
.option('--json', 'emit JSON instead of a summary')
|
|
1140
|
+
.action((o) => cmdExportPrompts(o));
|
|
1141
|
+
const project = program.command('project').description('manage projects');
|
|
1142
|
+
hostOption(project.command('create')
|
|
1143
|
+
.argument('<title>', 'name for the project as it appears in T3 Code')
|
|
1144
|
+
.argument('<workspace-root>', 'existing directory the project maps to')
|
|
1145
|
+
.description('create a project for an existing directory'))
|
|
1146
|
+
.action((title, root, o) => cmdProjectCreate(title, root, toFlags(o)));
|
|
1147
|
+
const thread = program.command('thread').description('create and drive threads');
|
|
1148
|
+
hostOption(thread.command('create')
|
|
1149
|
+
.argument('<project>', 'project id, title, or workspace root')
|
|
1150
|
+
.argument('<title...>', 'thread title; everything after the project is used')
|
|
1151
|
+
.description('create a thread (idle — use `thread send` to run it)')
|
|
1152
|
+
.option('--model <instance/model>', 'e.g. claudeAgent/claude-opus-5', 'claudeAgent/claude-opus-5')
|
|
1153
|
+
.option('--branch <branch>', 'git branch to associate with the thread')
|
|
1154
|
+
.option('--worktree <path>', 'git worktree the thread should run in')
|
|
1155
|
+
.option('--runtime-mode <mode>', 'approval-required | auto-accept-edits | auto | full-access', 'full-access')
|
|
1156
|
+
.option('--interaction-mode <mode>', 'default | plan', 'default'))
|
|
1157
|
+
.action((ref, title, o) => cmdThreadCreate(ref, title.join(' '), toFlags(o)));
|
|
1158
|
+
hostOption(thread.command('send')
|
|
1159
|
+
.alias('start')
|
|
1160
|
+
.argument('<thread>', 'thread id, exact title, or unique substring')
|
|
1161
|
+
.argument('<message...>', 'the message; everything after the thread is sent')
|
|
1162
|
+
.description('send a message to a thread and run the agent')
|
|
1163
|
+
.option('--model <instance/model>', "override the thread's model for this turn")
|
|
1164
|
+
.option('--runtime-mode <mode>', 'approval-required | auto-accept-edits | auto | full-access')
|
|
1165
|
+
.option('--interaction-mode <mode>', 'default | plan'))
|
|
1166
|
+
.action(async (ref, message, o) => {
|
|
1167
|
+
const { host: h, thread: t } = await resolve(ref, o);
|
|
1168
|
+
return cmdThreadStart(t, h, message.join(' '), toFlags(o));
|
|
1169
|
+
});
|
|
1170
|
+
hostOption(thread.command('rename')
|
|
1171
|
+
.argument('<thread>', 'thread id, exact title, or unique substring')
|
|
1172
|
+
.argument('<title...>', 'the new title')
|
|
1173
|
+
.description('set a thread title directly'))
|
|
1174
|
+
.action(async (ref, title, o) => {
|
|
1175
|
+
const { host: h, thread: t } = await resolve(ref, o);
|
|
1176
|
+
return cmdThreadRename(t, h, title.join(' '));
|
|
1177
|
+
});
|
|
1178
|
+
hostOption(thread.command('retitle')
|
|
1179
|
+
.argument('<thread>', 'thread id, exact title, or unique substring')
|
|
1180
|
+
.description('ask the server to derive a title; warns if it produces none')
|
|
1181
|
+
.option('--timeout <seconds>', 'how long to wait for a title', '30'))
|
|
1182
|
+
.action(async (ref, o) => {
|
|
1183
|
+
const { host: h, thread: t } = await resolve(ref, o);
|
|
1184
|
+
return cmdThreadRetitle(t, h, Number(o.timeout) > 0 ? Number(o.timeout) : 30);
|
|
1185
|
+
});
|
|
1186
|
+
hostOption(thread.command('interrupt')
|
|
1187
|
+
.argument('<thread>', 'thread id, exact title, or unique substring')
|
|
1188
|
+
.description('stop the turn currently running in a thread'))
|
|
1189
|
+
.action(async (ref, o) => {
|
|
1190
|
+
const { host: h, thread: t } = await resolve(ref, o);
|
|
1191
|
+
return cmdThreadInterrupt(t, h);
|
|
1192
|
+
});
|
|
1193
|
+
const VERB_HELP = {
|
|
1194
|
+
settle: 'mark a thread done so it drops out of the active list',
|
|
1195
|
+
archive: 'hide a thread from the default listing (reversible)',
|
|
1196
|
+
unarchive: 'bring an archived thread back into the default listing',
|
|
1197
|
+
unpin: 'remove a thread from the pinned section',
|
|
1198
|
+
delete: 'delete a thread',
|
|
1199
|
+
};
|
|
1200
|
+
for (const verb of SIMPLE_THREAD_COMMANDS) {
|
|
1201
|
+
hostOption(thread.command(verb)
|
|
1202
|
+
.argument('<thread>', 'thread id, exact title, or unique substring')
|
|
1203
|
+
.description(VERB_HELP[verb] ?? `${verb} a thread`))
|
|
1204
|
+
.action((ref, o) => cmdThreadSimple(verb, ref, toFlags(o)));
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
await program.parseAsync(process.argv);
|
|
1208
|
+
}
|
|
1209
|
+
catch (error) {
|
|
1210
|
+
console.error(`\x1b[31merror\x1b[0m ${error instanceof Error ? error.message : String(error)}`);
|
|
1211
|
+
process.exit(1);
|
|
1212
|
+
}
|
|
1213
|
+
//# sourceMappingURL=t3ctl.js.map
|