worktrust 0.6.7 → 0.6.9

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
@@ -53,7 +52,7 @@
53
52
  import { 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,14 @@ 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.9";
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"];
74
+ /** The readers are imported by name from beside the scripts, so a download keeps that name. */
75
+ const downloaded = (name) => (name === "transcript-readers.mjs" ? name : `${name}.download.mjs`);
75
76
  const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
76
77
  const PLACEHOLDER = `wt_${"0".repeat(43)}`;
77
78
  const say = (line = "") => process.stdout.write(`${line}\n`);
@@ -94,7 +95,7 @@ async function scripts() {
94
95
  for (const name of SCRIPTS) {
95
96
  const response = await fetch(`${ORIGIN}/counter/${name}`);
96
97
  if (!response.ok) fail(`could not download ${name} (${response.status}).`);
97
- paths[name] = join(HOME_DIR, `${name}.download.mjs`);
98
+ paths[name] = join(HOME_DIR, downloaded(name));
98
99
  writeFileSync(paths[name], await response.text(), { mode: 0o600 });
99
100
  }
100
101
  return paths;
@@ -181,9 +182,16 @@ async function pairByBrowser(host, extra) {
181
182
  say(` ✓ Approved as "${exchange.json.label}"${exchange.json.account ? ` for the WorkTrust account ${exchange.json.account}` : ""}`);
182
183
  if (exchange.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
183
184
  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;
185
+ return paired(exchange.json);
185
186
  }
186
187
 
188
+ /**
189
+ * What a pairing hands back: the key, its prefix (which names it to /api/cli/release), the account
190
+ * masked (to show), and `account_ref`, an opaque name for the account that is the same on every
191
+ * pairing of it, so a computer coupled again can tell "the same account" from "another one".
192
+ */
193
+ const paired = (json) => ({ token: json.token, prefix: typeof json.prefix === "string" ? json.prefix : null, account: json.account ?? null, accountRef: json.account_ref ?? null });
194
+
187
195
  /** WITHOUT A BROWSER: a code you type yourself on another device. Never a link with the code in it. */
188
196
  async function pairByCode(host, extra) {
189
197
  const pairing = await post("/api/cli/pair", { host, os: OS, flow: "device", ...extra }).catch(() => fail(`cannot reach ${ORIGIN}.`));
@@ -202,7 +210,7 @@ async function pairByCode(host, extra) {
202
210
  if (answer.status === 200 && answer.json.token) {
203
211
  say(` ✓ Approved as "${answer.json.label}"${answer.json.account ? ` for the WorkTrust account ${answer.json.account}` : ""}`);
204
212
  if (answer.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
205
- return answer.json.token;
213
+ return paired(answer.json);
206
214
  }
207
215
  if (answer.json.error === "slow_down") wait += 2000;
208
216
  else if (answer.json.error === "expired_token") fail("the code expired. Run `worktrust connect` again.");
@@ -245,6 +253,38 @@ const keyStore = {
245
253
  erase() { try { rmSync(KEY_FILE); } catch { /* gone */ } },
246
254
  };
247
255
 
256
+ /**
257
+ * WHICH ACCOUNT THIS COMPUTER LAST FED, after `disconnect` (owner, 2026-10-04). The key file goes; this
258
+ * small note stays (the masked address and the opaque account name, nothing secret), so that a later
259
+ * `connect` by ANOTHER account is recognised as a change of hands and offers none of the first
260
+ * person's earlier work. A successful `connect` deletes it.
261
+ */
262
+ const PREVIOUS_FILE = join(HOME_DIR, "previous.json");
263
+ const previousNote = {
264
+ 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 }); },
265
+ load() { try { return JSON.parse(readFileSync(PREVIOUS_FILE, "utf8")); } catch { return null; } },
266
+ erase() { try { rmSync(PREVIOUS_FILE); } catch { /* gone */ } },
267
+ };
268
+
269
+ /**
270
+ * THE OLD KEY ENDS WHEN THE COMPUTER IS COUPLED AGAIN (owner, 2026-10-04). Signed with the old key's own
271
+ * device key, which only this computer holds, and naming the new key by its prefix: WorkTrust ends the
272
+ * old one at once (an AI app still open on it is refused, reads the key file again and continues on the
273
+ * new key) and, when the two keys belong to different accounts, tells the old account its computer
274
+ * moved, without saying to whom.
275
+ */
276
+ async function releasePrevious(previous, nextPrefix) {
277
+ if (!previous?.token || !nextPrefix) return;
278
+ if (!previous.device) { say(" ! The previous key was not bound to this computer: end it in WorkTrust → Sources → Devices."); return; }
279
+ try {
280
+ const url = `${new URL(previous.url).origin}/api/cli/release`;
281
+ const body = JSON.stringify({ next_prefix: nextPrefix });
282
+ const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${previous.token}`, ...proof(previous.device, url, body) }, body });
283
+ if (response.ok) say(" ✓ The previous key ended; WorkTrust no longer accepts it.");
284
+ else say(` ! The previous key could not be ended (${response.status}): end it in WorkTrust → Sources → Devices.`);
285
+ } catch { say(" ! WorkTrust could not be reached to end the previous key: end it in WorkTrust → Sources → Devices."); }
286
+ }
287
+
248
288
  /** `WorkTrust-Proof` for one request: seconds, a one-time nonce, and the Ed25519 signature over method, path, both and the body's hash. */
249
289
  function proof(device, url, body) {
250
290
  if (!device) return {};
@@ -354,17 +394,92 @@ async function bridge() {
354
394
 
355
395
  /** THE HOOK Claude Code runs (`worktrust.mjs hook`): the key from the key file, handed to the session hook. */
356
396
  async function hook() {
397
+ if (has("antigravity-stop")) return antigravityStop();
357
398
  const key = await freshKey();
358
399
  if (!key) process.exit(0);
359
400
  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
401
  child.on("exit", (code) => process.exit(code ?? 0));
361
402
  }
362
403
 
404
+ /**
405
+ * ANTIGRAVITY'S STOP HOOK (`worktrust.mjs hook --antigravity-stop`, 2026-10-04). Antigravity runs it
406
+ * synchronously when its agent loop ends and reads its stdout as JSON whose `decision` is required: "continue"
407
+ * would send the agent back to work, any other value lets it stop (antigravity.google/docs/hooks/). So this
408
+ * prints `{"decision":"stop"}` FIRST, synchronously, changing nothing, and exits 0 at once: the sweep runs detached, as
409
+ * `hook --antigravity`, with the payload (ids, paths and the model name; no text) in its environment.
410
+ * Offline, or with no key, the sweep ends quietly; Antigravity never waits for it.
411
+ */
412
+ const STOP_ANSWER = { decision: "stop" };
413
+ async function antigravityStop() {
414
+ writeSync(1, `${JSON.stringify(STOP_ANSWER)}\n`);
415
+ const input = await new Promise((resolve) => {
416
+ let text = "";
417
+ if (process.stdin.isTTY) { resolve(text); return; }
418
+ const timer = setTimeout(() => resolve(text), 2000);
419
+ process.stdin.on("data", (chunk) => { text += chunk; });
420
+ process.stdin.on("end", () => { clearTimeout(timer); resolve(text); });
421
+ process.stdin.on("error", () => { clearTimeout(timer); resolve(text); });
422
+ });
423
+ 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 */ }
424
+ process.exit(0);
425
+ }
426
+
427
+ /**
428
+ * `npx worktrust update` (owner, 2026-10-04): THIS COMPUTER ONTO THE NEWEST CLI, WITHOUT PAIRING IT
429
+ * AGAIN. `connect` mints a new key and so a new device row to merge; an update keeps the key and the
430
+ * row. It writes what `connect` writes after its pairing: the bridge copy in ~/.worktrust, every
431
+ * WorkTrust entry in the AI apps taken out (a hand-made one with a key in it too) and the bridge put
432
+ * in, and the session hook. A computer coupled by hand, without the key file, is sent to `connect`.
433
+ */
434
+ async function update() {
435
+ const key = await freshKey();
436
+ if (!key) fail("this computer has no WorkTrust key file yet. Run npx worktrust@latest connect.");
437
+ say();
438
+ say(` WorkTrust · update ${hostname().replace(/\.local$/i, "")} to ${CLI_VERSION}`);
439
+ say();
440
+ const paths = await scripts();
441
+ const plan = await run(paths["setup-mcp.mjs"], ["--bridge", STABLE], PLACEHOLDER, true);
442
+ say(" Every AI app found here gets the WorkTrust door, the local command that reads the key:");
443
+ const found = planLines(plan.out, true).filter((line) => !line.includes("not on this machine"));
444
+ for (const line of found) say(line);
445
+ if (found.length === 0) say(" (no AI app found here)");
446
+ say(` The session hook and ${STABLE} are brought to ${CLI_VERSION}.`);
447
+ say(" The key stays as it is: no new pairing, the same computer in WorkTrust.");
448
+ say();
449
+ if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
450
+ if (!(await ask("Continue?"))) { say(" Stopped. Nothing was written."); return; }
451
+ ensureHome();
452
+ copyFileSync(fileURLToPath(import.meta.url), STABLE);
453
+ say(` ✓ ${STABLE} is now ${CLI_VERSION}`);
454
+ // Out first, then in: an app that held a key of its own (a hand-made entry) moves onto the bridge.
455
+ await run(paths["setup-mcp.mjs"], ["--remove", "--write"], key.token, true);
456
+ 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.");
457
+ const hookArgs = ["--install", ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), "--via", STABLE];
458
+ if ((await run(paths["log-session.mjs"], hookArgs, key.token, false)).code !== 0) fail("installing the session hook failed; the output above says where.");
459
+ // Codex installed since, or a No said at connect: the one question, asked again (or "already set").
460
+ await offerCodex();
461
+ await offerAntigravity();
462
+ say();
463
+ say(" Done. Quit your AI apps completely and open them again; the CLI version shows in WorkTrust");
464
+ say(" under Sources → CLI once one of them has spoken. A key an app held by hand still works until you end it there.");
465
+ }
466
+
363
467
  async function connect() {
364
468
  const host = (flag("name") ?? hostname()).replace(/\.local$/i, "").slice(0, 64);
365
469
  say();
366
470
  say(` WorkTrust · connect ${host} (${OS_NAME})`);
367
471
  say();
472
+ // ALREADY COUPLED: SAY TO WHOM, AND ASK (owner, 2026-10-04). Coupling again moves this computer to
473
+ // whichever account approves it next and ends the current key; Enter, --yes and a closed terminal
474
+ // mean No, `--replace` says yes in advance.
475
+ const previous = keyStore.load();
476
+ const before = previous ?? previousNote.load();
477
+ if (previous) {
478
+ 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).");
479
+ say(" Coupling it again sends its work to the account you approve next, and its current key ends.");
480
+ if (!has("replace") && !(await ask("Couple it again?", false))) { say(" Stopped. Nothing was changed: this computer stays coupled as it is."); return; }
481
+ say();
482
+ }
368
483
  if (!BUNDLED) say(` The three scripts that do the work are downloaded from ${ORIGIN} first. From npm they come in the package.\n`);
369
484
  const paths = await scripts();
370
485
  const direct = has("direct");
@@ -390,27 +505,37 @@ async function connect() {
390
505
  const device = direct ? null : generateKeyPairSync("ed25519");
391
506
  const apps = foundApps.map((line) => /^\s*[↻+✓] ([^:]+):/.exec(line)?.[1]).filter(Boolean).slice(0, 12);
392
507
  const extra = { apps, ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
393
- const token = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
508
+ const pairing = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
509
+ const { token } = pairing;
510
+ // ANOTHER ACCOUNT THAN BEFORE: told by the opaque account names. Unknown counts as another, the safe side.
511
+ const switched = Boolean(before) && !(before.accountRef && pairing.accountRef && before.accountRef === pairing.accountRef);
394
512
 
395
513
  say();
396
514
  // Out first, then in: an app that already held a WorkTrust key moves to the new one.
397
515
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], token, true);
398
516
  if (!direct) {
399
- keyStore.save({ url: MCP, token, renewedAt: new Date().toISOString(), device: device.privateKey.export({ format: "pem", type: "pkcs8" }) });
517
+ keyStore.save({ url: MCP, token, renewedAt: new Date().toISOString(), coupledAt: new Date().toISOString(), account: pairing.account, accountRef: pairing.accountRef, device: device.privateKey.export({ format: "pem", type: "pkcs8" }) });
518
+ previousNote.erase();
400
519
  ensureHome();
401
520
  copyFileSync(fileURLToPath(import.meta.url), STABLE);
402
521
  say(` ✓ Key kept in ${KEY_PLACE}`);
403
522
  }
404
523
  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
524
  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();
525
+ // The old key ends now that the new one is in place (never before: a failed pairing must not leave the computer uncoupled).
526
+ if (previous?.token) await releasePrevious(previous, pairing.prefix);
527
+ // A NEW COUPLING SENDS NOTHING FROM BEFORE IT (log-session THE FLOORS): `--from-now` for a first coupling and a change
528
+ // of account; the same account coupled again keeps its watermarks. A change of account also closes the history offer.
529
+ const floors = !previous || switched ? ["--from-now", ...(switched ? ["--history-floor", "now"] : [])] : [];
530
+ const hookArgs = ["--install", ...floors, ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), ...(direct ? [] : ["--via", STABLE])];
531
+ 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.");
532
+ await offerCodex();
533
+ await offerAntigravity();
534
+ 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."); }
535
+ else await offerHistory(paths, token, device ? device.privateKey.export({ format: "pem", type: "pkcs8" }) : null);
411
536
  say();
412
537
  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.");
538
+ say(" under Sources → CLI once one of them has spoken.");
414
539
  }
415
540
 
416
541
  /**
@@ -425,98 +550,131 @@ async function offerHistory(paths, token, devicePem, url = MCP) {
425
550
  let found = null;
426
551
  try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
427
552
  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}.`);
553
+ if (!found?.sessions) { say(" No earlier Claude Code, Codex or Antigravity sessions on this computer to send."); return; }
554
+ say(` This computer holds ${found.sessions} earlier Claude Code / Codex / Antigravity sessions · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
430
555
  if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
431
556
  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
557
  const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
433
558
  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
559
  }
435
560
 
561
+ async function history() {
562
+ const key = await freshKey();
563
+ if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
564
+ await offerHistory(await scripts(), key.token, key.device ?? null, key.url);
565
+ }
566
+
436
567
  /**
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.
568
+ * CODEX WAKES THE HOOK ON A YES (owner, 2026-10-04). Codex has no hook settings: it runs ONE `notify`
569
+ * program at the end of every turn, named on a top-level line of ~/.codex/config.toml. Without it
570
+ * Codex sessions are still swept whenever the Claude Code hook runs; with it they arrive when Codex
571
+ * works alone. Asked by `connect` and by `update`; Enter, --yes and a closed terminal mean No; `--codex` says yes in advance. Only a
572
+ * WorkTrust line is ever replaced or removed: another program's notify belongs to the person, and
573
+ * Codex takes one. The file is kept beside itself before it is changed.
441
574
  */
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);
575
+ const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
576
+ const isOurNotify = (text) => /--codex-notify/.test(text) && /worktrust|log-session\.mjs/i.test(text);
577
+
578
+ /** The top-level `notify = [...]` of a Codex config (it may span lines), or where one would go: before the first table. */
579
+ function codexNotify() {
580
+ let toml = "";
581
+ try { toml = readFileSync(CODEX_CONFIG, "utf8"); } catch { /* none yet */ }
582
+ const rows = toml === "" ? [] : toml.split("\n");
583
+ for (let at = 0; at < rows.length; at += 1) {
584
+ if (/^\s*\[/.test(rows[at])) return { rows, at: -1, end: -1, insert: at };
585
+ if (/^\s*notify\s*=/.test(rows[at])) {
586
+ let end = at;
587
+ while (!rows[end].includes("]") && end < rows.length - 1) end += 1;
588
+ return { rows, at, end, text: rows.slice(at, end + 1).join("\n") };
589
+ }
472
590
  }
591
+ return { rows, at: -1, end: -1, insert: rows.at(-1) === "" ? rows.length - 1 : rows.length };
473
592
  }
474
593
 
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);
594
+ function writeCodex(rows) {
595
+ if (existsSync(CODEX_CONFIG)) copyFileSync(CODEX_CONFIG, `${CODEX_CONFIG}.worktrust-backup`);
596
+ writeFileSync(CODEX_CONFIG, rows.join("\n"));
481
597
  }
482
598
 
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;
599
+ async function offerCodex() {
600
+ if (!existsSync(join(homedir(), ".codex"))) { if (command === "codex") say(" Codex is not on this computer (no ~/.codex)."); return; }
601
+ const viaBridge = existsSync(STABLE);
602
+ const hookFile = viaBridge ? STABLE : join(HOME_DIR, "log-session.mjs");
603
+ if (!existsSync(hookFile)) { say(" Couple this computer first: npx worktrust"); return; }
604
+ const want = `notify = [${(viaBridge ? [NODE, STABLE, "hook", "--codex-notify"] : [NODE, hookFile, "--codex-notify"]).map((part) => JSON.stringify(part)).join(", ")}]`;
605
+ const found = codexNotify();
496
606
  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(); }
607
+ if (found.text === want) { say(" ✓ Codex already wakes the session hook at the end of every turn."); return; }
608
+ if (found.text && !isOurNotify(found.text)) {
609
+ say(" Codex already runs another program at the end of each turn (notify in ~/.codex/config.toml).");
610
+ say(" Codex takes one, so it stays as it is; Codex sessions are still sent whenever the Claude Code hook runs.");
611
+ return;
503
612
  }
613
+ say(" Codex can wake the session hook at the end of every turn, so Codex work arrives without Claude Code running:");
614
+ say(` ${found.text ? "↻" : "+"} ${CODEX_CONFIG} → ${want}`);
615
+ if (has("dry-run")) { say(" Dry run: nothing was written."); return; }
616
+ if (!has("codex") && !(await ask("Let Codex wake the session hook?", false))) { say(" Not set. npx worktrust@latest update asks again."); return; }
617
+ const rows = [...found.rows];
618
+ if (found.text) rows.splice(found.at, found.end - found.at + 1, want);
619
+ else rows.splice(found.insert, 0, ...(found.insert < rows.length && rows[found.insert] !== "" ? [want, ""] : [want]));
620
+ if (rows.at(-1) !== "") rows.push("");
621
+ writeCodex(rows);
622
+ say(" ✓ Codex wakes the session hook. Restart Codex to pick it up; the earlier file is kept as config.toml.worktrust-backup.");
504
623
  }
505
624
 
506
625
  /**
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.
626
+ * ANTIGRAVITY WAKES THE HOOK ON A YES (owner, 2026-10-04), the way Codex does. Antigravity (the 2.0 app,
627
+ * the `agy` CLI, the IDE) reads named hooks from ~/.gemini/config/hooks.json, each with its events
628
+ * (antigravity.google/docs/hooks/). This adds one hook named `worktrust` with a single Stop command, merged
629
+ * into whatever else the file holds, the file kept beside itself first. Enter, --yes and a closed
630
+ * terminal mean No; `--antigravity` says yes in advance. Without it, Antigravity conversations are still
631
+ * swept whenever another client's hook runs.
509
632
  */
510
- async function importConversations() {
633
+ const AGY_HOOKS = join(homedir(), ".gemini", "config", "hooks.json");
634
+ const antigravityHere = () => ["config", ...readdirSafe(join(homedir(), ".gemini")).filter((name) => /^antigravity/.test(name))].some((name) => existsSync(join(homedir(), ".gemini", name)));
635
+ function readdirSafe(dir) { try { return readdirSync(dir); } catch { return []; } }
636
+ /** The hooks file as an object; null when it exists and is not JSON, which is never overwritten. */
637
+ function agyHooks() {
638
+ if (!existsSync(AGY_HOOKS)) return {};
639
+ try { const parsed = JSON.parse(readFileSync(AGY_HOOKS, "utf8") || "{}"); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; } catch { return null; }
640
+ }
641
+ function writeAgyHooks(hooks) {
642
+ mkdirSync(dirname(AGY_HOOKS), { recursive: true });
643
+ if (existsSync(AGY_HOOKS)) copyFileSync(AGY_HOOKS, `${AGY_HOOKS}.worktrust-backup`);
644
+ writeFileSync(AGY_HOOKS, `${JSON.stringify(hooks, null, 2)}\n`);
645
+ }
646
+ async function offerAntigravity() {
647
+ if (!antigravityHere()) { if (command === "antigravity") say(" Antigravity is not on this computer (no ~/.gemini/config or ~/.gemini/antigravity*)."); return; }
648
+ if (!existsSync(STABLE)) { say(" Couple this computer first: npx worktrust"); return; }
649
+ const want = { enabled: true, Stop: [{ type: "command", command: `"${NODE}" "${STABLE}" hook --antigravity-stop`, timeout: 10 }] };
650
+ const hooks = agyHooks();
511
651
  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);
652
+ 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; }
653
+ if (JSON.stringify(hooks.worktrust) === JSON.stringify(want)) { say(" ✓ Antigravity already wakes the session hook when its agent stops."); return; }
654
+ say(" Antigravity can wake the session hook when its agent stops, so Antigravity work arrives without Claude Code running:");
655
+ say(` ${hooks.worktrust ? "↻" : "+"} ${AGY_HOOKS} → worktrust: Stop runs ${want.Stop[0].command}`);
656
+ if (has("dry-run")) { say(" Dry run: nothing was written."); return; }
657
+ if (!has("antigravity") && !(await ask("Let Antigravity wake the session hook?", false))) { say(" Not set. npx worktrust@latest update asks again."); return; }
658
+ writeAgyHooks({ ...hooks, worktrust: want });
659
+ 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" : ""}.`);
660
+ }
661
+ /** Disconnect takes the hook named worktrust out of Antigravity's hooks, and nothing else. */
662
+ function removeAntigravityHook() {
663
+ const hooks = agyHooks();
664
+ if (!hooks?.worktrust) return false;
665
+ delete hooks.worktrust;
666
+ writeAgyHooks(hooks);
667
+ return true;
514
668
  }
515
669
 
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);
670
+ /** Disconnect takes WorkTrust's notify line out, never another program's: the hook it named is about to be deleted. */
671
+ function removeCodexNotify() {
672
+ const found = codexNotify();
673
+ if (!found.text || !isOurNotify(found.text)) return false;
674
+ const rows = [...found.rows];
675
+ rows.splice(found.at, found.end - found.at + 1);
676
+ writeCodex(rows);
677
+ return true;
520
678
  }
521
679
 
522
680
  async function disconnect() {
@@ -527,16 +685,20 @@ async function disconnect() {
527
685
  const plan = await run(paths["setup-mcp.mjs"], ["--remove"], PLACEHOLDER, true);
528
686
  say(" 1. The WorkTrust door comes out of every AI app here:");
529
687
  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.");
688
+ 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");
689
+ say(" but for one note of which account this computer fed (masked), so a later coupling by another account starts clean.");
531
690
  say(" 3. The key itself stays valid until you revoke it: WorkTrust → Sources → Devices → this computer → Revoke.");
532
691
  say();
533
692
  if (has("dry-run")) { say(" Dry run: nothing was changed."); return; }
534
693
  if (!(await ask("Continue?"))) { say(" Stopped. Nothing was changed."); return; }
535
694
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], PLACEHOLDER);
536
695
  await run(paths["log-session.mjs"], ["--uninstall", "--purge"], PLACEHOLDER);
696
+ if (removeCodexNotify()) say(" ✓ Codex no longer wakes the session hook.");
697
+ if (removeAntigravityHook()) say(" ✓ Antigravity no longer wakes the session hook.");
698
+ previousNote.save(keyStore.load());
537
699
  keyStore.erase();
538
700
  // 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 */ } }
701
+ for (const name of [STABLE, ...SCRIPTS.map((script) => join(HOME_DIR, downloaded(script))), join(HOME_DIR, "pinned")]) { try { rmSync(name); } catch { /* gone */ } }
540
702
  try { rmSync(HOME_DIR, { recursive: false }); } catch { /* not empty, or gone */ }
541
703
  say();
542
704
  say(" Done. Revoke the key in WorkTrust to finish.");
@@ -553,9 +715,12 @@ function status() {
553
715
  say(door ? ` Claude Code: coupled (${door[1].url ?? "through the local bridge"})` : " Claude Code: not coupled. Run `npx worktrust connect`.");
554
716
  const key = keyStore.load();
555
717
  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)");
718
+ 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
719
  let hook = false;
557
720
  try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
558
721
  say(hook ? " Session hook: installed" : " Session hook: not installed");
722
+ 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)"); }
723
+ if (antigravityHere()) say(agyHooks()?.worktrust ? " Antigravity: wakes the session hook" : " Antigravity: does not wake the session hook (npx worktrust@latest update asks)");
559
724
  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
725
  }
