worktrust 0.6.6 → 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,16 +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
13
  * npx worktrust disconnect show what comes out, ask, take it out again
15
14
  * npx worktrust status what is coupled here
16
15
  *
17
16
  * WHAT RUNS. From the npm package, everything that runs is in the package: this file,
18
17
  * `setup-mcp.mjs` (gives every AI app here the WorkTrust door), `log-session.mjs` (the session hook)
19
- * and `count-behaviour.mjs` (the local counter, pinned: never replaced from the network). Nothing is
20
- * 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
21
21
  * same site, and says so before it asks.
22
22
  *
23
23
  * HOW THE ACCOUNT IS DECIDED. By default this terminal listens on 127.0.0.1 for one answer and holds
@@ -52,7 +52,7 @@
52
52
  import { spawn } from "node:child_process";
53
53
  import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
54
54
  import { createServer } from "node:http";
55
- 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";
56
56
  import { homedir, hostname, platform } from "node:os";
57
57
  import { dirname, join } from "node:path";
58
58
  import { createInterface } from "node:readline/promises";
@@ -64,11 +64,15 @@ const VALUED = ["--name", "--origin", "--url"];
64
64
  const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALUED.includes(args[at - 1]))) ?? (args.includes("--help") ? "help" : "default");
65
65
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
66
66
  const has = (name) => args.includes(`--${name}`);
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). */
68
+ const CLI_VERSION = "0.6.9";
67
69
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
68
70
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
69
71
  const HOME_DIR = join(homedir(), ".worktrust");
70
72
  const HERE = dirname(fileURLToPath(import.meta.url));
71
- 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`);
72
76
  const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
73
77
  const PLACEHOLDER = `wt_${"0".repeat(43)}`;
74
78
  const say = (line = "") => process.stdout.write(`${line}\n`);
@@ -91,7 +95,7 @@ async function scripts() {
91
95
  for (const name of SCRIPTS) {
92
96
  const response = await fetch(`${ORIGIN}/counter/${name}`);
93
97
  if (!response.ok) fail(`could not download ${name} (${response.status}).`);
94
- paths[name] = join(HOME_DIR, `${name}.download.mjs`);
98
+ paths[name] = join(HOME_DIR, downloaded(name));
95
99
  writeFileSync(paths[name], await response.text(), { mode: 0o600 });
96
100
  }
97
101
  return paths;
@@ -178,9 +182,16 @@ async function pairByBrowser(host, extra) {
178
182
  say(` ✓ Approved as "${exchange.json.label}"${exchange.json.account ? ` for the WorkTrust account ${exchange.json.account}` : ""}`);
179
183
  if (exchange.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
180
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.");
181
- return exchange.json.token;
185
+ return paired(exchange.json);
182
186
  }
183
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
+
184
195
  /** WITHOUT A BROWSER: a code you type yourself on another device. Never a link with the code in it. */
185
196
  async function pairByCode(host, extra) {
186
197
  const pairing = await post("/api/cli/pair", { host, os: OS, flow: "device", ...extra }).catch(() => fail(`cannot reach ${ORIGIN}.`));
@@ -199,7 +210,7 @@ async function pairByCode(host, extra) {
199
210
  if (answer.status === 200 && answer.json.token) {
200
211
  say(` ✓ Approved as "${answer.json.label}"${answer.json.account ? ` for the WorkTrust account ${answer.json.account}` : ""}`);
201
212
  if (answer.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
202
- return answer.json.token;
213
+ return paired(answer.json);
203
214
  }
204
215
  if (answer.json.error === "slow_down") wait += 2000;
205
216
  else if (answer.json.error === "expired_token") fail("the code expired. Run `worktrust connect` again.");
@@ -242,6 +253,38 @@ const keyStore = {
242
253
  erase() { try { rmSync(KEY_FILE); } catch { /* gone */ } },
243
254
  };
244
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
+
245
288
  /** `WorkTrust-Proof` for one request: seconds, a one-time nonce, and the Ed25519 signature over method, path, both and the body's hash. */
246
289
  function proof(device, url, body) {
247
290
  if (!device) return {};
@@ -325,7 +368,7 @@ async function bridge() {
325
368
  if (!line.trim()) continue;
326
369
  let message;
327
370
  try { message = JSON.parse(line); } catch { continue; }
328
- if (message.method === "initialize" && message.params?.clientInfo?.name) agent = `${message.params.clientInfo.name}/${message.params.clientInfo.version ?? "0"} (worktrust-bridge)`;
371
+ if (message.method === "initialize" && message.params?.clientInfo?.name) agent = `${message.params.clientInfo.name}/${message.params.clientInfo.version ?? "0"} (worktrust-bridge) worktrust-cli/${CLI_VERSION}`;
329
372
  if (!key) { if (message.id !== undefined) answer(message.id, "WorkTrust is not connected on this computer: run `npx worktrust connect`."); continue; }
330
373
  try {
331
374
  const response = await fetch(key.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${key.token}`, "user-agent": agent, ...(session ? { "mcp-session-id": session } : {}), ...proof(key.device, key.url, line) }, body: line });
