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