worktrust 0.6.9 → 0.6.11

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 CHANGED
@@ -45,6 +45,29 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
45
45
  single `Stop` command to `~/.gemini/config/hooks.json` (kept as `hooks.json.worktrust-backup`
46
46
  first). Other hooks in that file stay as they are. Antigravity records no token counts, so its
47
47
  sessions arrive with durations and turn counts only.
48
+ 7. **Hermes Agent with the other apps.** With Hermes on this computer (`~/.hermes`, and every profile
49
+ under `~/.hermes/profiles`) its `config.yaml` gets the WorkTrust entry under `mcp_servers` and one
50
+ `on_session_end` hook that wakes the session hook; Hermes itself asks once before it runs a new
51
+ hook. A file that writes those sections inline is left as it is. The session hook reads Hermes's
52
+ own `state.db`: clocks, roles, the model and the session's token totals, never a message. A chat
53
+ app (Telegram, WhatsApp, Slack …) is sent as work steered from elsewhere; a subagent's, a
54
+ scheduled job's or a board task's session is not counted. Needs Node 22.5 or newer.
55
+ 8. **Goose, OpenCode and OpenClaw the same way.** Goose gets the entry under `extensions` in
56
+ `~/.config/goose/config.yaml` and one plugin folder, `~/.agents/plugins/worktrust`, whose
57
+ SessionEnd hook wakes the session hook. OpenCode gets it under `mcp` in
58
+ `~/.config/opencode/opencode.json` (a file with comments is left as it is) and one plugin file,
59
+ `plugin/worktrust.js`, that wakes the session hook when a session goes idle. OpenClaw is coupled
60
+ through its own `openclaw mcp set` (and `openclaw mcp unset` on disconnect); without that command
61
+ on this computer it is printed for you to run. Each is read from its own session database, the
62
+ same way as Hermes: clocks, roles, models and token counts, never a message. OpenClaw's Talk
63
+ sessions are sent as voice, its chat-app sessions as remote; subagents and scheduled runs are
64
+ not counted.
65
+ 9. **Cursor and GitHub Copilot in VS Code, read in place.** Cursor's chats are read from its own database
66
+ (`User/globalStorage/state.vscdb`), each message's type, clock, model and token counts through SQLite's own
67
+ `json_extract`, never the text. Copilot's are read from VS Code's `chatSessions` files: each request's two clocks
68
+ (asked, answered), its model and its token counts; the messages and responses are parsed with the file and never
69
+ kept or sent. Neither app runs a hook WorkTrust can give it, so the WorkTrust door, which both start when they
70
+ open, starts the session hook in the background, at most once an hour.
48
71
 
49
72
  ```
50
73
  npx worktrust history send this computer's earlier sessions later (asks first)
@@ -77,8 +100,8 @@ which would delete every password on the computer, and many people rightly keep
77
100
 
78
101
  | | |
79
102
  |---|---|
80
- | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's own session files on this computer to measure durations and token counts. |
81
- | **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
103
+ | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files and the session databases of Hermes, Goose, OpenCode and OpenClaw on this computer to measure durations and token counts. |
104
+ | **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
82
105
  | **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
83
106
  | **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
84
107
  | **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch or project names. The door has no field for them and refuses a submission that carries them. |
@@ -115,7 +138,7 @@ which would delete every password on the computer, and many people rightly keep
115
138
 
116
139
  ## Undo
117
140
 
118
- `npx worktrust disconnect` removes the WorkTrust entries, the hook (and Codex's `notify` line and
141
+ `npx worktrust disconnect` removes the WorkTrust entries (Hermes's hook with them), the hook (and Codex's `notify` line and
119
142
  Antigravity's `worktrust` hook when WorkTrust set them) and the key file, and empties
120
143
  `~/.worktrust` but for one small note of which account it fed (masked), so that a later coupling by
121
144
  another account offers none of this computer's earlier work.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * CONFIG EDITS — a YAML file edited line by line, never re-serialised (2026-10-05).
3
+ *
4
+ * Hermes Agent and Goose keep their settings in YAML, written by people, with comments and their own indentation.
5
+ * setup-mcp.mjs puts WorkTrust's entry into them and takes it out again with these: a section is found by its key at
6
+ * a known indent, an entry is added at the indent its siblings use, and a removal leaves every other byte as it was.
7
+ * Only block style is handled; a section written inline (`key: { … }`) makes placeUnder return false, and the caller
8
+ * leaves the file alone. No dependency: it ships beside setup-mcp.mjs.
9
+ */
10
+ const indentOf = (line) => line.length - line.trimStart().length;
11
+ const meaningful = (line) => line.trim() !== "" && !line.trimStart().startsWith("#");
12
+ /** `key:` at `indent` between start and stop: its line and the end of its block, or the inline value it carries. */
13
+ function section(lines, start, stop, indent, key) {
14
+ for (let i = start; i < stop; i += 1) {
15
+ if (!meaningful(lines[i]) || indentOf(lines[i]) !== indent) continue;
16
+ const match = new RegExp(`^\\s*${key}:(?:\\s+(.*?))?\\s*$`).exec(lines[i]);
17
+ if (!match) continue;
18
+ const inline = match[1] && !match[1].startsWith("#") ? match[1] : null;
19
+ let end = i + 1;
20
+ while (end < stop && (!meaningful(lines[end]) || indentOf(lines[end]) > indent || (indentOf(lines[end]) === indent && lines[end].trimStart().startsWith("-")))) end += 1;
21
+ while (end > i + 1 && !meaningful(lines[end - 1])) end -= 1;
22
+ return { at: i, end, inline };
23
+ }
24
+ return null;
25
+ }
26
+ /** The entries of a block (a mapping's keys or a list's items), each [from, to). */
27
+ function entries(lines, at, end) {
28
+ const starts = [];
29
+ const first = lines.slice(at + 1, end).find(meaningful);
30
+ const child = first === undefined ? null : indentOf(first);
31
+ for (let i = at + 1; i < end; i += 1) if (meaningful(lines[i]) && indentOf(lines[i]) === child) starts.push(i);
32
+ return { child, ranges: starts.map((from, n) => [from, starts[n + 1] ?? end]) };
33
+ }
34
+ const EMPTY_INLINE = new Set(["{}", "[]", "null", "~"]);
35
+ /** Puts `unit` under the path, making each section that is missing; false when one is written inline. */
36
+ export function placeUnder(lines, path, unit) {
37
+ let start = 0, stop = lines.length, indent = 0;
38
+ for (const [n, key] of path.entries()) {
39
+ let found = section(lines, start, stop, indent, key);
40
+ if (found?.inline && !EMPTY_INLINE.has(found.inline)) return false;
41
+ if (found?.inline) lines[found.at] = `${" ".repeat(indent)}${key}:`;
42
+ if (!found) { lines.splice(stop, 0, `${" ".repeat(indent)}${key}:`); found = { at: stop, end: stop + 1 }; }
43
+ const child = entries(lines, found.at, found.end).child ?? indent + 2;
44
+ if (n === path.length - 1) { lines.splice(found.end, 0, ...unit.map((line) => " ".repeat(child) + line)); return true; }
45
+ start = found.at + 1; stop = found.end; indent = child;
46
+ }
47
+ return true;
48
+ }
49
+ /** The section at the end of a path, in block style; null when absent or inline. */
50
+ function find(lines, path) {
51
+ let start = 0, stop = lines.length, indent = 0, found = null;
52
+ for (const key of path) {
53
+ found = section(lines, start, stop, indent, key);
54
+ if (!found || found.inline) return null;
55
+ start = found.at + 1; stop = found.end; indent = entries(lines, found.at, found.end).child ?? indent + 2;
56
+ }
57
+ return found;
58
+ }
59
+ /** Takes out every entry under the path that is ours, and a section that leaves empty. */
60
+ export function takeOut(lines, path, mine) {
61
+ const found = find(lines, path);
62
+ if (!found) return;
63
+ let removed = 0;
64
+ for (const [from, to] of entries(lines, found.at, found.end).ranges.reverse()) if (mine(lines.slice(from, to).join("\n"))) { lines.splice(from, to - from); removed += 1; }
65
+ if (removed > 0) for (let depth = path.length; depth > 0; depth -= 1) {
66
+ const left = find(lines, path.slice(0, depth));
67
+ if (left && !lines.slice(left.at + 1, left.end).some(meaningful)) lines.splice(left.at, left.end - left.at);
68
+ }
69
+ }
package/log-session.mjs CHANGED
@@ -196,24 +196,26 @@ const excludes = (() => {
196
196
  })();
197
197
  const excluded = (path) => excludes.some((text) => text && path.includes(text));
198
198
  /**
199
- * THE READERS FOR CODEX AND ANTIGRAVITY live in transcript-readers.mjs beside this file (2026-10-04;
200
- * the counter imports the same file, so there is one reader, not two copies). The install copies it
201
- * into ~/.worktrust with this hook. Without it this hook still reads Claude Code; the never-matching
202
- * patterns below keep every other client out rather than half read.
199
+ * THE READERS live beside this file: Codex and Antigravity in transcript-readers.mjs (the counter imports it too, one
200
+ * reader), the database clients in session-databases.mjs. The install copies both into ~/.worktrust with this hook.
201
+ * Without them this hook still reads Claude Code; the never-matching patterns keep every other client out.
203
202
  */
204
203
  const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
205
- const READERS = join(dirname(fileURLToPath(import.meta.url)), "transcript-readers.mjs");
206
- const READERS_HOME = join(homedir(), ".worktrust", "transcript-readers.mjs");
204
+ const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
205
+ const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
206
+ const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
207
207
  /** The readers go where the hook and the counter run: copied from beside this file, else (or for a newer counter) from the deployment. */
208
208
  async function installReaders(origin, fromNetwork = false) {
209
- try {
210
- if (!fromNetwork && existsSync(READERS)) { mkdirSync(dirname(READERS_HOME), { recursive: true }); if (READERS !== READERS_HOME) copyFileSync(READERS, READERS_HOME); return true; }
211
- if (!origin) return false;
212
- const response = await fetch(`${origin}/counter/transcript-readers.mjs`);
213
- if (!response.ok) return false;
214
- writeFileSync(READERS_HOME, await response.text());
215
- return true;
216
- } catch { return false; }
209
+ let installed = true;
210
+ for (const name of READER_FILES) { // Hermes, Goose, OpenCode and OpenClaw are read by session-databases.mjs
211
+ const here = join(dirname(fileURLToPath(import.meta.url)), name), home = join(homedir(), ".worktrust", name);
212
+ try {
213
+ if (!fromNetwork && existsSync(here)) { mkdirSync(dirname(home), { recursive: true }); if (here !== home) copyFileSync(here, home); continue; }
214
+ const response = origin ? await fetch(`${origin}/counter/${name}`) : null;
215
+ if (response?.ok) writeFileSync(home, await response.text()); else installed = false;
216
+ } catch { installed = false; }
217
+ }
218
+ return installed;
217
219
  }
218
220
 
219
221
  /** The roots this machine's transcripts live under; a test names its own. */
@@ -228,6 +230,14 @@ function* codexRollouts() {
228
230
  yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
229
231
  }
230
232
  yield* antigravityConversations();
233
+ yield* databaseConversations();
234
+ }
235
+ /** The attended sessions of the clients that keep a database (session-databases.mjs); an excluded folder skipped. */
236
+ function* databaseConversations() {
237
+ const given = Object.fromEntries(DATABASE_CLIENTS.map((client) => [client, value(`${client}-root`)]).filter(([, root]) => root));
238
+ for (const { client, sessionId, file, cwd, writtenAt } of databaseSessions(given, Boolean(value("transcript-root")))) {
239
+ if (!(cwd && excluded(cwd))) yield { id: `${client}:${sessionId}`, file, writtenAt };
240
+ }
231
241
  }
232
242
  /** Antigravity beside Codex: one transcript per conversation, its folder known only from the Stop hook. */
233
243
  function* antigravityConversations() {
@@ -239,6 +249,7 @@ function* antigravityConversations() {
239
249
  }
240
250
  /** A transcript's lines as objects, whichever client wrote it; null when the file cannot be read. */
241
251
  const parsedLines = (file) => {
252
+ if (DATABASE_SESSION.test(file)) return databaseLines(file);
242
253
  let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
243
254
  if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
244
255
  if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
@@ -313,9 +324,10 @@ function stretchesOf(file, since) {
313
324
  const lines = parsedLines(file);
314
325
  if (!lines) return [];
315
326
  const messages = [];
316
- let cwd = null;
327
+ let cwd = null, steered = STEERED_HERE;
317
328
  for (const line of lines) {
318
329
  if (typeof line.cwd === "string") cwd = line.cwd;
330
+ if (typeof line.steered === "string") steered = line.steered; // a client that says how it was steered (Hermes's source)
319
331
  if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
320
332
  const at = Date.parse(line.timestamp);
321
333
  if (!Number.isFinite(at)) continue;
@@ -330,7 +342,7 @@ function stretchesOf(file, since) {
330
342
  if (!byDay.has(day)) byDay.set(day, []);
331
343
  byDay.get(day).push(message);
332
344
  }
333
- return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ofDay]) => measure(cwd, ofDay)).filter(Boolean);
345
+ return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered }; }).filter(Boolean);
334
346
  }
335
347
 
336
348
  /**
@@ -408,6 +420,11 @@ function tokensOf(messages) {
408
420
  return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
409
421
  }
410
422
 
423
+ // WHICH STRETCH, HOW STEERED (0.6.10): a hash of the session id (the file name) and the day, the same on any computer, so a
424
+ // restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
425
+ const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
426
+ const STEERED_HERE = process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ? "remote" : null;
427
+
411
428
  /** The one payload shape this script can build. Every key is a number, a date or an enum value. */
412
429
  function payloadFor(stretch) {
413
430
  const cwd = stretch.cwd && existsSync(stretch.cwd) ? stretch.cwd : null;
@@ -437,6 +454,7 @@ function payloadFor(stretch) {
437
454
  ...(stretch.model ? { model: stretch.model } : {}),
438
455
  ...(repo ? { repo } : {}),
439
456
  ...(touched.shas.length > 0 ? { commits: touched.shas.slice(0, 50) } : {}),
457
+ ...(stretch.ref ? { stretch_ref: stretch.ref } : {}), ...(stretch.steered ? { steered_from: stretch.steered } : {}),
440
458
  at: new Date(stretch.to).toISOString(),
441
459
  };
442
460
  }
@@ -453,7 +471,7 @@ async function sendHistory(door, entries, announce = false) {
453
471
  // A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
454
472
  // through the door given: the record files a line under the coupling that sent it. Antigravity's
455
473
  // go apart too, so their batch can name the client (clientOf).
456
- for (const [source, group] of [["claude", entries.filter((entry) => !entrySource.has(entry))], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")], ["antigravity", entries.filter((entry) => entrySource.get(entry) === "antigravity")]]) {
474
+ for (const [source, group] of [["claude", entries.filter((entry) => !entrySource.has(entry))], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")], ...["antigravity", ...DATABASE_CLIENTS].map((client) => [client, entries.filter((entry) => entrySource.get(entry) === client)])]) {
457
475
  const target = source === "codex" ? codexDoor ?? door : door;
458
476
  if (group.length === 0 || !target) continue;
459
477
  for (let i = 0; i < group.length; i += 200) {
@@ -484,7 +502,7 @@ const proofFor = (url, body) => {
484
502
  * on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
485
503
  * agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
486
504
  */
487
- const clientOf = (source) => (source === "antigravity" ? { "user-agent": "antigravity/hook (worktrust-hook)" } : {});
505
+ const clientOf = (source) => (source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source}/hook (worktrust-hook)` } : {});
488
506
 
