worktrust 0.6.7 → 0.6.10

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/worktrust.mjs CHANGED
@@ -8,17 +8,16 @@
8
8
  * npx worktrust --yes no question (for scripts); the computer is still approved in the app
9
9
  * npx worktrust --device a computer without a browser (SSH): type a code on another device
10
10
  * npx worktrust connect couple again, also on a computer already coupled
11
+ * npx worktrust update this computer onto the newest CLI, same key, no new pairing
11
12
  * npx worktrust history send this computer's earlier sessions as history (asks first)
12
- * npx worktrust web connect claude.ai or ChatGPT on the web (copies the address, opens the page)
13
- * npx worktrust import import earlier Claude or ChatGPT conversations (copies the prompt, opens a chat)
14
- * npx worktrust sources connect GitHub, Vercel, Supabase or Hugging Face (opens each approval page)
15
13
  * npx worktrust disconnect show what comes out, ask, take it out again
16
14
  * npx worktrust status what is coupled here
17
15
  *
18
16
  * WHAT RUNS. From the npm package, everything that runs is in the package: this file,
19
17
  * `setup-mcp.mjs` (gives every AI app here the WorkTrust door), `log-session.mjs` (the session hook)
20
- * and `count-behaviour.mjs` (the local counter, pinned: never replaced from the network). Nothing is
21
- * downloaded. Run as a single file from WorkTrust's site instead, it fetches those three from that
18
+ * and `count-behaviour.mjs` (the local counter, pinned: never replaced from the network), with
19
+ * `transcript-readers.mjs` (how Codex and Antigravity logs are read, imported by the last two). Nothing
20
+ * is downloaded. Run as a single file from WorkTrust's site instead, it fetches those from that
22
21
  * same site, and says so before it asks.
23
22
  *
24
23
  * HOW THE ACCOUNT IS DECIDED. By default this terminal listens on 127.0.0.1 for one answer and holds
@@ -50,10 +49,10 @@
50
49
  *
51
50
  * No dependencies: Node 18 or later.
52
51
  */
53
- import { spawn } from "node:child_process";
52
+ import { execFileSync, spawn } from "node:child_process";
54
53
  import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
55
54
  import { createServer } from "node:http";
56
- import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
55
+ import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
57
56
  import { homedir, hostname, platform } from "node:os";
58
57
  import { dirname, join } from "node:path";
59
58
  import { createInterface } from "node:readline/promises";
@@ -66,12 +65,15 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
66
65
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
67
66
  const has = (name) => args.includes(`--${name}`);
68
67
  /** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
69
- const CLI_VERSION = "0.6.7";
68
+ const CLI_VERSION = "0.6.10";
70
69
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
71
70
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
72
71
  const HOME_DIR = join(homedir(), ".worktrust");
73
72
  const HERE = dirname(fileURLToPath(import.meta.url));
74
- const SCRIPTS = ["setup-mcp.mjs", "log-session.mjs", "count-behaviour.mjs"];
73
+ const SCRIPTS = ["setup-mcp.mjs", "log-session.mjs", "count-behaviour.mjs", "transcript-readers.mjs", "session-databases.mjs", "config-edits.mjs"];
74
+ /** The modules the scripts import by name from beside them; a download keeps that name. */
75
+ const IMPORTED = new Set(["transcript-readers.mjs", "session-databases.mjs", "config-edits.mjs"]);
76
+ const downloaded = (name) => (IMPORTED.has(name) ? name : `${name}.download.mjs`);
75
77
  const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
76
78
  const PLACEHOLDER = `wt_${"0".repeat(43)}`;
77
79
  const say = (line = "") => process.stdout.write(`${line}\n`);
@@ -79,6 +81,28 @@ const fail = (line) => { process.stderr.write(`worktrust: ${line}\n`); process.e
79
81
  const OS = { darwin: "macos", win32: "windows", linux: "linux" }[platform()] ?? "other";
80
82
  const OS_NAME = { macos: "macOS", windows: "Windows", linux: "Linux", other: "this system" }[OS];
81
83
 
84
+ /**
85
+ * WHICH COMPUTER, WHICH PROFILE (0.6.10, owner 2026-10-05: one computer coupled twice sent its history twice). The
86
+ * machine's own id (macOS hardware UUID, Windows MachineGuid, Linux machine-id) and, for the profile, that id with this
87
+ * user's home folder: two people or two environments on one computer stay two. Only one-way hashes leave the computer,
88
+ * and WorkTrust keeps them only mixed with the account, so the same machine under two accounts matches nothing.
89
+ * A machine whose id cannot be read sends nothing, and is a computer as before.
90
+ */
91
+ function machineId() {
92
+ try {
93
+ if (OS === "macos") return execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 3000 }).match(/"IOPlatformUUID" = "([0-9A-Fa-f-]{36})"/)?.[1] ?? null;
94
+ if (OS === "windows") return execFileSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { encoding: "utf8", timeout: 3000 }).match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{36})/)?.[1] ?? null;
95
+ if (OS === "linux") for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) if (existsSync(file)) { const id = readFileSync(file, "utf8").trim(); if (/^[0-9a-f]{32}$/.test(id)) return id; }
96
+ } catch { /* unreadable: no identity */ }
97
+ return null;
98
+ }
99
+ function computerIds() {
100
+ const machine = machineId();
101
+ if (!machine) return {};
102
+ const hash = (text) => createHash("sha256").update(text).digest("base64url");
103
+ return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
104
+ }
105
+
82
106
  if (Number(process.versions.node.split(".")[0]) < 18) fail("needs Node 18 or later (fetch is built in from 18).");
83
107
 