@@ -351,17 +394,92 @@ async function bridge() {
351
394
 
352
395
  /** THE HOOK Claude Code runs (`worktrust.mjs hook`): the key from the key file, handed to the session hook. */
353
396
  async function hook() {
397
+ if (has("antigravity-stop")) return antigravityStop();
354
398
  const key = await freshKey();
355
399
  if (!key) process.exit(0);
356
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 } : {}) } });
357
401
  child.on("exit", (code) => process.exit(code ?? 0));
358
402
  }
359
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
+
360
467
  async function connect() {
361
468
  const host = (flag("name") ?? hostname()).replace(/\.local$/i, "").slice(0, 64);
362
469
  say();
363
470
  say(` WorkTrust · connect ${host} (${OS_NAME})`);
364
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
+ }
365
483
  if (!BUNDLED) say(` The three scripts that do the work are downloaded from ${ORIGIN} first. From npm they come in the package.\n`);
366
484
  const paths = await scripts();
367
485
  const direct = has("direct");
@@ -387,26 +505,37 @@ async function connect() {
387
505
  const device = direct ? null : generateKeyPairSync("ed25519");
388
506
  const apps = foundApps.map((line) => /^\s*[↻+✓] ([^:]+):/.exec(line)?.[1]).filter(Boolean).slice(0, 12);
389
507
  const extra = { apps, ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
390
- 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);
391
512
 
392
513
  say();
393
514
  // Out first, then in: an app that already held a WorkTrust key moves to the new one.
394
515
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], token, true);
395
516
  if (!direct) {
396
- 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();
397
519
  ensureHome();
398
520
  copyFileSync(fileURLToPath(import.meta.url), STABLE);
399
521
  say(` ✓ Key kept in ${KEY_PLACE}`);
400
522
  }
401
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.");
402
524
  say();
403
- const hookArgs = ["--install", ...(BUNDLED ? ["--counter", paths["count-behaviour.mjs"]] : []), ...(direct ? [] : ["--via", STABLE])];
404
- if ((await run(paths["log-session.mjs"], hookArgs, token)).code !== 0) fail("installing the session hook failed; the output above says where.");
405
- await offerHistory(paths, token, device ? device.privateKey.export({ format: "pem", type: "pkcs8" }) : null);
406
- await offerWeb();
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);
407
536
  say();
408
537
  say(" Done. Quit your AI apps completely and open them again. This computer appears in WorkTrust");
409
- say(" under Sources → Devices once one of them has spoken.");
538
+ say(" under Sources → CLI once one of them has spoken.");
410
539
  }
411
540
 
412
541
  /**
@@ -421,75 +550,131 @@ async function offerHistory(paths, token, devicePem, url = MCP) {
421
550
  let found = null;
422
551
  try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
423
552
  say();
424
- if (!found?.sessions) { say(" No earlier Claude Code or Codex sessions on this computer to send."); return; }
425
- 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}.`);
426
555
  if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
427
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; }
428
557
  const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
429
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.");
430
559
  }
431
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
+
432
567
  /**
433
- * CLAUDE AND CHATGPT ON THE WEB, FROM HERE (owner, 2026-10-04). A web connector lives in the person's
434
- * own account at Anthropic or OpenAI, and neither lets a program add one: the person adds it and
435
- * approves it there. What this can do is the rest: copy the door's address, open the right page,
436
- * 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.
437
574
  */
438
- /** The app's own first-run import prompt (sources.tables.prompts.startEn), word for word; check-cli-package holds them equal. */
439
- 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.';
440
- const WEB = [
441
- { 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"] },
442
- { 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"] },
443
- ];
444
- function copyToClipboard(text) {
445
- const [cmd, cmdArgs] = OS === "macos" ? ["pbcopy", []] : OS === "windows" ? ["clip", []] : ["xclip", ["-selection", "clipboard"]];
446
- return new Promise((resolve) => {
447
- try {
448
- const child = spawn(cmd, cmdArgs, { stdio: ["pipe", "ignore", "ignore"] });
449
- child.on("error", () => resolve(false)).on("exit", (code) => resolve(code === 0));
450
- child.stdin.end(text);
451
- } catch { resolve(false); }
452
- });
453
- }
454
- async function offerWeb() {
455
- if (has("dry-run")) return;
456
- say();
457
- for (const web of WEB) {
458
- if (!(await ask(`Also connect ${web.name} in your browser?`, false))) continue;
459
- const copied = await copyToClipboard(MCP);
460
- say(` ${copied ? "✓ Copied" : "The address is"} ${MCP}${copied ? " to your clipboard" : ""}`);
461
- say(` Opening ${web.page}. There:`);
462
- web.steps.forEach((step, i) => say(` ${i + 1}. ${step}`));
463
- openBrowser(web.page);
464
- 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(); }
465
- // EARLIER CONVERSATIONS, SO NOBODY GETS LOST IN THE IMPORT (owner, 2026-10-04): the app's own
466
- // prompt, copied, and a new conversation opened to paste it in. Only on a typed yes.
467
- 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
+ }
468
590
  }
591
+ return { rows, at: -1, end: -1, insert: rows.at(-1) === "" ? rows.length - 1 : rows.length };
469
592
  }