561
726
 
@@ -569,11 +734,10 @@ function help() {
569
734
  say(" --device no browser here (SSH): type a code on another device");
570
735
  say(" --name <name> the name this computer gets (default: its host name)");
571
736
  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");
737
+ say(" worktrust connect couple again, also when this computer is already coupled (asks first)");
738
+ say(" --replace yes in advance to coupling a computer that is already coupled");
739
+ say(" worktrust update this computer onto the newest CLI: same key, no new pairing");
573
740
  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
741
  say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
578
742
  say(" worktrust status what is coupled here");
579
743
  }
@@ -585,10 +749,17 @@ if (command === "default") { if (keyStore.load()) { status(); say(); say(" This
585
749
  else if (command === "mcp") await bridge();
586
750
  else if (command === "hook") await hook();
587
751
  else if (command === "connect") await connect();
752
+ else if (command === "update") await update();
588
753
  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();
754
+ // NOT FROM THE TERMINAL (owner, 2026-10-04): an ACCOUNT is connected under Sources in the app, where the
755
+ // page also says what is already connected: Claude and ChatGPT on the web and their earlier
756
+ // conversations, GitHub, Vercel, Supabase and Hugging Face. The CLI does what happens on this
757
+ // computer. The old words still answer, with the address.
758
+ 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)}`); }
759
+ // CODEX IS ASKED BY CONNECT AND BY UPDATE (owner, 2026-10-04): a person knows those two. `codex` stays, unlisted,
760
+ // as plumbing like `mcp` and `hook`: the one question alone, which is also what check-cli-package runs on real configs.
761
+ else if (command === "codex") await offerCodex();
762
+ else if (command === "antigravity") await offerAntigravity();
592
763
  else if (command === "disconnect") await disconnect();
593
764
  else if (command === "status") status();
594
765
  else help();