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/README.md +34 -5
- package/count-behaviour.mjs +26 -97
- package/log-session.mjs +99 -112
- package/package.json +3 -1
- package/setup-mcp.mjs +40 -6
- package/transcript-readers.mjs +217 -0
- package/worktrust.mjs +269 -98
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)
|
|
21
|
-
*
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 →
|
|
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
|
|
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
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
say(
|
|
501
|
-
|
|
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
|
-
*
|
|
508
|
-
* the
|
|
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
|
-
|
|
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(
|
|
513
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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,
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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();
|