489
507
  async function send(door, payload) {
490
508
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
@@ -512,10 +530,11 @@ function historyEntries(floor = null) {
512
530
  for (const { file } of allTranscripts()) {
513
531
  const lines = parsedLines(file);
514
532
  if (!lines) continue;
515
- let cwd = null;
533
+ let cwd = null, steered = null;
516
534
  const byDay = new Map();
517
535
  for (const line of lines) {
518
536
  if (typeof line.cwd === "string") cwd = line.cwd;
537
+ if (typeof line.steered === "string") steered = line.steered;
519
538
  if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
520
539
  const at = Date.parse(line.timestamp);
521
540
  if (!Number.isFinite(at)) continue;
@@ -543,13 +562,13 @@ function historyEntries(floor = null) {
543
562
  const entry = payloadFor({
544
563
  cwd, seconds: seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null,
545
564
  tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown, exchanges, model: [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null,
546
- from: messages[0].at, to: last,
565
+ from: messages[0].at, to: last, ref: stretchRef(file, day), steered,
547
566
  });
548
567
  if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
549
568
  if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
569
+ if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
550
570
  entryFile.set(entry, file);
551
571
  entries.push(entry);
552
- void day;
553
572
  }
554
573
  }
555
574
  return entries.sort((a, b) => a.at.localeCompare(b.at));
@@ -581,6 +600,7 @@ function* allTranscripts() {
581
600
  */
582
601
  function* candidates() {
583
602
  for (const rollout of codexRollouts()) {
603
+ if (rollout.writtenAt) { yield rollout; continue; } // a database session knows its own last moment
584
604
  let stat; try { stat = statSync(rollout.file); } catch { continue; }
585
605
  yield { ...rollout, writtenAt: stat.mtimeMs };
586
606
  }
@@ -630,7 +650,7 @@ if (flag("uninstall")) {
630
650
  }
631
651
  const removed = before !== JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
632
652
  if (removed) writeFileSync(file, JSON.stringify(settings, null, 2));
633
- if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE, READERS_HOME, join(homedir(), ".worktrust", "antigravity.json")]) { try { rmSync(path); } catch { /* already gone */ } }
653
+ if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE, ...READER_FILES.map((name) => join(homedir(), ".worktrust", name)), join(homedir(), ".worktrust", "antigravity.json")]) { try { rmSync(path); } catch { /* already gone */ } }
634
654
  console.log(`${removed ? "removed" : "nothing wired"}: ${file}${flag("purge") ? "\n purged ~/.worktrust" : ""}`);
635
655
  console.log("the coupling itself still exists: end it in the app (Sources → the computer's row → end this coupling)");
636
656
  process.exit(0);
@@ -698,7 +718,7 @@ if (flag("install")) {
698
718
  // The counting half, from the same deployment the hook came from: one command installs both, or
699
719
  // says plainly that it could not — a half nobody knows is missing is the worse outcome.
700
720
  const counterOrigin = coupling()?.url ? new URL(coupling().url).origin : null;
701
- if (!(await installReaders(counterOrigin))) console.log("readers: transcript-readers.mjs not found beside this file nor at the deployment; Codex and Antigravity are not read until it is");
721
+ if (!(await installReaders(counterOrigin))) console.log("readers: transcript-readers.mjs or session-databases.mjs not found beside this file nor at the deployment; the clients they read are not read until they are");
702
722
  if (existsSync(COUNTER)) await refreshCounter(counterOrigin, (line) => console.log(line));
703
723
  if (!existsSync(COUNTER)) {
704
724
  const origin = counterOrigin;
@@ -802,7 +822,7 @@ for (const entry of work) {
802
822
  // every day but today, on whichever run comes first — it no longer waits for its own end.
803
823
  if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
804
824
  const payload = payloadFor(stretch);
805
- const source = /^(codex|antigravity):/.exec(String(entry.id))?.[1];
825
+ const source = /^(codex|antigravity|hermes|goose|opencode|openclaw):/.exec(String(entry.id))?.[1];
806
826
  if (source) entrySource.set(payload, source);
807
827
  if (DRY) { console.log(JSON.stringify({ ...payload, client: source ?? "claude", route: Date.now() - stretch.to > WINDOW_HOURS * 3600_000 ? "history" : "log_work" }, null, 1)); through = stretch.to; continue; }
808
828
  // MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  "log-session.mjs",
13
13
  "count-behaviour.mjs",
14
14
  "transcript-readers.mjs",
15
+ "session-databases.mjs",
16
+ "config-edits.mjs",
15
17
  "README.md",
16
18
  "SECURITY.md",
17
19
  "LICENSE"
@@ -0,0 +1,448 @@
1
+ /**
2
+ * THE SESSION DATABASES — the clients that keep their sessions in SQLite, read into Claude Code's shape (2026-10-05).
3
+ *
4
+ * Hermes Agent, Goose, OpenCode and OpenClaw keep no transcript file: each writes its sessions to its own SQLite
5
+ * database. This reads them through node:sqlite (Node 22.5+), read-only, into the same lines transcript-readers.mjs
6
+ * makes of the files, so the session hook's one clock reads them all. Each query takes roles, clocks, models and token
7
+ * counts and no text column (scripts/test-database-clients.mjs holds every query to that). It ships beside the hook,
8
+ * like transcript-readers.mjs; without it, or without node:sqlite, these clients are not read and nothing else changes.
9
+ */
10
+ import { createRequire } from "node:module";
11
+ import { readFileSync, readdirSync, statSync } from "node:fs";
12
+ import * as zlib from "node:zlib"; // zstd since Node 22.15; older Node reads OpenClaw's uncompressed rows only
13
+ import { homedir, platform } from "node:os";
14
+ import { join } from "node:path";
15
+ import { cleanModel } from "./transcript-readers.mjs";
16
+
17
+ // ── hermes session reader ──
18
+ /**
19
+ * HERMES AGENT, READ FROM ITS OWN DATABASE (Nous Research, 2026-10-05). Hermes keeps every session in one SQLite file
20
+ * per profile: `state.db` under its home (~/.hermes, %LOCALAPPDATA%/hermes on Windows, HERMES_HOME when set) and under
21
+ * each profile in profiles/<name>/ (hermes-agent.nousresearch.com/docs/developer-guide/session-storage, /user-guide/profiles).
22
+ * A session is read as a transcript: each message's role and clock, never its `content`, which this query does not
23
+ * select. A user message is a human turn, a tool message the loop's own result. The session's token totals ride on its
24
+ * last assistant message (Hermes keeps them per session, not per response). `source` says how it was steered: the
25
+ * terminal or the desktop app is the desk, a chat app (Telegram, WhatsApp, Slack …) is the computer driven from
26
+ * elsewhere. A subagent's, a scheduled job's or a board task's session has no person at the controls and its time
27
+ * already lies inside its parent's: it is not read. Needs node:sqlite (Node 22.5+); without it Hermes is not read.
28
+ */
29
+ const HERMES_DESK = new Set(["cli", "desktop", "dashboard", "tui"]);
30
+ const HERMES_REMOTE = new Set(["telegram", "discord", "slack", "whatsapp", "signal", "matrix", "mattermost", "email", "sms", "homeassistant", "feishu", "dingtalk", "wecom", "weixin", "bluebubbles", "qqbot"]);
31
+ const HERMES_UNATTENDED = new Set(["subagent", "cron", "kanban"]);
32
+ export const hermesSteered = (source) => (HERMES_DESK.has(source) ? "desk" : HERMES_REMOTE.has(source) ? "remote" : null);
33
+ let sqlite;
34
+ /** A database session's pseudo-file, `<client>:<database>#<session id>`, back into its parts. */
35
+ const splitSession = (pseudo) => { const at = pseudo.lastIndexOf("#"); return { file: pseudo.slice(pseudo.indexOf(":") + 1, at), id: pseudo.slice(at + 1) }; };
36
+ const openDatabase = (file) => {
37
+ if (sqlite === undefined) {
38
+ // node:sqlite says it is experimental on some Node versions; that line is not the person's business inside a hook.
39
+ const warn = process.emitWarning;
40
+ try { process.emitWarning = () => {}; sqlite = createRequire(import.meta.url)("node:sqlite"); } catch { sqlite = null; } finally { process.emitWarning = warn; }
41
+ }
42
+ if (!sqlite) return null;
43
+ try { return new sqlite.DatabaseSync(file, { readOnly: true }); } catch { return null; }
44
+ };
45
+ /** Hermes's homes on this machine: the default, HERMES_HOME, and every profile's. A test names its own. */
46
+ export function hermesHomes(given) {
47
+ if (given) return [given.replace(/^~(?=\/|$)/, homedir())];
48
+ const base = platform() === "win32" && process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "hermes") : join(homedir(), ".hermes");
49
+ const homes = new Set([base, ...(process.env.HERMES_HOME ? [process.env.HERMES_HOME] : [])]);
50
+ try { for (const name of readdirSync(join(base, "profiles")).sort()) homes.add(join(base, "profiles", name)); } catch { /* no profiles */ }
51
+ return [...homes];
52
+ }
53
+ export const hermesHere = () => hermesHomes().some((home) => { try { return statSync(join(home, "config.yaml")).isFile() || statSync(join(home, "state.db")).isFile(); } catch { return false; } });
54
+ const columnsOf = (db, table) => new Set(db.prepare(`pragma table_info(${table})`).all().map((row) => row.name));
55
+ /** Every attended session under the homes: its id, its pseudo-file, its folder and its last message's moment. */
56
+ export function* hermesSessions(homes) {
57
+ for (const home of homes) {
58
+ const file = join(home, "state.db");
59
+ try { if (!statSync(file).isFile()) continue; } catch { continue; }
60
+ const db = openDatabase(file);
61
+ if (!db) continue;
62
+ try {
63
+ const has = columnsOf(db, "sessions");
64
+ const rows = db.prepare(`select s.id, s.source, s.parent_session_id as parent, ${has.has("cwd") ? "s.cwd" : "null"} as cwd, max(m.timestamp) as last
65
+ from sessions s join messages m on m.session_id = s.id group by s.id order by s.id`).all();
66
+ for (const row of rows) {
67
+ if (row.parent || HERMES_UNATTENDED.has(row.source) || !/^[A-Za-z0-9_.:-]{1,120}$/.test(String(row.id))) continue;
68
+ yield { sessionId: String(row.id), file: `hermes:${file}#${row.id}`, cwd: typeof row.cwd === "string" ? row.cwd : null, writtenAt: Number(row.last) * 1000 };
69
+ }
70
+ } catch { /* an older or foreign schema: not read */ } finally { db.close(); }
71
+ }
72
+ }
73
+ /** One session → lines in Claude Code's shape: roles, clocks, the model, the totals, the folder and the steering. No text. */
74
+ export function hermesLines(pseudo) {
75
+ const { file, id } = splitSession(pseudo);
76
+ const db = openDatabase(file);
77
+ if (!db) return null;
78
+ try {
79
+ const has = columnsOf(db, "sessions");
80
+ const pick = (name) => (has.has(name) ? `${name}` : `0 as ${name}`);
81
+ const session = db.prepare(`select source, model, ${has.has("cwd") ? "cwd" : "null as cwd"}, ${["input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens"].map(pick).join(", ")} from sessions where id = ?`).get(id);
82
+ if (!session) return [];
83
+ const messages = db.prepare("select role, timestamp from messages where session_id = ? and timestamp is not null order by timestamp, id").all(id);
84
+ const model = cleanModel(session.model);
85
+ const out = [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}), steered: hermesSteered(session.source) }];
86
+ const lastAssistant = messages.map((m) => m.role).lastIndexOf("assistant");
87
+ messages.forEach((message, i) => {
88
+ const timestamp = new Date(Number(message.timestamp) * 1000).toISOString();
89
+ if (message.role === "user") out.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
90
+ else if (message.role === "tool") out.push({ type: "user", timestamp, message: { content: [{ type: "tool_result" }] } });
91
+ else if (message.role === "assistant") out.push({ type: "assistant", timestamp, message: { model, ...(i === lastAssistant ? { usage: { input_tokens: Number(session.input_tokens) || 0, output_tokens: Number(session.output_tokens) || 0, cache_read_input_tokens: Number(session.cache_read_tokens) || 0, cache_creation_input_tokens: Number(session.cache_write_tokens) || 0 } } : {}), content: [] } });
92
+ });
93
+ return out;
94
+ } catch { return null; } finally { db.close(); }
95
+ }
96
+ // ── end hermes session reader ──
97
+
98
+ // ── goose session reader ──
99
+ /**
100
+ * GOOSE, READ FROM ITS OWN DATABASE (2026-10-05). Goose (aaif-goose/goose, formerly block/goose) keeps every session in
101
+ * one SQLite file since 1.10, `sessions/sessions.db` under its data folder (~/.local/share/goose, %APPDATA%\\Block\\goose\\data
102
+ * on Windows, <GOOSE_PATH_ROOT>/data when set; crates/goose/src/session/session_manager.rs, config/paths.rs). A session
103
+ * is read as a transcript: each message's role, its clock (`created_timestamp`, seconds) and the TYPE of each content
104
+ * block, read inside SQLite with json_extract so the text never leaves the database: a user message that answers a tool
105
+ * (`toolResponse`) is the loop, one with text or an image is the person. Tokens come from the per-call `usage_ledger`
106
+ * rows (model per call), each on the assistant message at or before it; without that table, the session's accumulated
107
+ * totals ride on its last assistant message. A subagent's, a scheduled run's or a hidden session is not read; a session
108
+ * through the gateway (a chat app) is steered from elsewhere, any other from the desk.
109
+ */
110
+ const GOOSE_UNATTENDED = new Set(["sub_agent", "scheduled", "hidden"]);
111
+ export const gooseSteered = (type) => (type === "gateway" ? "remote" : type ? "desk" : null);
112
+ export function gooseHomes(given) {
113
+ if (given) return [given.replace(/^~(?=\/|$)/, homedir())];
114
+ const homes = [join(homedir(), ".local", "share", "goose"), join(homedir(), "Library", "Application Support", "Block", "goose")];
115
+ if (process.env.GOOSE_PATH_ROOT) homes.unshift(join(process.env.GOOSE_PATH_ROOT, "data"));
116
+ if (platform() === "win32" && process.env.APPDATA) homes.push(join(process.env.APPDATA, "Block", "goose", "data"));
117
+ return homes;
118
+ }
119
+ export function* gooseSessions(homes) {
120
+ for (const home of homes) {
121
+ const file = join(home, "sessions", "sessions.db");
122
+ try { if (!statSync(file).isFile()) continue; } catch { continue; }
123
+ const db = openDatabase(file);
124
+ if (!db) continue;
125
+ try {
126
+ const rows = db.prepare(`select s.id, s.session_type as type, s.parent_session_id as parent, s.working_dir as cwd, max(m.created_timestamp) as last
127
+ from sessions s join messages m on m.session_id = s.id group by s.id order by s.id`).all();
128
+ for (const row of rows) {
129
+ if (row.parent || GOOSE_UNATTENDED.has(row.type) || !/^[A-Za-z0-9_.:-]{1,120}$/.test(String(row.id))) continue;
130
+ const last = Number(row.last);
131
+ yield { sessionId: String(row.id), file: `goose:${file}#${row.id}`, cwd: typeof row.cwd === "string" ? row.cwd : null, writtenAt: (last > 1e10 ? last : last * 1000) };
132
+ }
133
+ } catch { /* an older or foreign schema: not read */ } finally { db.close(); }
134
+ }
135
+ }
136
+ export function gooseLines(pseudo) {
137
+ const { file, id } = splitSession(pseudo);
138
+ const db = openDatabase(file);
139
+ if (!db) return null;
140
+ const ms = (value) => { const n = Number(value); return n > 1e10 ? n : n * 1000; };
141
+ try {
142
+ const session = db.prepare(`select session_type as type, working_dir as cwd, json_extract(model_config_json, '$.model_name') as model,
143
+ accumulated_input_tokens as input, accumulated_output_tokens as output, accumulated_cache_read_tokens as cache_read, accumulated_cache_write_tokens as cache_write
144
+ from sessions where id = ?`).get(id);
145
+ if (!session) return [];
146
+ const messages = db.prepare(`select role, created_timestamp as at,
147
+ case when json_valid(content_json) then (select group_concat(json_extract(value, '$.type')) from json_each(content_json)) end as kinds
148
+ from messages where session_id = ? order by created_timestamp, id`).all(id);
149
+ const ledger = db.prepare("select 1 from sqlite_master where type = 'table' and name = 'usage_ledger'").get()
150
+ ? db.prepare("select created_timestamp as at, model, input_tokens as input, output_tokens as output, cache_read_tokens as cache_read, cache_write_tokens as cache_write from usage_ledger where session_id = ? order by created_timestamp, id").all(id)
151
+ : null;
152
+ const fallbackModel = cleanModel(session.model);
153
+ const lines = messages.map((message) => {
154
+ const kinds = new Set(String(message.kinds ?? "").split(","));
155
+ const timestamp = new Date(ms(message.at)).toISOString();
156
+ if (message.role === "user") return { type: "user", timestamp, message: { content: [{ type: kinds.has("toolResponse") ? "tool_result" : kinds.has("text") || kinds.has("image") ? "text" : "tool_result" }] } };
157
+ return { type: "assistant", timestamp, at: ms(message.at), message: { model: fallbackModel, content: [] } };
158
+ });
159
+ const assistants = lines.filter((line) => line.type === "assistant");
160
+ const usageOf = (row) => ({ input_tokens: Number(row.input) || 0, output_tokens: Number(row.output) || 0, cache_read_input_tokens: Number(row.cache_read) || 0, cache_creation_input_tokens: Number(row.cache_write) || 0 });
161
+ const add = (line, usage) => { const was = line.message.usage; line.message.usage = was ? Object.fromEntries(Object.entries(usage).map(([key, n]) => [key, n + (was[key] ?? 0)])) : usage; };
162
+ if (ledger) for (const row of ledger) {
163
+ const at = ms(row.at) + 1000;
164
+ const line = [...assistants].reverse().find((one) => one.at <= at) ?? assistants[0];
165
+ if (!line) continue;
166
+ add(line, usageOf(row));
167
+ if (cleanModel(row.model)) line.message.model = cleanModel(row.model);
168
+ } else if (assistants.length) add(assistants.at(-1), usageOf(session));
169
+ for (const line of assistants) delete line.at;
170
+ return [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}), steered: gooseSteered(session.type) }, ...lines];
171
+ } catch { return null; } finally { db.close(); }
172
+ }
173
+ // ── end goose session reader ──
174
+
175
+ // ── opencode session reader ──
176
+ /**
177
+ * OPENCODE, READ FROM ITS OWN DATABASE (2026-10-05). OpenCode (anomalyco/opencode, formerly sst/opencode) keeps its
178
+ * sessions in SQLite, `opencode.db` (or `opencode-<channel>.db`) under ~/.local/share/opencode on every system
179
+ * (packages/core/src/database/database.ts, session/sql.ts). A message's `data` is JSON; only its role, its clock
180
+ * (`time.created`, ms), its model and its token counts are taken out, inside SQLite with json_extract, so no text
181
+ * leaves the database (text lives in the `part` table, which is never read). A subagent's session (`parent_id`) is not
182
+ * read. How it was steered is not said: the terminal, the desktop app and a phone on `opencode serve` look alike here.
183
+ */
184
+ export function opencodeHomes(given) {
185
+ if (given) return [given.replace(/^~(?=\/|$)/, homedir())];
186
+ return [join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode")];
187
+ }
188
+ const opencodeDatabases = (home) => { try { return readdirSync(home).filter((name) => /^opencode(-[A-Za-z0-9_.-]+)?\.db$/.test(name)).sort().map((name) => join(home, name)); } catch { return []; } };
189
+ export function* opencodeSessions(homes) {
190
+ for (const home of homes) for (const file of opencodeDatabases(home)) {
191
+ const db = openDatabase(file);
192
+ if (!db) continue;
193
+ try {
194
+ const rows = db.prepare(`select s.id, s.parent_id as parent, s.directory as cwd, max(m.time_created) as last
195
+ from session s join message m on m.session_id = s.id group by s.id order by s.id`).all();
196
+ for (const row of rows) {
197
+ if (row.parent || !/^[A-Za-z0-9_.:-]{1,120}$/.test(String(row.id))) continue;
198
+ yield { sessionId: String(row.id), file: `opencode:${file}#${row.id}`, cwd: typeof row.cwd === "string" ? row.cwd : null, writtenAt: Number(row.last) };
199
+ }
200
+ } catch { /* an older or foreign schema: not read */ } finally { db.close(); }
201
+ }
202
+ }
203
+ export function opencodeLines(pseudo) {
204
+ const { file, id } = splitSession(pseudo);
205
+ const db = openDatabase(file);
206
+ if (!db) return null;
207
+ try {
208
+ const session = db.prepare("select directory as cwd from session where id = ?").get(id);
209
+ if (!session) return [];
210
+ const messages = db.prepare(`select json_extract(data, '$.role') as role, coalesce(json_extract(data, '$.time.created'), time_created) as at,
211
+ json_extract(data, '$.modelID') as model, json_extract(data, '$.tokens.input') as input, json_extract(data, '$.tokens.output') as output,
212
+ json_extract(data, '$.tokens.reasoning') as reasoning, json_extract(data, '$.tokens.cache.read') as cache_read, json_extract(data, '$.tokens.cache.write') as cache_write
213
+ from message where session_id = ? order by time_created, id`).all(id);
214
+ const lines = messages.map((message) => {
215
+ const timestamp = new Date(Number(message.at)).toISOString();
216
+ if (message.role === "user") return { type: "user", timestamp, message: { content: [{ type: "text" }] } };
217
+ const counted = message.input !== null || message.output !== null;
218
+ return { type: "assistant", timestamp, message: { model: cleanModel(message.model), ...(counted ? { usage: { input_tokens: Number(message.input) || 0, output_tokens: (Number(message.output) || 0) + (Number(message.reasoning) || 0), cache_read_input_tokens: Number(message.cache_read) || 0, cache_creation_input_tokens: Number(message.cache_write) || 0 } } : {}), content: [] } };
219
+ });
220
+ return [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}) }, ...lines];
221
+ } catch { return null; } finally { db.close(); }
222
+ }
223
+ // ── end opencode session reader ──
224
+
225
+ // ── openclaw session reader ──
226
+ /**
227
+ * OPENCLAW, READ FROM ITS OWN DATABASE (2026-10-05). OpenClaw keeps each agent's sessions in SQLite,
228
+ * ~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite (OPENCLAW_STATE_DIR moves ~/.openclaw;
229
+ * src/state/openclaw-agent-schema.sql). A window (`session_windows`) is one session; its transcript is
230
+ * `transcript_events`, one JSON event per row, or the same JSON zstd-compressed when large. From a `message` event only
231
+ * the role, the clock (ms), the model and the usage are taken: in SQLite with json_extract for the plain rows, and in
232
+ * memory for a compressed row, whose text is dropped as soon as those four are read. A spawned, scheduled, plugin or
233
+ * internal session is not read. Steering: a Talk session (`created_via = 'talk'`) is voice; one that came through a
234
+ * chat channel (WhatsApp, Telegram, Slack …) is remote; the rest is not said.
235
+ */
236
+ const OPENCLAW_UNATTENDED = new Set(["spawn", "cron", "plugin", "internal"]);
237
+ const OPENCLAW_LOCAL = new Set(["webchat", "cli", "tui", "terminal", "desktop", "macos", "control-ui"]);
238
+ export const openclawSteered = (createdVia, channel) => (createdVia === "talk" ? "voice" : channel && !OPENCLAW_LOCAL.has(String(channel).toLowerCase()) ? "remote" : null);
239
+ export function openclawHomes(given) {
240
+ if (given) return [given.replace(/^~(?=\/|$)/, homedir())];
241
+ const base = process.env.OPENCLAW_STATE_DIR || join(homedir(), ".openclaw");
242
+ try { return readdirSync(join(base, "agents")).sort().map((agent) => join(base, "agents", agent, "agent")); } catch { return []; }
243
+ }
244
+ const msOf = (value) => { const n = Number(value); return n > 1e11 ? n : n * 1000; };
245
+ export function* openclawSessions(homes) {
246
+ for (const home of homes) {
247
+ const file = join(home, "openclaw-agent.sqlite");
248
+ try { if (!statSync(file).isFile()) continue; } catch { continue; }
249
+ const db = openDatabase(file);
250
+ if (!db) continue;
251
+ try {
252
+ const rows = db.prepare(`select w.session_id as id, n.created_via as via, coalesce(w.spawned_by, w.parent_session_key, n.spawned_by, n.parent_session_key) as parent, max(e.created_at) as last
253
+ from session_windows w join session_nodes n on n.session_key = w.session_key join transcript_events e on e.session_id = w.session_id group by w.session_id order by w.session_id`).all();
254
+ for (const row of rows) {
255
+ if (row.parent || OPENCLAW_UNATTENDED.has(row.via) || !/^[A-Za-z0-9_.:-]{1,120}$/.test(String(row.id))) continue;
256
+ yield { sessionId: String(row.id), file: `openclaw:${file}#${row.id}`, cwd: null, writtenAt: msOf(row.last) };
257
+ }
258
+ } catch { /* an older or foreign schema: not read */ } finally { db.close(); }
259
+ }
260
+ }
261
+ export function openclawLines(pseudo) {
262
+ const { file, id } = splitSession(pseudo);
263
+ const db = openDatabase(file);
264
+ if (!db) return null;
265
+ try {
266
+ const session = db.prepare("select n.created_via as via, w.channel as channel, w.model as model from session_windows w join session_nodes n on n.session_key = w.session_key where w.session_id = ?").get(id);
267
+ if (!session) return [];
268
+ const plain = db.prepare(`select seq, json_extract(event_json, '$.message.role') as role, json_extract(event_json, '$.message.timestamp') as at, json_extract(event_json, '$.message.model') as model,
269
+ json_extract(event_json, '$.message.usage.input') as input, json_extract(event_json, '$.message.usage.output') as output, json_extract(event_json, '$.message.usage.cacheRead') as cache_read, json_extract(event_json, '$.message.usage.cacheWrite') as cache_write
270
+ from transcript_events where session_id = ? and event_json is not null and json_extract(event_json, '$.type') = 'message'`).all(id);
271
+ const packed = db.prepare("select seq, event_zstd as packed from transcript_events where session_id = ? and event_json is null and event_zstd is not null").all(id).map(({ seq, packed: blob }) => {
272
+ try {
273
+ const event = JSON.parse(zlib.zstdDecompressSync(Buffer.from(blob)).toString("utf8"));
274
+ if (event?.type !== "message") return null;
275
+ const { role, timestamp, model, usage } = event.message ?? {};
276
+ return { seq, role, at: timestamp, model, input: usage?.input ?? null, output: usage?.output ?? null, cache_read: usage?.cacheRead ?? null, cache_write: usage?.cacheWrite ?? null };
277
+ } catch { return null; }
278
+ }).filter(Boolean);
279
+ const lines = [...plain, ...packed].filter((event) => Number.isFinite(Number(event.at))).sort((a, b) => a.seq - b.seq).map((event) => {
280
+ const timestamp = new Date(msOf(event.at)).toISOString();
281
+ if (event.role === "user") return { type: "user", timestamp, message: { content: [{ type: "text" }] } };
282
+ if (event.role === "toolResult") return { type: "user", timestamp, message: { content: [{ type: "tool_result" }] } };
283
+ const counted = event.input !== null || event.output !== null;
284
+ return { type: "assistant", timestamp, message: { model: cleanModel(event.model) ?? cleanModel(session.model), ...(counted ? { usage: { input_tokens: Number(event.input) || 0, output_tokens: Number(event.output) || 0, cache_read_input_tokens: Number(event.cache_read) || 0, cache_creation_input_tokens: Number(event.cache_write) || 0 } } : {}), content: [] } };
285
+ });
286
+ return [{ type: "meta", steered: openclawSteered(session.via, session.channel) }, ...lines];
287
+ } catch { return null; } finally { db.close(); }
288
+ }
289
+ // ── end openclaw session reader ──
290
+
291
+ // ── cursor session reader ──
292
+ /**
293
+ * CURSOR, READ FROM ITS OWN DATABASE (2026-10-06, the owner: "a person who works in Cursor all day has no measured
294
+ * hours"). Cursor keeps every chat ("composer") in one SQLite file, `User/globalStorage/state.vscdb` (~/Library/Application
295
+ * Support/Cursor on macOS, %APPDATA%\Cursor on Windows, ~/.config/Cursor on Linux), table cursorDiskKV: `composerData:<id>`
296
+ * per chat and `bubbleId:<id>:<bubble>` per message. Each message is read through json_extract INSIDE SQLite, its type
297
+ * (1 the person, 2 the model), its clock and its token counts, so no text ever leaves the database. A model message that
298
+ * carries a tool call is the loop's own step. The folder a chat belongs to is read from each workspace's own state
299
+ * (`composer.composerData`); a chat in no workspace has none. Needs node:sqlite (Node 22.5+).
300
+ */
301
+ const userDirs = (app) => {
302
+ const base = platform() === "win32" && process.env.APPDATA ? process.env.APPDATA : platform() === "darwin" ? join(homedir(), "Library", "Application Support") : join(homedir(), ".config");
303
+ return [join(base, app, "User")];
304
+ };
305
+ const folderOf = (uri) => { try { return typeof uri === "string" && uri.startsWith("file://") ? decodeURIComponent(new URL(uri).pathname).replace(/^\/([A-Za-z]:)/, "$1") : null; } catch { return null; } };
306
+ export function cursorHomes(given) { return given ? [given.replace(/^~(?=\/|$)/, homedir())] : userDirs("Cursor"); }
307
+ /** Which folder each composer belongs to, from every workspace's own state. */
308
+ function cursorFolders(home) {
309
+ const folders = new Map();
310
+ let dirs = []; try { dirs = readdirSync(join(home, "workspaceStorage")); } catch { return folders; }
311
+ for (const dir of dirs) {
312
+ let folder = null; try { folder = folderOf(JSON.parse(readFileSync(join(home, "workspaceStorage", dir, "workspace.json"), "utf8")).folder); } catch { continue; }
313
+ if (!folder) continue;
314
+ const db = openDatabase(join(home, "workspaceStorage", dir, "state.vscdb"));
315
+ if (!db) continue;
316
+ try {
317
+ const row = db.prepare("select json_extract(value, '$.allComposers') as ids from ItemTable where key = 'composer.composerData'").get();
318
+ for (const one of JSON.parse(row?.ids ?? "[]")) if (one?.composerId) folders.set(String(one.composerId), folder);
319
+ } catch { /* an older schema */ } finally { db.close(); }
320
+ }
321
+ return folders;
322
+ }
323
+ export function* cursorSessions(homes) {
324
+ for (const home of homes) {
325
+ const file = join(home, "globalStorage", "state.vscdb");
326
+ try { if (!statSync(file).isFile()) continue; } catch { continue; }
327
+ const folders = cursorFolders(home);
328
+ const db = openDatabase(file);
329
+ if (!db) continue;
330
+ try {
331
+ const rows = db.prepare("select substr(key, 14) as id, json_extract(value, '$.lastUpdatedAt') as last from cursorDiskKV where key like 'composerData:%'").all();
332
+ for (const row of rows) {
333
+ if (!/^[A-Za-z0-9_.:-]{1,120}$/.test(String(row.id))) continue;
334
+ yield { sessionId: String(row.id), file: `cursor:${file}#${row.id}`, cwd: folders.get(String(row.id)) ?? null, writtenAt: Number(row.last) || 0 };
335
+ }
336
+ } catch { /* an older or foreign schema: not read */ } finally { db.close(); }
337
+ }
338
+ }
339
+ export function cursorLines(pseudo) {
340
+ const { file, id } = splitSession(pseudo);
341
+ const db = openDatabase(file);
342
+ if (!db) return null;
343
+ try {
344
+ const chat = db.prepare("select json_extract(value, '$.modelConfig.modelName') as model from cursorDiskKV where key = ?").get(`composerData:${id}`);
345
+ const bubbles = db.prepare(`select json_extract(value, '$.type') as type, json_extract(value, '$.createdAt') as at,
346
+ json_extract(value, '$.tokenCount.inputTokens') as input, json_extract(value, '$.tokenCount.outputTokens') as output,
347
+ json_extract(value, '$.toolFormerData') is not null as tool
348
+ from cursorDiskKV where key > ? and key < ? and json_extract(value, '$.createdAt') is not null`).all(`bubbleId:${id}:`, `bubbleId:${id};`);
349
+ const model = cleanModel(chat?.model);
350
+ const lines = [];
351
+ for (const bubble of bubbles.sort((a, b) => String(a.at).localeCompare(String(b.at)))) {
352
+ const timestamp = new Date(bubble.at).toISOString();
353
+ if (Number(bubble.type) === 1) lines.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
354
+ else if (Number(bubble.type) === 2) {
355
+ const usage = Number(bubble.input) || Number(bubble.output) ? { usage: { input_tokens: Number(bubble.input) || 0, output_tokens: Number(bubble.output) || 0 } } : {};
356
+ lines.push({ type: "assistant", timestamp, message: { model, ...usage, content: bubble.tool ? [{ type: "tool_use", name: "tool", input: {} }] : [] } });
357
+ }
358
+ }
359
+ return lines;
360
+ } catch { return null; } finally { db.close(); }
361
+ }
362
+ // ── end cursor session reader ──
363
+
364
+ // ── copilot session reader ──
365
+ /**
366
+ * GITHUB COPILOT IN VS CODE (2026-10-06). VS Code keeps each Copilot chat as a file: `chatSessions/<id>.jsonl` under
367
+ * every workspace's storage (`User/workspaceStorage/<hash>/`, its folder in `workspace.json`) and, for a window with no
368
+ * folder, under `User/globalStorage/emptyWindowChatSessions/`. A .jsonl file is a log of edits (kind 0 the whole
369
+ * session, 1 a value set at a path, 2 items appended at a path); an older .json file is the session whole. Each
370
+ * request is read for its clocks and counts only: when it was asked (`timestamp`, the person's turn) and when the
371
+ * model finished (`modelState.completedAt`), the model it resolved to and the prompt and output tokens. The messages,
372
+ * the responses and the tool calls' text are parsed with the file and never kept. Agent mode runs a request's tools
373
+ * between those two clocks without stamping each step, so under the same rule as every other client that span counts
374
+ * up to the idle cap, as one gap.
375
+ */
376
+ export function copilotHomes(given) { return given ? [given.replace(/^~(?=\/|$)/, homedir())] : [...userDirs("Code"), ...userDirs("Code - Insiders")]; }
377
+ export function* copilotSessions(homes) {
378
+ for (const home of homes) {
379
+ const places = [];
380
+ try { for (const dir of readdirSync(join(home, "workspaceStorage"))) places.push({ dir: join(home, "workspaceStorage", dir, "chatSessions"), workspace: join(home, "workspaceStorage", dir, "workspace.json") }); } catch { /* none */ }
381
+ places.push({ dir: join(home, "globalStorage", "emptyWindowChatSessions"), workspace: null });
382
+ for (const place of places) {
383
+ let names = []; try { names = readdirSync(place.dir); } catch { continue; }
384
+ let cwd = null; if (place.workspace) { try { cwd = folderOf(JSON.parse(readFileSync(place.workspace, "utf8")).folder); } catch { /* none */ } }
385
+ for (const name of names) {
386
+ const id = name.replace(/\.jsonl?$/, "");
387
+ if (!/\.jsonl?$/.test(name) || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id)) continue;
388
+ let writtenAt = 0; try { writtenAt = statSync(join(place.dir, name)).mtimeMs; } catch { continue; }
389
+ yield { sessionId: id, file: `copilot:${join(place.dir, name)}#${id}`, cwd, writtenAt };
390
+ }
391
+ }
392
+ }
393
+ }
394
+ /** A session file back into the session: the whole object, or the edit log replayed. */
395
+ function copilotSession(text, jsonl) {
396
+ if (!jsonl) return JSON.parse(text);
397
+ let session = null;
398
+ for (const raw of text.split("\n")) {
399
+ if (!raw.trim()) continue;
400
+ let edit; try { edit = JSON.parse(raw); } catch { continue; }
401
+ if (edit.kind === 0) { session = edit.v; continue; }
402
+ if (!session || !Array.isArray(edit.k) || edit.k.length === 0) continue;
403
+ const parent = edit.k.slice(0, -1).reduce((at, key) => (at == null ? at : at[key]), session);
404
+ const key = edit.k.at(-1);
405
+ if (parent == null || typeof parent !== "object") continue;
406
+ if (edit.kind === 1) parent[key] = edit.v;
407
+ else if (edit.kind === 2) { if (!Array.isArray(parent[key])) parent[key] = []; parent[key].push(...(Array.isArray(edit.v) ? edit.v : [edit.v])); }
408
+ }
409
+ return session;
410
+ }
411
+ export function copilotLines(pseudo) {
412
+ const { file } = splitSession(pseudo);
413
+ let session; try { session = copilotSession(readFileSync(file, "utf8"), file.endsWith(".jsonl")); } catch { return null; }
414
+ const lines = [];
415
+ for (const request of Array.isArray(session?.requests) ? session.requests : []) {
416
+ const asked = Number(request?.timestamp);
417
+ if (!Number.isFinite(asked) || asked <= 0) continue;
418
+ lines.push({ type: "user", timestamp: new Date(asked).toISOString(), message: { content: [{ type: "text" }] } });
419
+ const done = Number(request?.modelState?.completedAt) || (Number(request?.result?.timings?.totalElapsed) ? asked + Number(request.result.timings.totalElapsed) : 0);
420
+ if (!done || done < asked) continue;
421
+ const meta = request?.result?.metadata ?? {};
422
+ const usage = Number(meta.promptTokens) || Number(meta.outputTokens) ? { usage: { input_tokens: Number(meta.promptTokens) || 0, output_tokens: Number(meta.outputTokens) || 0 } } : {};
423
+ lines.push({ type: "assistant", timestamp: new Date(done).toISOString(), message: { model: cleanModel(String(meta.resolvedModel ?? request?.modelId ?? "").replace(/^copilot\//, "")), ...usage, content: [] } });
424
+ }
425
+ return lines;
426
+ }
427
+ // ── end copilot session reader ──
428
+
429
+ // ── database clients ──
430
+ /** The clients whose sessions live in a database, one registry: the hook and the counter ask it, not each client. */
431
+ const DATABASE_CLIENTS = {
432
+ hermes: { homes: hermesHomes, sessions: hermesSessions, lines: hermesLines },
433
+ goose: { homes: gooseHomes, sessions: gooseSessions, lines: gooseLines },
434
+ opencode: { homes: opencodeHomes, sessions: opencodeSessions, lines: opencodeLines },
435
+ openclaw: { homes: openclawHomes, sessions: openclawSessions, lines: openclawLines },
436
+ cursor: { homes: cursorHomes, sessions: cursorSessions, lines: cursorLines },
437
+ copilot: { homes: copilotHomes, sessions: copilotSessions, lines: copilotLines },
438
+ };
439
+ export const DATABASE_SESSION = new RegExp(`^(${Object.keys(DATABASE_CLIENTS).join("|")}):.+#[A-Za-z0-9_.:-]{1,120}$`);
440
+ /** Every attended session of every database client; `given` names a client's own home (a test), `onlyGiven` reads no other. */
441
+ export function* databaseSessions(given = {}, onlyGiven = false) {
442
+ for (const [client, reader] of Object.entries(DATABASE_CLIENTS)) {
443
+ const homes = given[client] ? reader.homes(given[client]) : onlyGiven ? [] : reader.homes();
444
+ for (const session of reader.sessions(homes)) yield { client, ...session };
445
+ }
446
+ }
447
+ export const databaseLines = (pseudo) => DATABASE_CLIENTS[pseudo.slice(0, pseudo.indexOf(":"))]?.lines(pseudo) ?? null;
448
+ // ── end database clients ──
package/setup-mcp.mjs CHANGED
@@ -8,17 +8,18 @@
8
8
  * node setup-mcp.mjs --remove --write (takes it out again)
9
9
  *
10
10
  * One machine, one token, many clients: Claude Code, Cursor, Codex, Gemini CLI, VS Code's
11
- * Copilot agent mode, Windsurf, Mistral Vibe, Google Antigravity, Grok Build, Zed, Cline. Each keeps its own config file in its own shape; this finds
11
+ * Copilot agent mode, Windsurf, Mistral Vibe, Google Antigravity, Grok Build, Zed, Cline, Hermes Agent, Goose. Each keeps its own config file in its own shape; this finds
12
12
  * the ones that exist here and adds the same WorkTrust server to each, so a coupling made once
13
13
  * counts everywhere — and every client names itself when it first speaks, which is how Sources
14
14
  * shows each product as coupled. Without --write nothing is touched. The token is shown
15
15
  * masked; it lives only in the files it is written to. A config that already names the server
16
16
  * is left as it is.
17
17
  */
18
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { homedir, platform } from "node:os";
21
21
  import { dirname, join } from "node:path";
22
+ import { placeUnder, takeOut } from "./config-edits.mjs";
22
23
 
23
24
  const args = process.argv.slice(2);
24
25
  const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
@@ -67,12 +68,109 @@ const masked = BRIDGE ? "kept in ~/.worktrust/key.json, not in these files" : `$
67
68
  const stdio = { command: NODE, args: [BRIDGE ?? "", "mcp"] };
68
69
  const home = homedir();
69
70
  /** Whether Claude Code's own command is on this machine: asked of the shell, never guessed from a file. */
70
- const claudeOnPath = () => spawnSync(platform() === "win32" ? "where" : "which", ["claude"], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
71
+ const onPath = (command) => spawnSync(platform() === "win32" ? "where" : "which", [command], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
72
+ const claudeOnPath = () => onPath("claude");
71
73
  const VIBE_DIR = process.env.VIBE_HOME?.trim() || join(home, ".vibe");
72
74
  const GROK_DIR = process.env.GROK_HOME?.trim() || join(home, ".grok");
73
75
  const ZED_DIR = platform() === "win32" ? join(process.env.APPDATA ?? home, "Zed") : join(home, ".config", "zed");
74
76
  const vscodeUser = platform() === "darwin" ? join(home, "Library", "Application Support", "Code", "User") : platform() === "win32" ? join(process.env.APPDATA ?? home, "Code", "User") : join(home, ".config", "Code", "User");
75
77
  const CLINE_DIR = join(vscodeUser, "globalStorage", "saoudrizwan.claude-dev", "settings");
78
+ /**
79
+ * HERMES AGENT (Nous Research, 2026-10-05): `mcp_servers` in config.yaml under its home (~/.hermes, %LOCALAPPDATA%/hermes
80
+ * on Windows, HERMES_HOME) and under every profile's own home (profiles/<name>), `command`/`args` for a local server,
81
+ * `url`/`headers` for a remote one (hermes-agent.nousresearch.com/docs/user-guide/features/mcp). With the bridge it also
82
+ * gets one shell hook on `on_session_end` that wakes the session hook, which reads Hermes's own session database
83
+ * (features/hooks); Hermes asks the person once before it runs a new hook. YAML is edited line by line and only in
84
+ * block style: a file whose sections are written inline is left alone and the entry printed to add by hand.
85
+ */
86
+ const HERMES_BASE = platform() === "win32" && process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "hermes") : join(home, ".hermes");
87
+ const hermesHomes = () => {
88
+ const homes = new Set([HERMES_BASE, ...(process.env.HERMES_HOME ? [process.env.HERMES_HOME] : [])]);
89
+ try { for (const name of readdirSync(join(HERMES_BASE, "profiles")).sort()) homes.add(join(HERMES_BASE, "profiles", name)); } catch { /* no profiles */ }
90
+ return [...homes].filter((dir) => existsSync(dir));
91
+ };
92
+ const HERMES_HOOK = `"${NODE}" "${BRIDGE ?? ""}" hook --hermes`;
93
+ const ours = (text) => text.includes(URL_) || /worktrust\.mjs/.test(text);
94
+ /** A YAML client's file with our entry (and hook) taken out and, unless removing, put back in; null when it cannot be. */
95
+ function yamlConfig(client, text, remove) {
96
+ const lines = text.trim() === "" ? [] : text.replace(/\n$/, "").split("\n");
97
+ takeOut(lines, [client.root], (unit) => new RegExp(`^\\s*${NAME}:`).test(unit) || ours(unit));
98
+ if (client.hook) takeOut(lines, client.hook.path, ours);
99
+ if (!remove) {
100
+ if (!placeUnder(lines, [client.root], client.server)) return null;
101
+ if (client.hook && BRIDGE && !placeUnder(lines, client.hook.path, client.hook.unit)) return null;
102
+ }
103
+ return lines.length ? `${lines.join("\n")}\n` : "";
104
+ }
105
+ const bridgeOrUrl = (local, remote) => (BRIDGE ? local : remote);
106
+ const HERMES_SERVER = bridgeOrUrl([`${NAME}:`, ` command: ${JSON.stringify(NODE)}`, ` args: [${JSON.stringify(BRIDGE)}, "mcp"]`], [`${NAME}:`, ` url: ${JSON.stringify(URL_)}`, " headers:", ` Authorization: ${JSON.stringify(`Bearer ${TOKEN}`)}`]);
107
+ /**
108
+ * GOOSE (2026-10-05, aaif-goose/goose: crates/goose/src/config/extensions.rs, agents/extension.rs; documentation
109
+ * guides/config-files.md): `extensions` in config.yaml (~/.config/goose, %APPDATA%\\Block\\goose\\config on Windows,
110
+ * <GOOSE_PATH_ROOT>/config), each entry `type: stdio` with `cmd` and `args`, or `type: streamable_http` with `uri` and
111
+ * `headers`. Its hooks come from Open Plugins folders (~/.agents/plugins/<name>/plugin.json + hooks/hooks.json,
112
+ * guides/context-engineering/hooks.md); with the bridge one plugin named worktrust runs the session hook on SessionEnd.
113
+ */
114
+ const GOOSE_SERVER = bridgeOrUrl(
115
+ [`${NAME}:`, " type: stdio", ` name: ${NAME}`, " enabled: true", ` cmd: ${JSON.stringify(NODE)}`, ` args: [${JSON.stringify(BRIDGE)}, "mcp"]`, " envs: {}", " timeout: 300"],
116
+ [`${NAME}:`, " type: streamable_http", ` name: ${NAME}`, " enabled: true", ` uri: ${JSON.stringify(URL_)}`, " headers:", ` Authorization: ${JSON.stringify(`Bearer ${TOKEN}`)}`, " envs: {}", " timeout: 300"],
117
+ );
118
+ const gooseDirs = () => [...new Set([...(process.env.GOOSE_PATH_ROOT ? [join(process.env.GOOSE_PATH_ROOT, "config")] : []), join(home, ".config", "goose"), ...(platform() === "win32" && process.env.APPDATA ? [join(process.env.APPDATA, "Block", "goose", "config")] : [])])].filter((dir) => existsSync(dir));
119
+ const GOOSE_PLUGIN = join(process.env.GOOSE_PATH_ROOT || home, ".agents", "plugins", NAME);
120
+ const GOOSE_HOOKS = { hooks: { SessionEnd: [{ hooks: [{ type: "command", command: `"${NODE}" "${BRIDGE ?? ""}" hook --goose`, timeout: 30 }] }] } };
121
+ const goosePluginStands = () => { try { return JSON.stringify(JSON.parse(readFileSync(join(GOOSE_PLUGIN, "hooks", "hooks.json"), "utf8"))) === JSON.stringify(GOOSE_HOOKS); } catch { return false; } };
122
+ /** Our plugin folder in, or out; another plugin is never touched, and a folder of that name that is not ours stays. */
123
+ function goosePlugin(remove) {
124
+ let manifest = null; try { manifest = JSON.parse(readFileSync(join(GOOSE_PLUGIN, "plugin.json"), "utf8")); } catch { /* none */ }
125
+ if (existsSync(GOOSE_PLUGIN) && manifest?.description !== GOOSE_DESCRIPTION) return false;
126
+ if (remove) { rmSync(GOOSE_PLUGIN, { recursive: true, force: true }); return true; }
127
+ mkdirSync(join(GOOSE_PLUGIN, "hooks"), { recursive: true });
128
+ writeFileSync(join(GOOSE_PLUGIN, "plugin.json"), `${JSON.stringify({ name: NAME, version: "1.0.0", description: GOOSE_DESCRIPTION }, null, 2)}\n`);
129
+ writeFileSync(join(GOOSE_PLUGIN, "hooks", "hooks.json"), `${JSON.stringify(GOOSE_HOOKS, null, 2)}\n`);
130
+ return true;
131
+ }
132
+ const GOOSE_DESCRIPTION = "WorkTrust: wakes the WorkTrust session hook when a goose session ends (counts only, never content).";
133
+
134
+ /**
135
+ * OPENCODE (2026-10-05, anomalyco/opencode: packages/core/src/v1/config/mcp.ts, opencode.ai/docs/mcp-servers): `mcp` in
136
+ * ~/.config/opencode/opencode.json(c) (XDG_CONFIG_HOME moves it), `type: "local"` with `command` as one array, or
137
+ * `type: "remote"` with `url` and `headers`. A file with comments is left alone, as for Zed. With the bridge one plugin
138
+ * file, plugin/worktrust.js, wakes the session hook when a session goes idle (opencode.ai/docs/plugins: `session.idle`).
139
+ */
140
+ const OPENCODE_DIR = join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "opencode");
141
+ const OPENCODE_FILE = ["opencode.jsonc", "opencode.json", "config.json"].map((name) => join(OPENCODE_DIR, name)).find((file) => existsSync(file)) ?? join(OPENCODE_DIR, "opencode.json");
142
+ const OPENCODE_PLUGIN = join(OPENCODE_DIR, "plugin", `${NAME}.js`);
143
+ const OPENCODE_MARK = "// WorkTrust: wakes the WorkTrust session hook when an OpenCode session goes idle (counts only, never content).";
144
+ const OPENCODE_PLUGIN_TEXT = `${OPENCODE_MARK}
145
+ import { spawn } from "node:child_process";
146
+ let last = 0;
147
+ export const WorkTrust = async () => ({
148
+ event: async ({ event }) => {
149
+ if (event?.type !== "session.idle" || Date.now() - last < 30_000) return;
150
+ last = Date.now();
151
+ try { spawn(${JSON.stringify(NODE)}, [${JSON.stringify(BRIDGE ?? "")}, "hook"], { detached: true, stdio: "ignore" }).unref(); } catch { /* the next run sweeps it */ }
152
+ },
153
+ });
154
+ `;
155
+ /** Our plugin file in, or out; a file of that name that is not ours stays. */
156
+ function opencodePlugin(remove) {
157
+ let text = null; try { text = readFileSync(OPENCODE_PLUGIN, "utf8"); } catch { /* none */ }
158
+ if (text !== null && !text.startsWith(OPENCODE_MARK)) return false;
159
+ if (remove) { rmSync(OPENCODE_PLUGIN, { force: true }); return true; }
160
+ mkdirSync(dirname(OPENCODE_PLUGIN), { recursive: true });
161
+ writeFileSync(OPENCODE_PLUGIN, OPENCODE_PLUGIN_TEXT);
162
+ return true;
163
+ }
164
+ const opencodePluginStands = () => { try { return readFileSync(OPENCODE_PLUGIN, "utf8") === OPENCODE_PLUGIN_TEXT; } catch { return false; } };
165
+ /**
166
+ * OPENCLAW (2026-10-05, openclaw/openclaw: docs/cli/mcp/registry.md, transports.md): its servers live under `mcp.servers`
167
+ * in ~/.openclaw/openclaw.json, a JSON5 file OpenClaw replaces atomically, so it is never edited here: `openclaw mcp set
168
+ * <name> <json>` writes the entry and `openclaw mcp unset <name>` takes it out (config only, no connection is made).
169
+ * Without the `openclaw` command on this machine's PATH the command is printed to run. OpenClaw runs no shell hook; its
170
+ * sessions are read whenever another client's hook runs.
171
+ */
172
+ const OPENCLAW_FILE = process.env.OPENCLAW_CONFIG_PATH || join(process.env.OPENCLAW_STATE_DIR || join(home, ".openclaw"), "openclaw.json");
173
+ const OPENCLAW_ENTRY = BRIDGE ? { command: NODE, args: [BRIDGE, "mcp"] } : { url: URL_, transport: "streamable-http", headers: { Authorization: `Bearer ${TOKEN}` } };
76
174
 
77
175
  /** Each client: where it lives, how it says "an HTTP MCP server with a header". */
78
176
  const CLIENTS = [
@@ -96,6 +194,10 @@ const CLIENTS = [
96
194
  // CLINE (docs.cline.bot/mcp): `mcpServers` in the extension's cline_mcp_settings.json; `type` must be
97
195
  // "streamableHttp" exactly, or Cline falls back to SSE and the door answers 405.
98
196
  { key: "cline", label: "Cline (VS Code)", present: () => existsSync(CLINE_DIR), file: join(CLINE_DIR, "cline_mcp_settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? { ...stdio, disabled: false } : { type: "streamableHttp", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` }, disabled: false } },
197
+ ...hermesHomes().map((dir) => ({ key: "hermes", label: dir === HERMES_BASE ? "Hermes Agent" : `Hermes Agent (${dir.split(/[\\/]/).pop()})`, present: () => true, file: join(dir, "config.yaml"), shape: "yaml", root: "mcp_servers", server: HERMES_SERVER, hook: { path: ["hooks", "on_session_end"], unit: [`- command: ${JSON.stringify(HERMES_HOOK)}`, " timeout: 30"], note: "an on_session_end hook (Hermes asks once before it runs it)" } })),
198
+ ...gooseDirs().map((dir) => ({ key: "goose", label: "Goose", present: () => true, file: join(dir, "config.yaml"), shape: "yaml", root: "extensions", server: GOOSE_SERVER, plugin: true })),
199
+ { key: "opencode", label: "OpenCode", present: () => existsSync(OPENCODE_DIR), file: OPENCODE_FILE, shape: "json", root: "mcp", entry: BRIDGE ? { type: "local", command: [NODE, BRIDGE, "mcp"], enabled: true } : { type: "remote", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` }, enabled: true }, plugin: "opencode" },
200
+ { key: "openclaw", label: "OpenClaw", present: () => existsSync(dirname(OPENCLAW_FILE)) || onPath("openclaw"), file: OPENCLAW_FILE, shape: "openclaw" },
99
201
  { key: "claude", label: "Claude Code", present: () => existsSync(join(home, ".claude.json")), file: join(home, ".claude.json"), shape: "claude" },
100
202
  ];
101
203
 
@@ -104,6 +206,19 @@ let touched = 0;
104
206
  let standing = 0;
105
207
  for (const client of CLIENTS) {
106
208
  if (!client.present()) { console.log(` – ${client.label}: not on this machine`); continue; }
209
+ if (client.shape === "openclaw") {
210
+ const text = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
211
+ const named = new RegExp(`["']?${NAME}["']?\\s*:\\s*\\{`).test(text) && ours(text);
212
+ const verb = REMOVE ? ["mcp", "unset", NAME] : ["mcp", "set", NAME, JSON.stringify(OPENCLAW_ENTRY)];
213
+ const shown = `openclaw ${verb.slice(0, 3).join(" ")}${REMOVE ? "" : ` '${JSON.stringify(OPENCLAW_ENTRY).replace(/Bearer [^"]+/, "Bearer <token>")}'`}`;
214
+ if (REMOVE ? !named : named && text.includes(JSON.stringify(BRIDGE ?? URL_).slice(1, -1)) && (BRIDGE === undefined || !text.includes(URL_))) { console.log(REMOVE ? ` – ${client.label}: does not name the door` : ` ✓ ${client.label}: already names the door (${client.file})`); if (!REMOVE) standing += 1; continue; }
215
+ if (!WRITE) { console.log(` ${REMOVE ? "−" : "+"} ${client.label}: would run ${shown}`); continue; }
216
+ if (!onPath("openclaw")) { console.log(` ! ${client.label}: the \`openclaw\` command is not on this machine's PATH; run ${shown}`); continue; }
217
+ const run = spawnSync("openclaw", verb, { encoding: "utf8", shell: platform() === "win32" });
218
+ if (run.status === 0) { console.log(` ${REMOVE ? "−" : "+"} ${client.label}: ${REMOVE ? "removed" : "coupled"} through \`openclaw mcp ${REMOVE ? "unset" : "set"}\``); touched += 1; }
219
+ else console.log(` ! ${client.label}: \`openclaw mcp\` refused (${(run.stderr || run.stdout || "").trim().split("\n")[0] || "no output"}); run it yourself: ${shown}`);
220
+ continue;
221
+ }
107
222
  if (client.shape === "claude") {
108
223
  // Claude Code owns this file and has its own verb for it, so this never edits the file: it RUNS
109
224
  // the verb when the `claude` command is on this machine (2026-09-28; before, the line was only
@@ -131,7 +246,7 @@ for (const client of CLIENTS) {
131
246
  }
132
247
  const text = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
133
248
  // With the bridge, coupled means the bridge is in AND no entry with a key in it is left beside it.
134
- const already = REMOVE ? text.includes(URL_) || /worktrust\.mjs/.test(text) : BRIDGE !== undefined ? text.includes(JSON.stringify(BRIDGE).slice(1, -1)) && !text.includes(URL_) : text.includes(URL_);
249
+ const already = client.shape === "yaml" && !REMOVE ? (BRIDGE !== undefined ? text.includes(JSON.stringify(BRIDGE)) && (!client.plugin || goosePluginStands()) : text.includes(URL_)) && yamlConfig(client, text, false) === text : REMOVE ? text.includes(URL_) || /worktrust\.mjs/.test(text) : BRIDGE !== undefined ? text.includes(JSON.stringify(BRIDGE).slice(1, -1)) && !text.includes(URL_) : text.includes(URL_);
135
250
  // THE WAY OUT, ON THE MACHINE. Uncoupling in the app ends the TOKEN; the config that names the
136
251
  // door stays behind on every client here, so the next person to read this file believes the
137
252
  // machine is coupled. `--remove` takes the entry out; the account side is a separate act and
@@ -148,8 +263,12 @@ for (const client of CLIENTS) {
148
263
  if (key === NAME || named) delete root[key];
149
264
  }
150
265
  config[client.root] = root;
151
- console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
152
- if (WRITE) writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n");
266
+ console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}${client.plugin && existsSync(OPENCODE_PLUGIN) ? ` and ${OPENCODE_PLUGIN}` : ""}`);
267
+ if (WRITE) { writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); if (client.plugin) opencodePlugin(true); }
268
+ } else if (client.shape === "yaml") {
269
+ const next = yamlConfig(client, readFileSync(client.file, "utf8"), true);
270
+ console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}${client.plugin && existsSync(GOOSE_PLUGIN) ? ` and ${GOOSE_PLUGIN}` : ""}`);
271
+ if (WRITE) { writeFileSync(client.file, next); if (client.plugin) goosePlugin(true); }
153
272
  } else if (client.shape === "vibe") {
154
273
  // Vibe's servers are an ARRAY of tables, so a block is found by what it says, not by its header: every
155
274
  // `[[mcp_servers]]` that names ours, the door, or the bridge goes, up to the next header at a line start.
@@ -170,7 +289,7 @@ for (const client of CLIENTS) {
170
289
  touched += 1;
171
290
  continue;
172
291
  }
173
- if (already) { console.log(` ✓ ${client.label}: already names the door (${client.file})`); standing += 1; continue; }
292
+ if (already && !(client.plugin === "opencode" && BRIDGE && !opencodePluginStands())) { console.log(` ✓ ${client.label}: already names the door (${client.file})`); standing += 1; continue; }
174
293
  if (client.shape === "json") {
175
294
  let config = {};
176
295
  // NEVER OVERWRITE WHAT DOES NOT PARSE (2026-10-04): Zed's and VS Code's settings allow comments and trailing
@@ -182,8 +301,20 @@ for (const client of CLIENTS) {
182
301
  continue;
183
302
  }
184
303
  config[client.root] = { ...(config[client.root] ?? {}), [NAME]: client.entry };
185
- console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}`);
186
- if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); touched += 1; }
304
+ console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}${client.plugin && BRIDGE ? ` and ${OPENCODE_PLUGIN}` : ""}`);
305
+ if (WRITE) {
306
+ mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); touched += 1;
307
+ if (client.plugin && BRIDGE && !opencodePlugin(false)) console.log(` ! ${client.label}: ${OPENCODE_PLUGIN} exists and is not WorkTrust's, so no plugin was written; OpenCode sessions are still read whenever another hook runs`);
308
+ }
309
+ } else if (client.shape === "yaml") {
310
+ const next = yamlConfig(client, text, false);
311
+ if (next === null) { console.log(` ! ${client.label}: ${client.file} writes ${client.root}${client.hook ? " or hooks" : ""} inline, so it is left as it is. Add under ${client.root}:\n ${client.server.join("\n ")}`); continue; }
312
+ const hookNote = !BRIDGE ? "" : client.hook ? ` and ${client.hook.note}` : client.plugin ? ` and a SessionEnd hook in ${GOOSE_PLUGIN}` : "";
313
+ console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}${hookNote}`);
314
+ if (WRITE) {
315
+ mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, next); touched += 1;
316
+ if (client.plugin && BRIDGE && !goosePlugin(false)) console.log(` ! ${client.label}: ${GOOSE_PLUGIN} exists and is not WorkTrust's, so no hook was written; goose sessions are still read whenever another hook runs`);
317
+ }
187
318
  } else if (client.shape === "toml" || client.shape === "vibe") {
188
319
  console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → ${client.shape === "vibe" ? `[[mcp_servers]] name = "${NAME}"` : `[mcp_servers.${NAME}]`}`);
189
320
  if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, (existsSync(client.file) ? readFileSync(client.file, "utf8") : "") + client.block); touched += 1; }
package/worktrust.mjs CHANGED
@@ -49,7 +49,7 @@
49
49
  *
50
50
  * No dependencies: Node 18 or later.
51
51
  */
52
- import { spawn } from "node:child_process";
52
+ import { execFileSync, spawn } from "node:child_process";
53
53
  import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
54
54
  import { createServer } from "node:http";
55
55
  import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
@@ -65,14 +65,15 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
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
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";
68
+ const CLI_VERSION = "0.6.11";
69
69
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
70
70
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
71
71
  const HOME_DIR = join(homedir(), ".worktrust");
72
72
  const HERE = dirname(fileURLToPath(import.meta.url));
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`);
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`);
76
77
  const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
77
78
  const PLACEHOLDER = `wt_${"0".repeat(43)}`;
78
79
  const say = (line = "") => process.stdout.write(`${line}\n`);
@@ -80,6 +81,28 @@ const fail = (line) => { process.stderr.write(`worktrust: ${line}\n`); process.e
80
81
  const OS = { darwin: "macos", win32: "windows", linux: "linux" }[platform()] ?? "other";
81
82
  const OS_NAME = { macos: "macOS", windows: "Windows", linux: "Linux", other: "this system" }[OS];
82
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
+
83
106
  if (Number(process.versions.node.split(".")[0]) < 18) fail("needs Node 18 or later (fetch is built in from 18).");
84
107
 
85
108
  async function post(path, body) {
@@ -154,10 +177,14 @@ async function pairByBrowser(host, extra) {
154
177
  const state = randomBytes(24).toString("base64url");
155
178
  let deliver;
156
179
  const answer = new Promise((resolve) => { deliver = resolve; });
180
+ // Where the browser goes once this terminal has its code (0.6.11, owner 2026-10-06): WorkTrust's own "connected" page in
181
+ // the house style, not a bare page on 127.0.0.1. Known once the pairing has its code; until then the plain page.
182
+ let doneUrl = null;
157
183
  const server = createServer((request, response) => {
158
184
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
159
185
  if (url.pathname !== "/callback" || url.searchParams.get("state") !== state || !url.searchParams.get("code")) { response.writeHead(404).end(); return; }
160
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(DONE_PAGE);
186
+ if (doneUrl) response.writeHead(303, { location: doneUrl, "cache-control": "no-store" }).end();
187
+ else response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(DONE_PAGE);
161
188
  deliver(url.searchParams.get("code"));
162
189
  });
163
190
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -166,6 +193,7 @@ async function pairByBrowser(host, extra) {
166
193
  if (!pairing) { server.close(); fail(`cannot reach ${ORIGIN}.`); }
167
194
  if (pairing.status !== 200) { server.close(); fail(pairing.json.error === "rate_limited" ? "too many attempts from this network; wait a minute." : `pairing refused (${pairing.json.error ?? pairing.status}).`); }
168
195
  const { device_code: secret, authorize_url: url, user_code: shown, expires_in: lifetime = 600, features = [] } = pairing.json;
196
+ doneUrl = `${ORIGIN}/connect/computer/done?code=${encodeURIComponent(shown)}`;
169
197
  if (!features.includes("loopback")) { server.close(); fail(`${ORIGIN} is older than this command. Use the command WorkTrust shows under Sources → Add a computer.`); }
170
198
  say();
171
199
  say(` Your code: ${shown} (the WorkTrust page shows the same; approve only if it matches)`);
@@ -323,10 +351,30 @@ function withLock(work) {
323
351
  return work().finally(() => { closeSync(handle); try { rmSync(LOCK_FILE); } catch { /* gone */ } });
324
352
  }
325
353
 
354
+ /**
355
+ * 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
356
+ * passport unseen"). The key file remembers the profile it was made on; on any other computer it sends nothing, and
357
+ * WorkTrust is told once (`/api/cli/moved`), so the key is ended and the owner sees it. `npx worktrust connect` couples this one.
358
+ */
359
+ async function copiedHere(key) {
360
+ const here = computerIds().profile_id;
361
+ if (!key.profile || !here || key.profile === here) return false;
362
+ try {
363
+ // Its own door, which only ends and marks the key: nothing is renewed, so no new secret is ever announced here.
364
+ const url = `${new URL(key.url).origin}/api/cli/moved`, body = JSON.stringify(computerIds());
365
+ await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key.token}`, ...proof(key.device, url, body) }, body });
366
+ } catch { /* offline: it is told next time */ }
367
+ 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");
368
+ return true;
369
+ }
370
+
326
371
  async function freshKey() {
327
372
  const first = keyStore.load();
328
373
  if (!first) return null;
329
- if (!first.pending && Date.now() - Date.parse(first.renewedAt ?? 0) < RENEW_EVERY_MS) return first;
374
+ if (await copiedHere(first)) return null;
375
+ // A key made before 0.6.10 renews now, once: the renewal carries the computer's identity. `identified` means it was
376
+ // tried, so a machine whose id cannot be read does not renew on every use.
377
+ if (!first.pending && first.identified && Date.now() - Date.parse(first.renewedAt ?? 0) < RENEW_EVERY_MS) return first;
330
378
  const settled = await withLock(async () => {
331
379
  const value = keyStore.load();
332
380
  if (!value) return null;
@@ -335,14 +383,15 @@ async function freshKey() {
335
383
  if (next === true) { const promoted = { ...value, token: value.pending, renewedAt: new Date().toISOString() }; delete promoted.pending; keyStore.save(promoted); return promoted; }
336
384
  if (next === false) { delete value.pending; keyStore.save(value); }
337
385
  }
338
- if (Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
386
+ if (value.identified && Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
339
387
  const pending = `wt_${randomBytes(32).toString("base64url")}`;
340
388
  keyStore.save({ ...value, pending });
341
389
  try {
342
390
  const renewUrl = `${new URL(value.url).origin}/api/cli/renew`;
343
- const body = JSON.stringify({ token_hash: createHash("sha256").update(pending).digest("hex"), token_prefix: pending.slice(0, 11) });
391
+ const ids = computerIds();
392
+ const body = JSON.stringify({ token_hash: createHash("sha256").update(pending).digest("hex"), token_prefix: pending.slice(0, 11), ...ids });
344
393
  const response = await fetch(renewUrl, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${value.token}`, ...proof(value.device, renewUrl, body) }, body });
345
- if (response.ok) { const renewed = { ...value, token: pending, renewedAt: new Date().toISOString() }; keyStore.save(renewed); return renewed; }
394
+ 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; }
346
395
  // Refused: drop the pending key only if the file still holds the value this renewal read.
347
396
  if (response.status === 400 || response.status === 401) { const now = keyStore.load(); if (now?.token === value.token && now?.pending === pending) keyStore.save(value); }
348
397
  } catch { /* offline: the pending key is settled next time */ }
@@ -359,8 +408,21 @@ async function freshKey() {
359
408
  * the key from the key file, answers on stdout. It names the app that started it (from `initialize`),
360
409
  * so WorkTrust still sees Claude Code or Cursor, not the bridge.
361
410
  */
411
+ /**
412
+ * A SWEEP WHEN AN AI APP OPENS THE DOOR (0.6.11, owner 2026-10-06). Cursor and VS Code run no hook WorkTrust can give
413
+ * them, so the session hook never woke for a person who works there all day; both do start this bridge whenever they
414
+ * open. So the bridge starts the sweep once, detached and silent (stdout stays the MCP channel), at most once an hour.
415
+ */
416
+ function sweepAtMostHourly() {
417
+ const stamp = join(HOME_DIR, "bridge-swept-at");
418
+ try { if (Date.now() - Number(readFileSync(stamp, "utf8")) < 3_600_000) return; } catch { /* never swept here */ }
419
+ try { mkdirSync(HOME_DIR, { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch { return; }
420
+ try { spawn(process.execPath, [fileURLToPath(import.meta.url), "hook"], { detached: true, stdio: "ignore" }).unref(); } catch { /* the next start sweeps */ }
421
+ }
422
+
362
423
  async function bridge() {
363
424
  let key = await freshKey();
425
+ if (key) sweepAtMostHourly();
364
426
  const answer = (id, message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32603, message } })}\n`);
365
427
  let session = null;
366
428
  let agent = "worktrust-bridge";
@@ -395,6 +457,7 @@ async function bridge() {
395
457
  /** THE HOOK Claude Code runs (`worktrust.mjs hook`): the key from the key file, handed to the session hook. */
396
458
  async function hook() {
397
459
  if (has("antigravity-stop")) return antigravityStop();
460
+ if (has("hermes") || has("goose")) return sessionEnd();
398
461
  const key = await freshKey();
399
462
  if (!key) process.exit(0);
400
463
  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 } : {}) } });
@@ -424,6 +487,20 @@ async function antigravityStop() {
424
487
  process.exit(0);
425
488
  }
426
489
 
490
+ /**
491
+ * A SESSION-END HOOK THAT READS JSON AND ANSWERS JSON: Hermes Agent's (`hook --hermes`) and Goose's SessionEnd
492
+ * (`hook --goose`, an Open Plugins hook: JSON on stdin, run with sh -c), 2026-10-05. Hermes pipes a JSON payload (ids, the
493
+ * model, the platform; no message body) to a shell hook on every `on_session_end` and reads stdout as JSON
494
+ * (hermes-agent.nousresearch.com/docs/user-guide/features/hooks). This answers `{}` (nothing to change), reads and drops
495
+ * the payload, and runs the sweep detached: the session hook reads Hermes's own database (transcript-readers.mjs).
496
+ */
497
+ async function sessionEnd() {
498
+ writeSync(1, "{}\n");
499
+ 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(); }); });
500
+ try { spawn(process.execPath, [fileURLToPath(import.meta.url), "hook"], { detached: true, stdio: "ignore" }).unref(); } catch { /* the next run sweeps it */ }
501
+ process.exit(0);
502
+ }
503
+
427
504
  /**
428
505
  * `npx worktrust update` (owner, 2026-10-04): THIS COMPUTER ONTO THE NEWEST CLI, WITHOUT PAIRING IT
429
506
  * AGAIN. `connect` mints a new key and so a new device row to merge; an update keeps the key and the
@@ -504,7 +581,7 @@ async function connect() {
504
581
  // The device key: made here, its private half kept with the key, its public half bound at WorkTrust.
505
582
  const device = direct ? null : generateKeyPairSync("ed25519");
506
583
  const apps = foundApps.map((line) => /^\s*[↻+✓] ([^:]+):/.exec(line)?.[1]).filter(Boolean).slice(0, 12);
507
- const extra = { apps, ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
584
+ const extra = { apps, ...computerIds(), ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
508
585
  const pairing = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
509
586
  const { token } = pairing;
510
587
  // ANOTHER ACCOUNT THAN BEFORE: told by the opaque account names. Unknown counts as another, the safe side.
@@ -514,7 +591,7 @@ async function connect() {
514
591
  // Out first, then in: an app that already held a WorkTrust key moves to the new one.
515
592
  await run(paths["setup-mcp.mjs"], ["--remove", "--write"], token, true);
516
593
  if (!direct) {
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" }) });
594
+ 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" }) });
518
595
  previousNote.erase();
519
596
  ensureHome();
520
597
  copyFileSync(fileURLToPath(import.meta.url), STABLE);
@@ -720,6 +797,7 @@ function status() {
720
797
  try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
721
798
  say(hook ? " Session hook: installed" : " Session hook: not installed");
722
799
  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)"); }
800
+ 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)"); }
723
801
  if (antigravityHere()) say(agyHooks()?.worktrust ? " Antigravity: wakes the session hook" : " Antigravity: does not wake the session hook (npx worktrust@latest update asks)");
724
802
  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");
725
803
  }