470
593
 
471
- /** The import prompt for one product: copied (or printed when there is no clipboard), and a new conversation opened. */
472
- async function handOverPrompt(web) {
473
- const text = IMPORT_PROMPT.replace("{client}", web.client);
474
- say(` ${(await copyToClipboard(text)) ? "✓ The import prompt is on your clipboard" : `The prompt:\n\n${text}\n`}`);
475
- say(` Opening a new conversation in ${web.name}: paste it there and send. It asks before it sends anything, and only counts leave.`);
476
- 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"));
597
+ }
598
+
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();
606
+ say();
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;
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.");
477
623
  }
478
624
 
479
625
  /**
480
- * `npx worktrust import` (owner, 2026-10-04): the earlier conversations, later. For whoever skipped
481
- * 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.
482
632
  */
483
- 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();
484
651
  say();
485
- say(" Your earlier conversations come in through the WorkTrust connector in that product: connect it first if you have not (npx worktrust web).");
486
- 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;
487
668
  }
488
669
 
489
- async function history() {
490
- const key = await freshKey();
491
- if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
492
- 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;
493
678
  }
494
679
 
495
680
  async function disconnect() {
@@ -500,16 +685,20 @@ async function disconnect() {
500
685
  const plan = await run(paths["setup-mcp.mjs"], ["--remove"], PLACEHOLDER, true);
501
686
  say(" 1. The WorkTrust door comes out of every AI app here:");
502
687
  for (const line of planLines(plan.out)) say(line);
503
- 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.");
504
690
  say(" 3. The key itself stays valid until you revoke it: WorkTrust → Sources → Devices → this computer → Revoke.");
505
691
  say();
506
692
  if (has("dry-run")) { say(" Dry run: nothing was changed."); return; }
507
693
  if (!(await ask("Continue?"))) { say(" Stopped. Nothing was changed."); return; }
508
694
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], PLACEHOLDER);
509
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());
510
699
  keyStore.erase();
511
700
  // Everything of ours on this computer goes; the folder too, when nothing else is left in it.
512
- 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 */ } }
513
702
  try { rmSync(HOME_DIR, { recursive: false }); } catch { /* not empty, or gone */ }
514
703
  say();
515
704
  say(" Done. Revoke the key in WorkTrust to finish.");
@@ -526,9 +715,12 @@ function status() {
526
715
  say(door ? ` Claude Code: coupled (${door[1].url ?? "through the local bridge"})` : " Claude Code: not coupled. Run `npx worktrust connect`.");
527
716
  const key = keyStore.load();
528
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)");
529
719
  let hook = false;
530
720
  try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
531
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)");
532
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");
533
725
  }
534
726
 
@@ -542,10 +734,10 @@ function help() {
542
734
  say(" --device no browser here (SSH): type a code on another device");
543
735
  say(" --name <name> the name this computer gets (default: its host name)");
544
736
  say(" --direct put the key in each app's settings instead of the key file");
545
- 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");
546
740
  say(" worktrust history send this computer's earlier sessions as history (asks first)");
547
- say(" worktrust web connect Claude or ChatGPT on the web: copies the address, opens the page");
548
- say(" worktrust import import your earlier Claude or ChatGPT conversations: copies the prompt, opens a chat");
549
741
  say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
550
742
  say(" worktrust status what is coupled here");
551
743
  }
@@ -557,9 +749,17 @@ if (command === "default") { if (keyStore.load()) { status(); say(); say(" This
557
749
  else if (command === "mcp") await bridge();
558
750
  else if (command === "hook") await hook();
559
751
  else if (command === "connect") await connect();
752
+ else if (command === "update") await update();
560
753
  else if (command === "history") await history();
561
- else if (command === "web") await offerWeb();
562
- else if (command === "import") await importConversations();
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();
563
763
  else if (command === "disconnect") await disconnect();
564
764
  else if (command === "status") status();
565
765
  else help();