@gobius/t3ctl 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +56 -3
  2. package/package.json +1 -1
  3. package/t3ctl.mjs +82 -2
package/README.md CHANGED
@@ -18,13 +18,19 @@ T3 Code is MIT-licensed open source. **Read the source before guessing at anythi
18
18
  t3ctl host add <name> <origin> <token>
19
19
  t3ctl project create <title> <workspace-root>
20
20
  t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>]
21
-
22
- `<project>` resolves by id, title, or workspace root. `--model` defaults to
21
+ t3ctl thread start <thread> <message...> [--interaction-mode plan] [--model ...]
22
+ t3ctl thread interrupt <thread>
23
+ t3ctl thread settle|archive|unarchive|unpin|delete <thread>
24
+
25
+ `<project>` resolves by id, title, or workspace root. `<thread>` resolves by id,
26
+ exact title, then unique case-insensitive substring (ambiguous matches are listed,
27
+ not guessed). The five verb commands above are exactly those whose payload is
28
+ `{commandId, threadId}`; `unsettle` carries extra fields and is not among them. `--model` defaults to
23
29
  `claudeAgent/claude-opus-5`; `instanceId` is the segment before the first slash
24
30
  (opencode models are themselves slashed, e.g. `opencode/github-copilot/gpt-5.4`).
25
31
 
26
32
  `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
33
+ agent; use `thread start` for that. The UI never produces this state: it always fires `thread.create` immediately
28
34
  followed by `thread.turn.start`, a single command that carries the first message
29
35
  inline (`message: {messageId, role, text, attachments}` plus a `titleSeed`).
30
36
  `thread.message-sent` and `thread.turn-start-requested` are the resulting *events*,
@@ -87,6 +93,53 @@ Most-urgent-first: `running` (`session.activeTurnId` or `session.status==="runni
87
93
  `.../environment-link-challenges`, `.../devices`. Not implemented here yet; see
88
94
  `docs/internals/t3-connect.md` and `packages/contracts/src/relay.ts`.
89
95
 
96
+ ## Releasing
97
+
98
+ Publishing runs in CI via **npm trusted publishing (OIDC)**. There is no `NPM_TOKEN`
99
+ anywhere — not in the workflow, not in repo secrets. CI proves its identity with a
100
+ short-lived OIDC token that npm verifies against a configured trusted publisher, so
101
+ there is no long-lived credential to leak, rotate, or exfiltrate. npm also attaches a
102
+ provenance attestation linking the tarball to the exact commit and workflow run.
103
+
104
+ To cut a release:
105
+
106
+ npm version minor # or patch/major; commits and tags
107
+ git push origin main --follow-tags
108
+
109
+ The `v*` tag triggers `.github/workflows/release.yml`, which **stages** the release.
110
+ CI cannot make a version public: the trusted publisher is configured stage-only, so
111
+ a maintainer must promote it with 2FA. Either:
112
+
113
+ - **npmjs.com** → the package → **Staged Packages** tab → **Approve**, or
114
+ - `npm stage list @gobius/t3ctl` then `npm stage approve <stage-id>`
115
+
116
+ 2FA is required either way. `npm stage view <id>` and `npm stage download <id>` let
117
+ you inspect the exact tarball before approving. Prereleases (`1.2.3-beta.0`) target
118
+ the `next` dist-tag; everything else `latest`.
119
+
120
+ Trust boundaries, deliberately:
121
+
122
+ - **Staged, not published.** CI stages with provenance; a human promotes with 2FA.
123
+ A compromised workflow cannot ship anything to users. This is npm's own hardened
124
+ recommendation, and the stage subcommands can't use OIDC tokens by design.
125
+ - **Tag-triggered, not push-to-main.** Merging never publishes.
126
+ - **`environment: release`.** The OIDC subject npm checks includes the environment,
127
+ so a workflow running outside it cannot publish even from this repo. Add required
128
+ reviewers to that environment in GitHub settings to gate releases on a human.
129
+ - **`permissions: {}` at the top**, with the job opting into only `contents: read`
130
+ and `id-token: write`.
131
+ - **Actions pinned to full commit SHAs**, so a moved tag cannot swap the code.
132
+ - **`persist-credentials: false`**, so the job's token isn't left in `.git/config`.
133
+ - **`--ignore-scripts`** on publish, and the package has zero dependencies, so no
134
+ third-party code executes in the release job.
135
+ - **Tag/version agreement is enforced** before publishing, not after.
136
+
137
+ One-time setup on npmjs.com (package → Settings → Trusted Publisher):
138
+ organization/user `Goobles`, repository `t3ctl`, workflow `release.yml`,
139
+ environment `release`. Grant it `npm stage publish` only — not `npm publish`.
140
+ Once that works, consider disallowing token-based publishes for the package entirely
141
+ so this pipeline becomes the only path in.
142
+
90
143
  ## Caveats
91
144
 
92
145
  - Unofficial client. Built against T3 Code Nightly; pin to `snapshot` + `dispatch`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobius/t3ctl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Controller CLI for T3 Code hosts — list and drive threads across machines",
