@gobius/t3ctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/package.json +37 -0
  4. package/t3ctl.mjs +237 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gobius Dolhain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # t3ctl
2
+
3
+ A controller CLI for [T3 Code](https://github.com/pingdotgg/t3code) — a peer of the
4
+ mobile app, not a host. Lists and (eventually) drives threads across all your machines.
5
+
6
+ T3 Code is MIT-licensed open source. **Read the source before guessing at anything here:**
7
+ `docs/user/remote-access.md`, `docs/internals/environment-auth.md`,
8
+ `docs/internals/t3-connect.md`, and `packages/contracts/src/orchestration.ts`.
9
+
10
+ ## Install
11
+
12
+ npm i -g @gobius/t3ctl # installs a `t3ctl` binary
13
+ npx @gobius/t3ctl ls
14
+
15
+ ## Status: local read + write working; relay transport not started
16
+
17
+ t3ctl ls [-t|--threads] [-a|--all] [--json]
18
+ t3ctl host add <name> <origin> <token>
19
+ t3ctl project create <title> <workspace-root>
20
+ t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>]
21
+
22
+ `<project>` resolves by id, title, or workspace root. `--model` defaults to
23
+ `claudeAgent/claude-opus-5`; `instanceId` is the segment before the first slash
24
+ (opencode models are themselves slashed, e.g. `opencode/github-copilot/gpt-5.4`).
25
+
26
+ `thread create` creates an *idle* thread with no messages — it does not start the
27
+ agent. The UI never produces this state: it always fires `thread.create` immediately
28
+ followed by `thread.turn.start`, a single command that carries the first message
29
+ inline (`message: {messageId, role, text, attachments}` plus a `titleSeed`).
30
+ `thread.message-sent` and `thread.turn-start-requested` are the resulting *events*,
31
+ not commands. Starting a turn is not implemented here yet.
32
+
33
+ ## Auth
34
+
35
+ The server advertises its own policy at `GET /api/auth/session`; `bearer-access-token`
36
+ is a supported session method. `t3 auth` is the documented way to manage access.
37
+
38
+ npx t3 auth session issue --label t3ctl --ttl 30d --token-only
39
+ npx t3 auth session list
40
+ npx t3 auth session revoke <session-id>
41
+
42
+ `t3` is published on npm (the `apps/server` package); `npx t3` works. Note that npx
43
+ resolves the latest *published* version, which may lag the Nightly server you're running
44
+ — T3 Code warns about client/server version skew. The same CLI ships inside the desktop
45
+ app and needs no install, which guarantees an exact version match:
46
+
47
+ APP="/Applications/T3 Code (Nightly).app"
48
+ ELECTRON_RUN_AS_NODE=1 "$APP/Contents/MacOS/T3 Code (Nightly)" \
49
+ "$APP/Contents/Resources/app.asar/apps/server/dist/bin.mjs" auth session issue --token-only
50
+
51
+ Note `@t3tools/contracts` and `@t3tools/client-runtime` are `private: true` — readable in
52
+ the repo, but not installable from npm. Hence the hand-rolled HTTP client here.
53
+
54
+ ## Endpoints
55
+
56
+ | Purpose | Endpoint |
57
+ |---|---|
58
+ | List everything | `GET /api/orchestration/snapshot` |
59
+ | Per-thread | `GET /api/orchestration/threads/:threadId` |
60
+ | Writes (commands) | `POST /api/orchestration/dispatch` |
61
+
62
+ Commands are imperative, events past-tense (`thread.create` → `thread.created`).
63
+ **The client generates `commandId`, `threadId`, and `projectId`**; `commandId` is the
64
+ idempotency key. Exact schemas: `packages/contracts/src/orchestration.ts`.
65
+
66
+ project.create { commandId, projectId, title, workspaceRoot, createdAt,
67
+ createWorkspaceRootIfMissing? }
68
+ thread.create { commandId, threadId, projectId, title, modelSelection,
69
+ runtimeMode, interactionMode?, branch, worktreePath, createdAt }
70
+
71
+ ### Derived thread status
72
+
73
+ Most-urgent-first: `running` (`session.activeTurnId` or `session.status==="running"`)
74
+ › `error` › `snoozed` › `needs-review` (`proposedPlans`) › `settled` › `idle`;
75
+ `archived`/`deleted` short-circuit.
76
+
77
+ ## Cross-machine
78
+
79
+ `t3ctl` is origin-agnostic, so anything that gives a host a reachable URL works:
80
+
81
+ - **Tailscale** — `t3 serve --tailscale-serve` advertises `https://machine.tailnet.ts.net/`.
82
+ Then `t3ctl host add <name> <url> <token>`.
83
+ - **LAN** — `t3 serve --host "$(tailscale ip -4)"` or any bound interface.
84
+ - **SSH launch** — desktop-only today; the desktop app starts a remote server and port-forwards.
85
+ - **T3 Connect relay** (`relay.t3.codes`) — account-level environment registry, what mobile
86
+ uses off-tailnet. Client-side API is `/v1/client/environment-links`, `.../dpop-token`,
87
+ `.../environment-link-challenges`, `.../devices`. Not implemented here yet; see
88
+ `docs/internals/t3-connect.md` and `packages/contracts/src/relay.ts`.
89
+
90
+ ## Caveats
91
+
92
+ - Unofficial client. Built against T3 Code Nightly; pin to `snapshot` + `dispatch`.
93
+ - A host is only reachable while its T3 Code server is running.
94
+ - `snapshot` returns full messages/activities (~900 KB for 215 threads). Fine for `ls`,
95
+ wrong for polling — use `snapshotSequence` for incremental sync.
96
+ - Treat pairing tokens like passwords; revoke with `t3 auth session revoke`.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@gobius/t3ctl",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Controller CLI for T3 Code hosts — list and drive threads across machines",
6
+ "keywords": [
7
+ "t3",
8
+ "t3code",
9
+ "cli",
10
+ "agents",
11
+ "coding-agents"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Gobius Dolhain (https://github.com/Goobles)",
15
+ "homepage": "https://github.com/Goobles/t3ctl#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Goobles/t3ctl.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/Goobles/t3ctl/issues"
22
+ },
23
+ "bin": {
24
+ "t3ctl": "t3ctl.mjs"
25
+ },
26
+ "files": [
27
+ "t3ctl.mjs",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
package/t3ctl.mjs ADDED
@@ -0,0 +1,237 @@
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: { 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
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
51
+
52
+ const collect = async (hosts) => {
53
+ const results = await Promise.allSettled(hosts.map(async (h) => ({ host: h, snap: await snapshot(h) })));
54
+ const ok = [], failed = [];
55
+ results.forEach((r, i) => r.status === 'fulfilled' ? ok.push(r.value) : failed.push({ host: hosts[i], error: r.reason.message }));
56
+ return { ok, failed };
57
+ };
58
+
59
+ const cmdLs = async (args) => {
60
+ const showThreads = args.includes('--threads') || args.includes('-t');
61
+ const showAll = args.includes('--all') || args.includes('-a');
62
+ const asJson = args.includes('--json');
63
+ const hosts = readHosts();
64
+ if (!hosts.length) return console.error('No hosts registered. Run: t3ctl host add <name> <origin> <token>');
65
+
66
+ const { ok, failed } = await collect(hosts);
67
+
68
+ if (asJson) {
69
+ const out = ok.flatMap(({ host, snap }) => snap.projects
70
+ .filter((p) => showAll || !p.deletedAt)
71
+ .map((p) => ({
72
+ host: host.name, id: p.id, title: p.title, workspaceRoot: p.workspaceRoot,
73
+ threads: snap.threads.filter((t) => t.projectId === p.id)
74
+ .filter((t) => showAll || (!t.deletedAt && !t.archivedAt))
75
+ .map((t) => ({ id: t.id, title: t.title, branch: t.branch, status: threadStatus(t), provider: t.session?.providerName ?? null, updatedAt: t.updatedAt })),
76
+ })));
77
+ console.log(JSON.stringify({ projects: out, unreachable: failed.map((f) => ({ host: f.host.name, error: f.error })) }, null, 2));
78
+ return;
79
+ }
80
+
81
+ for (const { host, snap } of ok) {
82
+ console.log(`\n${bold(host.name)} ${dim(host.origin)}`);
83
+ const projects = snap.projects.filter((p) => showAll || !p.deletedAt)
84
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
85
+
86
+ for (const p of projects) {
87
+ const threads = snap.threads.filter((t) => t.projectId === p.id)
88
+ .filter((t) => showAll || (!t.deletedAt && !t.archivedAt))
89
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
90
+ if (!threads.length && !showAll) continue;
91
+
92
+ const counts = {};
93
+ for (const t of threads) counts[threadStatus(t)] = (counts[threadStatus(t)] ?? 0) + 1;
94
+ const badge = Object.entries(counts).map(([k, v]) => `${ICON[k] ?? '?'}${v}`).join(' ');
95
+
96
+ console.log(` ${bold(p.title)} ${badge} ${dim(p.workspaceRoot.replace(os.homedir(), '~'))}`);
97
+ if (!showThreads) continue;
98
+ for (const t of threads) {
99
+ const s = threadStatus(t);
100
+ const meta = [t.branch, t.session?.providerName].filter(Boolean).join(' ');
101
+ console.log(` ${ICON[s] ?? '?'} ${(t.title || '(untitled)').slice(0, 62).padEnd(62)} ${dim(meta)}`);
102
+ }
103
+ }
104
+ }
105
+ for (const f of failed) console.error(`\n\x1b[31munreachable\x1b[0m ${f.host.name}: ${f.error}`);
106
+ };
107
+
108
+ const cmdHost = (args) => {
109
+ const [sub, ...rest] = args;
110
+ const hosts = readHosts();
111
+ if (sub === 'add') {
112
+ const [name, origin, token] = rest;
113
+ if (!name || !origin || !token) return console.error('usage: t3ctl host add <name> <origin> <token>');
114
+ const next = hosts.filter((h) => h.name !== name).concat({ name, origin: origin.replace(/\/$/, ''), token });
115
+ writeHosts(next);
116
+ console.log(`added ${name} -> ${origin}`);
117
+ } else if (sub === 'rm') {
118
+ writeHosts(hosts.filter((h) => h.name !== rest[0]));
119
+ console.log(`removed ${rest[0]}`);
120
+ } else {
121
+ if (!hosts.length) return console.log('(no hosts)');
122
+ for (const h of hosts) console.log(`${h.name.padEnd(16)} ${h.origin} ${dim('token:' + h.token.slice(0, 8) + '…')}`);
123
+ }
124
+ };
125
+
126
+
127
+ // ---- writes -------------------------------------------------------------
128
+ // Commands are dispatched directly (no envelope). The CLIENT mints every id;
129
+ // commandId is the idempotency key, so retries are safe.
130
+ // Schemas: packages/contracts/src/orchestration.ts in pingdotgg/t3code.
131
+
132
+ const VALUE_FLAGS = ['host', 'model', 'branch', 'runtime-mode', 'interaction-mode', 'worktree'];
133
+
134
+ const parseArgs = (argv) => {
135
+ const flags = {}, pos = [];
136
+ for (let i = 0; i < argv.length; i++) {
137
+ const a = argv[i];
138
+ if (!a.startsWith('--')) { pos.push(a); continue; }
139
+ const k = a.slice(2);
140
+ flags[k] = VALUE_FLAGS.includes(k) ? argv[++i] : true;
141
+ }
142
+ return { flags, pos };
143
+ };
144
+
145
+ const pickHost = (flags) => {
146
+ const hosts = readHosts();
147
+ if (flags.host) {
148
+ const h = hosts.find((x) => x.name === flags.host);
149
+ if (!h) throw new Error(`no such host: ${flags.host}`);
150
+ return h;
151
+ }
152
+ if (!hosts.length) throw new Error('no hosts registered — run: t3ctl host add <name> <origin> <token>');
153
+ if (hosts.length > 1) throw new Error(`multiple hosts; pass --host <${hosts.map((h) => h.name).join('|')}>`);
154
+ return hosts[0];
155
+ };
156
+
157
+ const dispatch = async (host, command) => {
158
+ const res = await fetch(`${host.origin}/api/orchestration/dispatch`, {
159
+ method: 'POST',
160
+ headers: { authorization: `Bearer ${host.token}`, 'content-type': 'application/json' },
161
+ body: JSON.stringify(command),
162
+ signal: AbortSignal.timeout(host.timeoutMs ?? 15000),
163
+ });
164
+ const body = await res.text();
165
+ if (!res.ok) throw new Error(`${command.type} failed: HTTP ${res.status} ${body}`);
166
+ return body ? JSON.parse(body) : {};
167
+ };
168
+
169
+ const resolveProject = (snap, ref) =>
170
+ snap.projects.find((p) => p.id === ref) ??
171
+ snap.projects.find((p) => !p.deletedAt && p.title === ref) ??
172
+ snap.projects.find((p) => !p.deletedAt && p.workspaceRoot === path.resolve(ref.replace(/^~/, os.homedir())));
173
+
174
+ const cmdProject = async (args) => {
175
+ const [sub, ...rest] = args;
176
+ const { flags, pos } = parseArgs(rest);
177
+ if (sub !== 'create') return console.error('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
178
+ const [title, root] = pos;
179
+ if (!title || !root) return console.error('usage: t3ctl project create <title> <workspace-root> [--host <name>]');
180
+ const host = pickHost(flags);
181
+ const workspaceRoot = path.resolve(root.replace(/^~/, os.homedir()));
182
+ if (!fs.existsSync(workspaceRoot)) return console.error(`workspace root does not exist: ${workspaceRoot}`);
183
+ const projectId = crypto.randomUUID();
184
+ const { sequence } = await dispatch(host, {
185
+ type: 'project.create', commandId: crypto.randomUUID(),
186
+ projectId, title, workspaceRoot, createdAt: new Date().toISOString(),
187
+ });
188
+ console.log(`created project ${bold(title)} on ${host.name}\n id ${projectId}\n root ${workspaceRoot}\n seq ${sequence}`);
189
+ };
190
+
191
+ const cmdThread = async (args) => {
192
+ const [sub, ...rest] = args;
193
+ const { flags, pos } = parseArgs(rest);
194
+ if (sub !== 'create') return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
195
+ const [projectRef, ...titleParts] = pos;
196
+ const title = titleParts.join(' ');
197
+ if (!projectRef || !title) return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
198
+ const host = pickHost(flags);
199
+ const project = resolveProject(await snapshot(host), projectRef);
200
+ if (!project) throw new Error(`no project matching "${projectRef}" on ${host.name}`);
201
+
202
+ // instanceId is the segment before the first slash; model keeps the rest
203
+ // (opencode models look like "github-copilot/gpt-5.4").
204
+ const raw = flags.model ?? 'claudeAgent/claude-opus-5';
205
+ const slash = raw.indexOf('/');
206
+ if (slash < 1) throw new Error(`--model must be <instance>/<model>, got "${raw}"`);
207
+ const modelSelection = { instanceId: raw.slice(0, slash), model: raw.slice(slash + 1) };
208
+
209
+ const threadId = crypto.randomUUID();
210
+ const { sequence } = await dispatch(host, {
211
+ type: 'thread.create', commandId: crypto.randomUUID(),
212
+ threadId, projectId: project.id, title, modelSelection,
213
+ runtimeMode: flags['runtime-mode'] ?? 'full-access',
214
+ interactionMode: flags['interaction-mode'] ?? 'default',
215
+ branch: flags.branch ?? null,
216
+ worktreePath: flags.worktree ?? null,
217
+ createdAt: new Date().toISOString(),
218
+ });
219
+ console.log(`created thread ${bold(title)} in ${project.title} on ${host.name}\n id ${threadId}\n model ${modelSelection.instanceId}/${modelSelection.model}\n seq ${sequence}`);
220
+ };
221
+
222
+ const [cmd, ...rest] = process.argv.slice(2);
223
+ const commands = { ls: cmdLs, host: cmdHost, hosts: () => cmdHost([]), project: cmdProject, thread: cmdThread };
224
+ if (!commands[cmd]) {
225
+ console.log(`t3ctl — control T3 Code hosts
226
+
227
+ t3ctl ls [-t|--threads] [-a|--all] [--json] list projects and threads across hosts
228
+ t3ctl host add <name> <origin> <token> register a host
229
+ t3ctl host rm <name> remove a host
230
+ t3ctl hosts list registered hosts
231
+
232
+ t3ctl project create <title> <root> create a project for an existing dir
233
+ t3ctl thread create <project> <title> start a thread (--model inst/model,
234
+ --branch, --host)`);
235
+ process.exit(cmd ? 1 : 0);
236
+ }
237
+ await commands[cmd](rest);