84
108
  async function post(path, body) {
@@ -94,7 +118,7 @@ async function scripts() {
94
118
  for (const name of SCRIPTS) {
95
119
  const response = await fetch(`${ORIGIN}/counter/${name}`);
96
120
  if (!response.ok) fail(`could not download ${name} (${response.status}).`);
97
- paths[name] = join(HOME_DIR, `${name}.download.mjs`);
121
+ paths[name] = join(HOME_DIR, downloaded(name));
98
122
  writeFileSync(paths[name], await response.text(), { mode: 0o600 });
99
123
  }
100
124
  return paths;
@@ -181,9 +205,16 @@ async function pairByBrowser(host, extra) {
181
205
  say(` ✓ Approved as "${exchange.json.label}"${exchange.json.account ? ` for the WorkTrust account ${exchange.json.account}` : ""}`);
182
206
  if (exchange.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
183
207
  if (extraBound && exchange.json.bound === false) say(" ! WorkTrust did not bind the key to this computer; it works like a key made by hand.");
184
- return exchange.json.token;
208
+ return paired(exchange.json);
185
209
  }
186
210
 
211
+ /**
212
+ * What a pairing hands back: the key, its prefix (which names it to /api/cli/release), the account
213
+ * masked (to show), and `account_ref`, an opaque name for the account that is the same on every
214
+ * pairing of it, so a computer coupled again can tell "the same account" from "another one".
215
+ */
216
+ const paired = (json) => ({ token: json.token, prefix: typeof json.prefix === "string" ? json.prefix : null, account: json.account ?? null, accountRef: json.account_ref ?? null });
217
+
187
218
  /** WITHOUT A BROWSER: a code you type yourself on another device. Never a link with the code in it. */
188
219
  async function pairByCode(host, extra) {
189
220
  const pairing = await post("/api/cli/pair", { host, os: OS, flow: "device", ...extra }).catch(() => fail(`cannot reach ${ORIGIN}.`));
@@ -202,7 +233,7 @@ async function pairByCode(host, extra) {
202
233
  if (answer.status === 200 && answer.json.token) {
203
234
  say(` ✓ Approved as "${answer.json.label}"${answer.json.account ? ` for the WorkTrust account ${answer.json.account}` : ""}`);
204
235
  if (answer.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
205
- return answer.json.token;
236
+ return paired(answer.json);
206
237
  }
207
238
  if (answer.json.error === "slow_down") wait += 2000;
208
239
  else if (answer.json.error === "expired_token") fail("the code expired. Run `worktrust connect` again.");
@@ -245,6 +276,38 @@ const keyStore = {
245
276
  erase() { try { rmSync(KEY_FILE); } catch { /* gone */ } },
246
277
  };
247
278
 
279
+ /**
280
+ * WHICH ACCOUNT THIS COMPUTER LAST FED, after `disconnect` (owner, 2026-10-04). The key file goes; this
281
+ * small note stays (the masked address and the opaque account name, nothing secret), so that a later
282
+ * `connect` by ANOTHER account is recognised as a change of hands and offers none of the first
283
+ * person's earlier work. A successful `connect` deletes it.
284
+ */
285
+ const PREVIOUS_FILE = join(HOME_DIR, "previous.json");
286
+ const previousNote = {
287
+ save(entry) { if (!entry?.accountRef && !entry?.account) return; ensureHome(); writeFileSync(PREVIOUS_FILE, JSON.stringify({ account: entry.account ?? null, accountRef: entry.accountRef ?? null, until: new Date().toISOString() }), { mode: 0o600 }); },
288
+ load() { try { return JSON.parse(readFileSync(PREVIOUS_FILE, "utf8")); } catch { return null; } },
289
+ erase() { try { rmSync(PREVIOUS_FILE); } catch { /* gone */ } },
290
+ };
291
+
292
+ /**
293
+ * THE OLD KEY ENDS WHEN THE COMPUTER IS COUPLED AGAIN (owner, 2026-10-04). Signed with the old key's own
294
+ * device key, which only this computer holds, and naming the new key by its prefix: WorkTrust ends the
295
+ * old one at once (an AI app still open on it is refused, reads the key file again and continues on the
296
+ * new key) and, when the two keys belong to different accounts, tells the old account its computer
297
+ * moved, without saying to whom.
298
+ */
299
+ async function releasePrevious(previous, nextPrefix) {
300
+ if (!previous?.token || !nextPrefix) return;
301
+ if (!previous.device) { say(" ! The previous key was not bound to this computer: end it in WorkTrust → Sources → Devices."); return; }
302
+ try {
303
+ const url = `${new URL(previous.url).origin}/api/cli/release`;
304
+ const body = JSON.stringify({ next_prefix: nextPrefix });
305
+ const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${previous.token}`, ...proof(previous.device, url, body) }, body });
306
+ if (response.ok) say(" ✓ The previous key ended; WorkTrust no longer accepts it.");
307
+ else say(` ! The previous key could not be ended (${response.status}): end it in WorkTrust → Sources → Devices.`);
308
+ } catch { say(" ! WorkTrust could not be reached to end the previous key: end it in WorkTrust → Sources → Devices."); }
309
+ }
310
+
248
311
  /** `WorkTrust-Proof` for one request: seconds, a one-time nonce, and the Ed25519 signature over method, path, both and the body's hash. */
249
312
  function proof(device, url, body) {
250
313
  if (!device) return {};
@@ -283,10 +346,30 @@ function withLock(work) {
283
346
  return work().finally(() => { closeSync(handle); try { rmSync(LOCK_FILE); } catch { /* gone */ } });
284
347
  }
285
348
 
349
+ /**
350
+ * A KEY FILE ON OTHER HARDWARE (0.6.10, owner 2026-10-05: "a backup restored onto a new device must not upload to the
351
+ * passport unseen"). The key file remembers the profile it was made on; on any other computer it sends nothing, and
352
+ * WorkTrust is told once (`/api/cli/moved`), so the key is ended and the owner sees it. `npx worktrust connect` couples this one.
353
+ */
354
+ async function copiedHere(key) {
355
+ const here = computerIds().profile_id;
356
+ if (!key.profile || !here || key.profile === here) return false;
357
+ try {
358
+ // Its own door, which only ends and marks the key: nothing is renewed, so no new secret is ever announced here.
359
+ const url = `${new URL(key.url).origin}/api/cli/moved`, body = JSON.stringify(computerIds());
360
+ await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key.token}`, ...proof(key.device, url, body) }, body });
361
+ } catch { /* offline: it is told next time */ }
362
+ process.stderr.write("WorkTrust: this key was made on another computer (copied from a backup?). Nothing is sent from here; run `npx worktrust connect` on this computer.\n");
363
+ return true;
364
+ }
365
+
286
366
  async function freshKey() {
287
367
  const first = keyStore.load();
288
368
  if (!first) return null;
289
- if (!first.pending && Date.now() - Date.parse(first.renewedAt ?? 0) < RENEW_EVERY_MS) return first;
369
+ if (await copiedHere(first)) return null;
370
+ // A key made before 0.6.10 renews now, once: the renewal carries the computer's identity. `identified` means it was
371
+ // tried, so a machine whose id cannot be read does not renew on every use.
372
+ if (!first.pending && first.identified && Date.now() - Date.parse(first.renewedAt ?? 0) < RENEW_EVERY_MS) return first;
290
373
  const settled = await withLock(async () => {
291
374
  const value = keyStore.load();
292
375
  if (!value) return null;
@@ -295,14 +378,15 @@ async function freshKey() {
295
378
  if (next === true) { const promoted = { ...value, token: value.pending, renewedAt: new Date().toISOString() }; delete promoted.pending; keyStore.save(promoted); return promoted; }
296
379
  if (next === false) { delete value.pending; keyStore.save(value); }
297
380
  }
298
- if (Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
381
+ if (value.identified && Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
299
382
  const pending = `wt_${randomBytes(32).toString("base64url")}`;
300
383
  keyStore.save({ ...value, pending });
301
384
  try {
302
385
  const renewUrl = `${new URL(value.url).origin}/api/cli/renew`;
303
- const body = JSON.stringify({ token_hash: createHash("sha256").update(pending).digest("hex"), token_prefix: pending.slice(0, 11) });
386
+ const ids = computerIds();
387
+ const body = JSON.stringify({ token_hash: createHash("sha256").update(pending).digest("hex"), token_prefix: pending.slice(0, 11), ...ids });
304
388
  const response = await fetch(renewUrl, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${value.token}`, ...proof(value.device, renewUrl, body) }, body });
305
- if (response.ok) { const renewed = { ...value, token: pending, renewedAt: new Date().toISOString() }; keyStore.save(renewed); return renewed; }
389
+ if (response.ok) { const renewed = { ...value, token: pending, renewedAt: new Date().toISOString(), identified: true, profile: value.profile ?? ids.profile_id }; keyStore.save(renewed); return renewed; }
306
390
  // Refused: drop the pending key only if the file still holds the value this renewal read.
307
391
  if (response.status === 400 || response.status === 401) { const now = keyStore.load(); if (now?.token === value.token && now?.pending === pending) keyStore.save(value); }
308
392
  } catch { /* offline: the pending key is settled next time */ }
@@ -354,17 +438,107 @@ async function bridge() {
354
438
 
355
439
  /** THE HOOK Claude Code runs (`worktrust.mjs hook`): the key from the key file, handed to the session hook. */
356
440
  async function hook() {
441
+ if (has("antigravity-stop")) return antigravityStop();
442
+ if (has("hermes") || has("goose")) return sessionEnd();
357
443
  const key = await freshKey();
358
444
  if (!key) process.exit(0);
359
445
  const child = spawn(process.execPath, [join(HOME_DIR, "log-session.mjs"), ...args.filter((arg) => arg !== "hook")], { stdio: "inherit", env: { ...process.env, WORKTRUST_MCP_URL: key.url, WORKTRUST_MCP_TOKEN: key.token, ...(key.device ? { WORKTRUST_DEVICE_KEY: key.device } : {}) } });
360
446
  child.on("exit", (code) => process.exit(code ?? 0));
361
447
  }
362
448
 
449
+ /**
450
+ * ANTIGRAVITY'S STOP HOOK (`worktrust.mjs hook --antigravity-stop`, 2026-10-04). Antigravity runs it
451
+ * synchronously when its agent loop ends and reads its stdout as JSON whose `decision` is required: "continue"
452
+ * would send the agent back to work, any other value lets it stop (antigravity.google/docs/hooks/). So this
453
+ * prints `{"decision":"stop"}` FIRST, synchronously, changing nothing, and exits 0 at once: the sweep runs detached, as
454
+ * `hook --antigravity`, with the payload (ids, paths and the model name; no text) in its environment.
455
+ * Offline, or with no key, the sweep ends quietly; Antigravity never waits for it.
456
+ */
457
+ const STOP_ANSWER = { decision: "stop" };
458
+ async function antigravityStop() {
459
+ writeSync(1, `${JSON.stringify(STOP_ANSWER)}\n`);
460
+ const input = await new Promise((resolve) => {
461
+ let text = "";
462
+ if (process.stdin.isTTY) { resolve(text); return; }
463
+ const timer = setTimeout(() => resolve(text), 2000);
464
+ process.stdin.on("data", (chunk) => { text += chunk; });
465
+ process.stdin.on("end", () => { clearTimeout(timer); resolve(text); });
466
+ process.stdin.on("error", () => { clearTimeout(timer); resolve(text); });
467
+ });
468
+ try { spawn(process.execPath, [fileURLToPath(import.meta.url), "hook", "--antigravity"], { detached: true, stdio: "ignore", env: { ...process.env, WORKTRUST_HOOK_INPUT: input.slice(0, 65536) } }).unref(); } catch { /* the next run sweeps it */ }
469
+ process.exit(0);
470
+ }
471
+
472
+ /**
473
+ * A SESSION-END HOOK THAT READS JSON AND ANSWERS JSON: Hermes Agent's (`hook --hermes`) and Goose's SessionEnd
474
+ * (`hook --goose`, an Open Plugins hook: JSON on stdin, run with sh -c), 2026-10-05. Hermes pipes a JSON payload (ids, the
475
+ * model, the platform; no message body) to a shell hook on every `on_session_end` and reads stdout as JSON
476
+ * (hermes-agent.nousresearch.com/docs/user-guide/features/hooks). This answers `{}` (nothing to change), reads and drops
477
+ * the payload, and runs the sweep detached: the session hook reads Hermes's own database (transcript-readers.mjs).
478
+ */
479
+ async function sessionEnd() {
480
+ writeSync(1, "{}\n");
481
+ if (!process.stdin.isTTY) await new Promise((resolve) => { const timer = setTimeout(resolve, 2000); process.stdin.on("data", () => {}); process.stdin.on("end", () => { clearTimeout(timer); resolve(); }); process.stdin.on("error", () => { clearTimeout(timer); resolve(); }); });
482
+ try { spawn(process.execPath, [fileURLToPath(import.meta.url), "hook"], { detached: true, stdio: "ignore" }).unref(); } catch { /* the next run sweeps it */ }
483
+ process.exit(0);
484
+ }
485
+
486
+ /**
487
+ * `npx worktrust update` (owner, 2026-10-04): THIS COMPUTER ONTO THE NEWEST CLI, WITHOUT PAIRING IT
488
+ * AGAIN. `connect` mints a new key and so a new device row to merge; an update keeps the key and the
489
+ * row. It writes what `connect` writes after its pairing: the bridge copy in ~/.worktrust, every
490
+ * WorkTrust entry in the AI apps taken out (a hand-made one with a key in it too) and the bridge put
491
+ * in, and the session hook. A computer coupled by hand, without the key file, is sent to `connect`.
492
+ */
493
+ async function update() {
494
+ const key = await freshKey();
495
+ if (!key) fail("this computer has no WorkTrust key file yet. Run npx worktrust@latest connect.");
496
+ say();
497
+ say(` WorkTrust · update ${hostname().replace(/\.local$/i, "")} to ${CLI_VERSION}`);
498
+ say();
499
+ const paths = await scripts();
500
+ const plan = await run(paths["setup-mcp.mjs"], ["--bridge", STABLE], PLACEHOLDER, true);
501
+ say(" Every AI app found here gets the WorkTrust door, the local command that reads the key:");
502
+ const found = planLines(plan.out, true).filter((line) => !line.includes("not on this machine"));
503
+ for (const line of found) say(line);
504
+ if (found.length === 0) say(" (no AI app found here)");
505
+ say(` The session hook and ${STABLE} are brought to ${CLI_VERSION}.`);
506
+ say(" The key stays as it is: no new pairing, the same computer in WorkTrust.");
507
+ say();
508
+ if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
509
+ if (!(await ask("Continue?"))) { say(" Stopped. Nothing was written."); return; }
510
+ ensureHome();
511
+ copyFileSync(fileURLToPath(import.meta.url), STABLE);
512
+ say(` ✓ ${STABLE} is now ${CLI_VERSION}`);
513
+ // Out first, then in: an app that held a key of its own (a hand-made entry) moves onto the bridge.
514
+ await run(paths["setup-mcp.mjs"], ["--remove", "--write"], key.token, true);
515
+ if ((await run(paths["setup-mcp.mjs"], ["--write", "--bridge", STABLE], key.token)).code !== 0) fail("coupling the AI apps failed; the output above says where.");
516
+ const hookArgs = ["--install", ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), "--via", STABLE];
517
+ if ((await run(paths["log-session.mjs"], hookArgs, key.token, false)).code !== 0) fail("installing the session hook failed; the output above says where.");
518
+ // Codex installed since, or a No said at connect: the one question, asked again (or "already set").
519
+ await offerCodex();
520
+ await offerAntigravity();
521
+ say();
522
+ say(" Done. Quit your AI apps completely and open them again; the CLI version shows in WorkTrust");
523
+ say(" under Sources → CLI once one of them has spoken. A key an app held by hand still works until you end it there.");
524
+ }
525
+
363
526
  async function connect() {
364
527
  const host = (flag("name") ?? hostname()).replace(/\.local$/i, "").slice(0, 64);
365
528
  say();
366
529
  say(` WorkTrust · connect ${host} (${OS_NAME})`);
367
530
  say();
531
+ // ALREADY COUPLED: SAY TO WHOM, AND ASK (owner, 2026-10-04). Coupling again moves this computer to
532
+ // whichever account approves it next and ends the current key; Enter, --yes and a closed terminal
533
+ // mean No, `--replace` says yes in advance.
534
+ const previous = keyStore.load();
535
+ const before = previous ?? previousNote.load();
536
+ if (previous) {
537
+ say(previous.account ? ` This computer is coupled to the WorkTrust account ${previous.account}.` : " This computer is already coupled (by an older version, which did not record the account).");
538
+ say(" Coupling it again sends its work to the account you approve next, and its current key ends.");
539
+ if (!has("replace") && !(await ask("Couple it again?", false))) { say(" Stopped. Nothing was changed: this computer stays coupled as it is."); return; }
540
+ say();
541
+ }
368
542
  if (!BUNDLED) say(` The three scripts that do the work are downloaded from ${ORIGIN} first. From npm they come in the package.\n`);
369
543
  const paths = await scripts();
370
544
  const direct = has("direct");
@@ -389,28 +563,38 @@ async function connect() {
389
563
  // The device key: made here, its private half kept with the key, its public half bound at WorkTrust.
390
564
  const device = direct ? null : generateKeyPairSync("ed25519");
391
565
  const apps = foundApps.map((line) => /^\s*[↻+✓] ([^:]+):/.exec(line)?.[1]).filter(Boolean).slice(0, 12);
392
- const extra = { apps, ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
393
- const token = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
566
+ const extra = { apps, ...computerIds(), ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
567
+ const pairing = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
568
+ const { token } = pairing;
569
+ // ANOTHER ACCOUNT THAN BEFORE: told by the opaque account names. Unknown counts as another, the safe side.
570
+ const switched = Boolean(before) && !(before.accountRef && pairing.accountRef && before.accountRef === pairing.accountRef);
394
571
 
395
572
  say();
396
573
  // Out first, then in: an app that already held a WorkTrust key moves to the new one.
397
574
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], token, true);
398
575
  if (!direct) {
399
- keyStore.save({ url: MCP, token, renewedAt: new Date().toISOString(), device: device.privateKey.export({ format: "pem", type: "pkcs8" }) });
576
+ keyStore.save({ url: MCP, token, renewedAt: new Date().toISOString(), coupledAt: new Date().toISOString(), identified: true, profile: computerIds().profile_id, account: pairing.account, accountRef: pairing.accountRef, device: device.privateKey.export({ format: "pem", type: "pkcs8" }) });
577
+ previousNote.erase();
400
578
  ensureHome();
401
579
  copyFileSync(fileURLToPath(import.meta.url), STABLE);
402
580
  say(` ✓ Key kept in ${KEY_PLACE}`);
403
581
  }
404
582
  if ((await run(paths["setup-mcp.mjs"], direct ? ["--write"] : ["--write", "--bridge", STABLE], token)).code !== 0) fail("coupling the AI apps failed; the output above says where.");
405
583
  say();
406
- const hookArgs = ["--install", ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), ...(direct ? [] : ["--via", STABLE])];
407
- if ((await run(paths["log-session.mjs"], hookArgs, token)).code !== 0) fail("installing the session hook failed; the output above says where.");
408
- await offerHistory(paths, token, device ? device.privateKey.export({ format: "pem", type: "pkcs8" }) : null);
409
- await offerWeb();
410
- await offerSources();
584
+ // The old key ends now that the new one is in place (never before: a failed pairing must not leave the computer uncoupled).
585
+ if (previous?.token) await releasePrevious(previous, pairing.prefix);
586
+ // A NEW COUPLING SENDS NOTHING FROM BEFORE IT (log-session THE FLOORS): `--from-now` for a first coupling and a change
587
+ // of account; the same account coupled again keeps its watermarks. A change of account also closes the history offer.
588
+ const floors = !previous || switched ? ["--from-now", ...(switched ? ["--history-floor", "now"] : [])] : [];
589
+ const hookArgs = ["--install", ...floors, ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), ...(direct ? [] : ["--via", STABLE])];
590
+ if ((await run(paths["log-session.mjs"], hookArgs, token, false, { WORKTRUST_CODEX_OFFER: "1" })).code !== 0) fail("installing the session hook failed; the output above says where.");
591
+ await offerCodex();
592
+ await offerAntigravity();
593
+ if (switched) { say(); say(" Earlier work on this computer is not offered: it was coupled to another account before. Only work from now on goes to this account."); }
594
+ else await offerHistory(paths, token, device ? device.privateKey.export({ format: "pem", type: "pkcs8" }) : null);
411
595
  say();
412
596
  say(" Done. Quit your AI apps completely and open them again. This computer appears in WorkTrust");
413
- say(" under Sources → Devices once one of them has spoken.");
597
+ say(" under Sources → CLI once one of them has spoken.");
414
598
  }
415
599
 
416
600
  /**
@@ -425,98 +609,131 @@ async function offerHistory(paths, token, devicePem, url = MCP) {
425
609
  let found = null;
426
610
  try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
427
611
  say();
428
- if (!found?.sessions) { say(" No earlier Claude Code or Codex sessions on this computer to send."); return; }
429
- say(` This computer holds ${found.sessions} earlier Claude Code / Codex sessions · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
612
+ if (!found?.sessions) { say(" No earlier Claude Code, Codex or Antigravity sessions on this computer to send."); return; }
613
+ say(` This computer holds ${found.sessions} earlier Claude Code / Codex / Antigravity sessions · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
430
614
  if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
431
615
  if (!has("history") && !(await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(" Not sent. Send them later with: npx worktrust history"); return; }
432
616
  const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
433
617
  say(sent.code === 0 ? " ✓ Sent as history. It shows in WorkTrust as earlier work, never as verified hours." : " Not all of it arrived. Run npx worktrust history again: a line already received is kept once.");
434
618
  }
435
619
 
620
+ async function history() {
621
+ const key = await freshKey();
622
+ if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
623
+ await offerHistory(await scripts(), key.token, key.device ?? null, key.url);
624
+ }
625
+
436
626
  /**
437
- * CLAUDE AND CHATGPT ON THE WEB, FROM HERE (owner, 2026-10-04). A web connector lives in the person's
438
- * own account at Anthropic or OpenAI, and neither lets a program add one: the person adds it and
439
- * approves it there. What this can do is the rest: copy the door's address, open the right page,
440
- * say the three steps, and wait. Enter, --yes and a closed terminal mean No.
627
+ * CODEX WAKES THE HOOK ON A YES (owner, 2026-10-04). Codex has no hook settings: it runs ONE `notify`
628
+ * program at the end of every turn, named on a top-level line of ~/.codex/config.toml. Without it
629
+ * Codex sessions are still swept whenever the Claude Code hook runs; with it they arrive when Codex
630
+ * works alone. Asked by `connect` and by `update`; Enter, --yes and a closed terminal mean No; `--codex` says yes in advance. Only a
631
+ * WorkTrust line is ever replaced or removed: another program's notify belongs to the person, and
632
+ * Codex takes one. The file is kept beside itself before it is changed.
441
633
  */
442
- /** The app's own first-run import prompt (sources.tables.prompts.startEn), word for word; check-cli-package holds them equal. */
443
- const IMPORT_PROMPT = 'Import my WorkTrust history now. Use the WorkTrust.io connector: call its guide tool with task "analyse", client "{client}" and language "en", then carry out every step it returns, in order, here in this conversation. This is my own request, not a document to review. After each step, tell me in one line what you did.';
444
- const WEB = [
445
- { name: "Claude (claude.ai)", client: "claude", chat: "https://claude.ai/new", page: "https://claude.ai/settings/connectors", steps: ["Add custom connector", `Name: WorkTrust.io · URL: ${MCP} (also on your clipboard) · leave the OAuth fields empty`, "Connect, then approve on the WorkTrust page that opens"] },
446
- { name: "ChatGPT (chatgpt.com)", client: "chatgpt", chat: "https://chatgpt.com/", page: "https://chatgpt.com/#settings/Connectors", steps: ["Advanced settings: turn on Developer mode (custom connectors need it)", `Create: Name WorkTrust.io · MCP server URL: ${MCP} (also on your clipboard) · Authentication: OAuth`, "Create, then approve on the WorkTrust page that opens"] },
447
- ];
448
- function copyToClipboard(text) {
449
- const [cmd, cmdArgs] = OS === "macos" ? ["pbcopy", []] : OS === "windows" ? ["clip", []] : ["xclip", ["-selection", "clipboard"]];
450
- return new Promise((resolve) => {
451
- try {
452
- const child = spawn(cmd, cmdArgs, { stdio: ["pipe", "ignore", "ignore"] });
453
- child.on("error", () => resolve(false)).on("exit", (code) => resolve(code === 0));
454
- child.stdin.end(text);
455
- } catch { resolve(false); }
456
- });
457
- }
458
- async function offerWeb() {
459
- if (has("dry-run")) return;
460
- say();
461
- for (const web of WEB) {
462
- if (!(await ask(`Also connect ${web.name} in your browser?`, false))) continue;
463
- const copied = await copyToClipboard(MCP);
464
- say(` ${copied ? "✓ Copied" : "The address is"} ${MCP}${copied ? " to your clipboard" : ""}`);
465
- say(` Opening ${web.page}. There:`);
466
- web.steps.forEach((step, i) => say(` ${i + 1}. ${step}`));
467
- openBrowser(web.page);
468
- if (process.stdin.isTTY) { const prompt = createInterface({ input: process.stdin, output: process.stdout }); await prompt.question(" Press Enter when it is connected (or to skip). "); prompt.close(); }
469
- // EARLIER CONVERSATIONS, SO NOBODY GETS LOST IN THE IMPORT (owner, 2026-10-04): the app's own
470
- // prompt, copied, and a new conversation opened to paste it in. Only on a typed yes.
471
- if (await ask(`Also import your earlier ${web.name.split(" ")[0]} conversations? (copies one prompt to paste in a new chat)`, false)) await handOverPrompt(web);
634
+ const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
635
+ const isOurNotify = (text) => /--codex-notify/.test(text) && /worktrust|log-session\.mjs/i.test(text);
636
+
637
+ /** The top-level `notify = [...]` of a Codex config (it may span lines), or where one would go: before the first table. */
638
+ function codexNotify() {
639
+ let toml = "";
640
+ try { toml = readFileSync(CODEX_CONFIG, "utf8"); } catch { /* none yet */ }
641
+ const rows = toml === "" ? [] : toml.split("\n");
642
+ for (let at = 0; at < rows.length; at += 1) {
643
+ if (/^\s*\[/.test(rows[at])) return { rows, at: -1, end: -1, insert: at };
644
+ if (/^\s*notify\s*=/.test(rows[at])) {
645
+ let end = at;
646
+ while (!rows[end].includes("]") && end < rows.length - 1) end += 1;
647
+ return { rows, at, end, text: rows.slice(at, end + 1).join("\n") };
648
+ }
472
649
  }
650
+ return { rows, at: -1, end: -1, insert: rows.at(-1) === "" ? rows.length - 1 : rows.length };
473
651
  }
474
652
 
475
- /** The import prompt for one product: copied (or printed when there is no clipboard), and a new conversation opened. */
476
- async function handOverPrompt(web) {
477
- const text = IMPORT_PROMPT.replace("{client}", web.client);
478
- say(` ${(await copyToClipboard(text)) ? "✓ The import prompt is on your clipboard" : `The prompt:\n\n${text}\n`}`);
479
- say(` Opening a new conversation in ${web.name}: paste it there and send. It asks before it sends anything, and only counts leave.`);
480
- openBrowser(web.chat);
653
+ function writeCodex(rows) {
654
+ if (existsSync(CODEX_CONFIG)) copyFileSync(CODEX_CONFIG, `${CODEX_CONFIG}.worktrust-backup`);
655
+ writeFileSync(CODEX_CONFIG, rows.join("\n"));
481
656
  }
482
657
 
483
- /**
484
- * THE OTHER SOURCES, FROM HERE (owner, 2026-10-04): GitHub, Vercel, Supabase and Hugging Face couple
485
- * in the browser, through the same doors as the buttons under Sources — each opens its own approval
486
- * page where the person is already signed in to WorkTrust. One [y/N] per source; Enter means No.
487
- */
488
- const CLOUD = [
489
- { name: "GitHub", path: "/api/connect/github", what: "commits, checks and deployments, as counts" },
490
- { name: "Vercel", path: "/api/connect/vercel", what: "deployments and their outcomes" },
491
- { name: "Supabase", path: "/api/connect/supabase", what: "database projects and migrations, as counts" },
492
- { name: "Hugging Face", path: "/api/connect/huggingface", what: "models, datasets and spaces you published" },
493
- ];
494
- async function offerSources() {
495
- if (has("dry-run")) return;
658
+ async function offerCodex() {
659
+ if (!existsSync(join(homedir(), ".codex"))) { if (command === "codex") say(" Codex is not on this computer (no ~/.codex)."); return; }
660
+ const viaBridge = existsSync(STABLE);
661
+ const hookFile = viaBridge ? STABLE : join(HOME_DIR, "log-session.mjs");
662
+ if (!existsSync(hookFile)) { say(" Couple this computer first: npx worktrust"); return; }
663
+ const want = `notify = [${(viaBridge ? [NODE, STABLE, "hook", "--codex-notify"] : [NODE, hookFile, "--codex-notify"]).map((part) => JSON.stringify(part)).join(", ")}]`;
664
+ const found = codexNotify();
496
665
  say();
497
- for (const source of CLOUD) {
498
- if (!(await ask(`Also connect ${source.name} (${source.what})?`, false))) continue;
499
- const page = new URL(source.path, ORIGIN).toString();
500
- say(` Opening ${page}: approve there, signed in to WorkTrust.`);
501
- openBrowser(page);
502
- if (process.stdin.isTTY) { const prompt = createInterface({ input: process.stdin, output: process.stdout }); await prompt.question(" Press Enter when it is approved (or to skip). "); prompt.close(); }
666
+ if (found.text === want) { say(" ✓ Codex already wakes the session hook at the end of every turn."); return; }
667
+ if (found.text && !isOurNotify(found.text)) {
668
+ say(" Codex already runs another program at the end of each turn (notify in ~/.codex/config.toml).");
669
+ say(" Codex takes one, so it stays as it is; Codex sessions are still sent whenever the Claude Code hook runs.");
670
+ return;
503
671
  }
672
+ say(" Codex can wake the session hook at the end of every turn, so Codex work arrives without Claude Code running:");
673
+ say(` ${found.text ? "↻" : "+"} ${CODEX_CONFIG} → ${want}`);
674
+ if (has("dry-run")) { say(" Dry run: nothing was written."); return; }
675
+ if (!has("codex") && !(await ask("Let Codex wake the session hook?", false))) { say(" Not set. npx worktrust@latest update asks again."); return; }
676
+ const rows = [...found.rows];
677
+ if (found.text) rows.splice(found.at, found.end - found.at + 1, want);
678
+ else rows.splice(found.insert, 0, ...(found.insert < rows.length && rows[found.insert] !== "" ? [want, ""] : [want]));
679
+ if (rows.at(-1) !== "") rows.push("");
680
+ writeCodex(rows);
681
+ say(" ✓ Codex wakes the session hook. Restart Codex to pick it up; the earlier file is kept as config.toml.worktrust-backup.");
504
682
  }
505
683
 
506
684
  /**
507
- * `npx worktrust import` (owner, 2026-10-04): the earlier conversations, later. For whoever skipped
508
- * the question after connecting, or connected on the web without this computer. Each product asks.
685
+ * ANTIGRAVITY WAKES THE HOOK ON A YES (owner, 2026-10-04), the way Codex does. Antigravity (the 2.0 app,
686
+ * the `agy` CLI, the IDE) reads named hooks from ~/.gemini/config/hooks.json, each with its events
687
+ * (antigravity.google/docs/hooks/). This adds one hook named `worktrust` with a single Stop command, merged
688
+ * into whatever else the file holds, the file kept beside itself first. Enter, --yes and a closed
689
+ * terminal mean No; `--antigravity` says yes in advance. Without it, Antigravity conversations are still
690
+ * swept whenever another client's hook runs.
509
691
  */
510
- async function importConversations() {
692
+ const AGY_HOOKS = join(homedir(), ".gemini", "config", "hooks.json");
693
+ const antigravityHere = () => ["config", ...readdirSafe(join(homedir(), ".gemini")).filter((name) => /^antigravity/.test(name))].some((name) => existsSync(join(homedir(), ".gemini", name)));
694
+ function readdirSafe(dir) { try { return readdirSync(dir); } catch { return []; } }
695
+ /** The hooks file as an object; null when it exists and is not JSON, which is never overwritten. */
696
+ function agyHooks() {
697
+ if (!existsSync(AGY_HOOKS)) return {};
698
+ try { const parsed = JSON.parse(readFileSync(AGY_HOOKS, "utf8") || "{}"); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; } catch { return null; }
699
+ }
700
+ function writeAgyHooks(hooks) {
701
+ mkdirSync(dirname(AGY_HOOKS), { recursive: true });
702
+ if (existsSync(AGY_HOOKS)) copyFileSync(AGY_HOOKS, `${AGY_HOOKS}.worktrust-backup`);
703
+ writeFileSync(AGY_HOOKS, `${JSON.stringify(hooks, null, 2)}\n`);
704
+ }
705
+ async function offerAntigravity() {
706
+ if (!antigravityHere()) { if (command === "antigravity") say(" Antigravity is not on this computer (no ~/.gemini/config or ~/.gemini/antigravity*)."); return; }
707
+ if (!existsSync(STABLE)) { say(" Couple this computer first: npx worktrust"); return; }
708
+ const want = { enabled: true, Stop: [{ type: "command", command: `"${NODE}" "${STABLE}" hook --antigravity-stop`, timeout: 10 }] };
709
+ const hooks = agyHooks();
511
710
  say();
512
- say(" Your earlier conversations come in through the WorkTrust connector in that product: connect it first if you have not (npx worktrust web).");
513
- for (const web of WEB) if (await ask(`Import your earlier ${web.name} conversations now?`, false)) await handOverPrompt(web);
711
+ if (hooks === null) { say(` ${AGY_HOOKS} is not readable JSON, so it is left as it is; Antigravity sessions are still sent whenever another hook runs.`); return; }
712
+ if (JSON.stringify(hooks.worktrust) === JSON.stringify(want)) { say(" ✓ Antigravity already wakes the session hook when its agent stops."); return; }
713
+ say(" Antigravity can wake the session hook when its agent stops, so Antigravity work arrives without Claude Code running:");
714
+ say(` ${hooks.worktrust ? "↻" : "+"} ${AGY_HOOKS} → worktrust: Stop runs ${want.Stop[0].command}`);
715
+ if (has("dry-run")) { say(" Dry run: nothing was written."); return; }
716
+ if (!has("antigravity") && !(await ask("Let Antigravity wake the session hook?", false))) { say(" Not set. npx worktrust@latest update asks again."); return; }
717
+ writeAgyHooks({ ...hooks, worktrust: want });
718
+ say(` ✓ Antigravity wakes the session hook. Restart Antigravity to pick it up${existsSync(`${AGY_HOOKS}.worktrust-backup`) ? "; the earlier file is kept as hooks.json.worktrust-backup" : ""}.`);
719
+ }
720
+ /** Disconnect takes the hook named worktrust out of Antigravity's hooks, and nothing else. */
721
+ function removeAntigravityHook() {
722
+ const hooks = agyHooks();
723
+ if (!hooks?.worktrust) return false;
724
+ delete hooks.worktrust;
725
+ writeAgyHooks(hooks);
726
+ return true;
514
727
  }
515
728
 
516
- async function history() {
517
- const key = await freshKey();
518
- if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
519
- await offerHistory(await scripts(), key.token, key.device ?? null, key.url);
729
+ /** Disconnect takes WorkTrust's notify line out, never another program's: the hook it named is about to be deleted. */
730
+ function removeCodexNotify() {
731
+ const found = codexNotify();
732
+ if (!found.text || !isOurNotify(found.text)) return false;
733
+ const rows = [...found.rows];
734
+ rows.splice(found.at, found.end - found.at + 1);
735
+ writeCodex(rows);
736
+ return true;
520
737
  }
521
738
 
522
739
  async function disconnect() {
@@ -527,16 +744,20 @@ async function disconnect() {
527
744
  const plan = await run(paths["setup-mcp.mjs"], ["--remove"], PLACEHOLDER, true);
528
745
  say(" 1. The WorkTrust door comes out of every AI app here:");
529
746
  for (const line of planLines(plan.out)) say(line);
530
- say(" 2. The session hook comes out of ~/.claude/settings.json, the key file is deleted, and ~/.worktrust is emptied.");
747
+ say(" 2. The session hook comes out of ~/.claude/settings.json (and Codex's notify line and Antigravity's worktrust hook, when WorkTrust set them), the key file is deleted, and ~/.worktrust is emptied");
748
+ say(" but for one note of which account this computer fed (masked), so a later coupling by another account starts clean.");
531
749
  say(" 3. The key itself stays valid until you revoke it: WorkTrust → Sources → Devices → this computer → Revoke.");
532
750
  say();
533
751
  if (has("dry-run")) { say(" Dry run: nothing was changed."); return; }
534
752
  if (!(await ask("Continue?"))) { say(" Stopped. Nothing was changed."); return; }
535
753
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], PLACEHOLDER);
536
754
  await run(paths["log-session.mjs"], ["--uninstall", "--purge"], PLACEHOLDER);
755
+ if (removeCodexNotify()) say(" ✓ Codex no longer wakes the session hook.");
756
+ if (removeAntigravityHook()) say(" ✓ Antigravity no longer wakes the session hook.");
757
+ previousNote.save(keyStore.load());
537
758
  keyStore.erase();
538
759
  // Everything of ours on this computer goes; the folder too, when nothing else is left in it.
539
- for (const name of [STABLE, ...SCRIPTS.map((script) => join(HOME_DIR, `${script}.download.mjs`)), join(HOME_DIR, "pinned")]) { try { rmSync(name); } catch { /* gone */ } }
760
+ for (const name of [STABLE, ...SCRIPTS.map((script) => join(HOME_DIR, downloaded(script))), join(HOME_DIR, "pinned")]) { try { rmSync(name); } catch { /* gone */ } }
540
761
  try { rmSync(HOME_DIR, { recursive: false }); } catch { /* not empty, or gone */ }
541
762
  say();
542
763
  say(" Done. Revoke the key in WorkTrust to finish.");
@@ -553,9 +774,13 @@ function status() {
553
774
  say(door ? ` Claude Code: coupled (${door[1].url ?? "through the local bridge"})` : " Claude Code: not coupled. Run `npx worktrust connect`.");
554
775
  const key = keyStore.load();
555
776
  say(key ? ` Key: in ${KEY_PLACE}, ${key.device ? "bound to this computer, " : ""}last renewed ${String(key.renewedAt ?? "").slice(0, 10) || "never"}` : " Key: not on this computer (or written into the apps' settings with --direct)");
777
+ if (key) say(key.account ? ` Account: ${key.account}${key.coupledAt ? `, coupled ${String(key.coupledAt).slice(0, 10)}` : ""}` : " Account: not recorded (coupled by an older version; `npx worktrust connect` records it)");
556
778
  let hook = false;
557
779
  try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
558
780
  say(hook ? " Session hook: installed" : " Session hook: not installed");
781
+ if (existsSync(join(homedir(), ".codex"))) { const notify = codexNotify().text ?? ""; say(isOurNotify(notify) ? " Codex: wakes the session hook" : " Codex: does not wake the session hook (npx worktrust@latest update asks)"); }
782
+ if (existsSync(join(homedir(), ".hermes"))) { let text = ""; try { text = readFileSync(join(homedir(), ".hermes", "config.yaml"), "utf8"); } catch { /* none */ } say(/worktrust\.mjs\\?" hook --hermes/.test(text) ? " Hermes Agent: wakes the session hook" : " Hermes Agent: does not wake the session hook (npx worktrust@latest update writes it)"); }
783
+ if (antigravityHere()) say(agyHooks()?.worktrust ? " Antigravity: wakes the session hook" : " Antigravity: does not wake the session hook (npx worktrust@latest update asks)");
559
784
  say(existsSync(join(HOME_DIR, "pinned")) ? " Counter: from the package, pinned" : existsSync(join(HOME_DIR, "count-behaviour.mjs")) ? " Counter: follows the site" : " Counter: not installed");
560
785
  }
561
786
 
@@ -569,11 +794,10 @@ function help() {
569
794
  say(" --device no browser here (SSH): type a code on another device");
570
795
  say(" --name <name> the name this computer gets (default: its host name)");
571
796
  say(" --direct put the key in each app's settings instead of the key file");
572
- say(" worktrust connect couple again, also when this computer is already coupled");
797
+ say(" worktrust connect couple again, also when this computer is already coupled (asks first)");
798
+ say(" --replace yes in advance to coupling a computer that is already coupled");
799
+ say(" worktrust update this computer onto the newest CLI: same key, no new pairing");
573
800
  say(" worktrust history send this computer's earlier sessions as history (asks first)");
574
- say(" worktrust web connect Claude or ChatGPT on the web: copies the address, opens the page");
575
- say(" worktrust import import your earlier Claude or ChatGPT conversations: copies the prompt, opens a chat");
576
- say(" worktrust sources connect GitHub, Vercel, Supabase or Hugging Face: opens each approval page");
577
801
  say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
578
802
  say(" worktrust status what is coupled here");
579
803
  }
@@ -585,10 +809,17 @@ if (command === "default") { if (keyStore.load()) { status(); say(); say(" This
585
809
  else if (command === "mcp") await bridge();
586
810
  else if (command === "hook") await hook();
587
811
  else if (command === "connect") await connect();
812
+ else if (command === "update") await update();
588
813
  else if (command === "history") await history();
589
- else if (command === "web") await offerWeb();
590
- else if (command === "import") await importConversations();
591
- else if (command === "sources") await offerSources();
814
+ // NOT FROM THE TERMINAL (owner, 2026-10-04): an ACCOUNT is connected under Sources in the app, where the
815
+ // page also says what is already connected: Claude and ChatGPT on the web and their earlier
816
+ // conversations, GitHub, Vercel, Supabase and Hugging Face. The CLI does what happens on this
817
+ // computer. The old words still answer, with the address.
818
+ else if (command === "sources" || command === "web" || command === "import") { say(); say(` Claude and ChatGPT on the web, their earlier conversations, GitHub, Vercel, Supabase and Hugging Face`); say(` are connected in WorkTrust, under Sources: ${new URL("/sources", ORIGIN)}`); }
819
+ // CODEX IS ASKED BY CONNECT AND BY UPDATE (owner, 2026-10-04): a person knows those two. `codex` stays, unlisted,
820
+ // as plumbing like `mcp` and `hook`: the one question alone, which is also what check-cli-package runs on real configs.
821
+ else if (command === "codex") await offerCodex();
822
+ else if (command === "antigravity") await offerAntigravity();
592
823
  else if (command === "disconnect") await disconnect();
593
824
  else if (command === "status") status();
594
825
  else help();