6
6
  "keywords": [
package/t3ctl.mjs CHANGED
@@ -171,6 +171,63 @@ const resolveProject = (snap, ref) =>
171
171
  snap.projects.find((p) => !p.deletedAt && p.title === ref) ??
172
172
  snap.projects.find((p) => !p.deletedAt && p.workspaceRoot === path.resolve(ref.replace(/^~/, os.homedir())));
173
173
 
174
+
175
+ // Commands whose entire payload is {commandId, threadId}. Verified against
176
+ // packages/contracts/src/orchestration.ts — note `unsettle` is NOT one of
177
+ // these (it carries extra fields), so it is deliberately absent.
178
+ const SIMPLE_THREAD_COMMANDS = ['settle', 'archive', 'unarchive', 'unpin', 'delete'];
179
+
180
+ const resolveThread = (snap, ref) => {
181
+ const live = snap.threads.filter((t) => !t.deletedAt);
182
+ const byId = live.find((t) => t.id === ref);
183
+ if (byId) return byId;
184
+ const exact = live.filter((t) => t.title === ref);
185
+ if (exact.length === 1) return exact[0];
186
+ const fuzzy = live.filter((t) => (t.title ?? '').toLowerCase().includes(ref.toLowerCase()));
187
+ if (fuzzy.length === 1) return fuzzy[0];
188
+ if (fuzzy.length > 1) {
189
+ throw new Error(`"${ref}" matches ${fuzzy.length} threads:\n` +
190
+ fuzzy.slice(0, 8).map((t) => ` ${t.id} ${t.title}`).join('\n'));
191
+ }
192
+ throw new Error(`no thread matching "${ref}"`);
193
+ };
194
+
195
+
196
+ // thread.turn.start is ONE command carrying the first message inline — this is
197
+ // what the UI fires immediately after thread.create, which is why a thread with
198
+ // no messages is a state the UI never produces. The client-side schema requires
199
+ // runtimeMode/interactionMode explicitly (the server-side one defaults them).
200
+ const cmdThreadStart = async (thread, host, text, flags) => {
201
+ const command = {
202
+ type: 'thread.turn.start',
203
+ commandId: crypto.randomUUID(),
204
+ threadId: thread.id,
205
+ message: { messageId: crypto.randomUUID(), role: 'user', text, attachments: [] },
206
+ runtimeMode: flags['runtime-mode'] ?? thread.runtimeMode ?? 'full-access',
207
+ interactionMode: flags['interaction-mode'] ?? 'default',
208
+ createdAt: new Date().toISOString(),
209
+ };
210
+ if (flags.model) {
211
+ const slash = flags.model.indexOf('/');
212
+ if (slash < 1) throw new Error(`--model must be <instance>/<model>, got "${flags.model}"`);
213
+ command.modelSelection = { instanceId: flags.model.slice(0, slash), model: flags.model.slice(slash + 1) };
214
+ } else if (thread.modelSelection) {
215
+ command.modelSelection = thread.modelSelection;
216
+ }
217
+ const { sequence } = await dispatch(host, command);
218
+ const m = command.modelSelection;
219
+ console.log(`started ${bold(thread.title || thread.id)}\n id ${thread.id}` +
220
+ (m ? `\n model ${m.instanceId}/${m.model}` : '') +
221
+ `\n mode ${command.runtimeMode} / ${command.interactionMode}\n seq ${sequence}`);
222
+ };
223
+
224
+ const cmdThreadInterrupt = async (thread, host) => {
225
+ const { sequence } = await dispatch(host, {
226
+ type: 'thread.turn.interrupt', commandId: crypto.randomUUID(), threadId: thread.id,
227
+ });
228
+ console.log(`interrupted ${bold(thread.title || thread.id)}\n seq ${sequence}`);
229
+ };
230
+
174
231
  const cmdProject = async (args) => {
175
232
  const [sub, ...rest] = args;
176
233
  const { flags, pos } = parseArgs(rest);
@@ -191,7 +248,26 @@ const cmdProject = async (args) => {
191
248
  const cmdThread = async (args) => {
192
249
  const [sub, ...rest] = args;
193
250
  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>]');
251
+ if (sub === 'start' || sub === 'interrupt') {
252
+ const host = pickHost(flags);
253
+ const [ref, ...rest2] = pos;
254
+ if (!ref) return console.error(`usage: t3ctl thread ${sub} <thread>` + (sub === 'start' ? ' <message...>' : ''));
255
+ const thread = resolveThread(await snapshot(host), ref);
256
+ if (sub === 'interrupt') return cmdThreadInterrupt(thread, host);
257
+ const text = rest2.join(' ');
258
+ if (!text) return console.error('usage: t3ctl thread start <thread> <message...>');
259
+ return cmdThreadStart(thread, host, text, flags);
260
+ }
261
+ if (SIMPLE_THREAD_COMMANDS.includes(sub)) {
262
+ const host = pickHost(flags);
263
+ const thread = resolveThread(await snapshot(host), pos.join(' '));
264
+ const { sequence } = await dispatch(host, {
265
+ type: `thread.${sub}`, commandId: crypto.randomUUID(), threadId: thread.id,
266
+ });
267
+ console.log(`${sub}d ${bold(thread.title || thread.id)}\n id ${thread.id}\n seq ${sequence}`);
268
+ return;
269
+ }
270
+ if (sub !== 'create') return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]\n t3ctl thread <settle|archive|unarchive|unpin|delete> <thread>');
195
271
  const [projectRef, ...titleParts] = pos;
196
272
  const title = titleParts.join(' ');
197
273
  if (!projectRef || !title) return console.error('usage: t3ctl thread create <project> <title> [--model <instance>/<model>] [--branch <b>] [--host <name>]');
@@ -231,7 +307,11 @@ if (!commands[cmd]) {
231
307
 
232
308
  t3ctl project create <title> <root> create a project for an existing dir
233
309
  t3ctl thread create <project> <title> start a thread (--model inst/model,
234
- --branch, --host)`);
310
+ --branch, --host)
311
+ t3ctl thread start <thread> <message...> send a message and run the agent
312
+ t3ctl thread interrupt <thread> stop the running turn
313
+ t3ctl thread settle <thread> settle a thread (also: archive,
314
+ unarchive, unpin, delete)`);
235
315
  process.exit(cmd ? 1 : 0);
236
316
  }
237
317
  await commands[cmd](rest);