@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/package.json +37 -0
- 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);
|