worktrust 0.6.9 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/config-edits.mjs +69 -0
- package/log-session.mjs +44 -24
- package/package.json +3 -1
- package/session-databases.mjs +308 -0
- package/setup-mcp.mjs +140 -9
- package/worktrust.mjs +71 -11
package/README.md
CHANGED
|
@@ -45,6 +45,23 @@ 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.
|
|
48
65
|
|
|
49
66
|
```
|
|
50
67
|
npx worktrust history send this computer's earlier sessions later (asks first)
|
|
@@ -77,8 +94,8 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
77
94
|
|
|
78
95
|
| | |
|
|
79
96
|
|---|---|
|
|
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
|
|
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/`. |
|
|
97
|
+
| **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). 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. |
|
|
98
|
+
| **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
99
|
| **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
100
|
| **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
|
|
84
101
|
| **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 +132,7 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
115
132
|
|
|
116
133
|
## Undo
|
|
117
134
|
|
|
118
|
-
`npx worktrust disconnect` removes the WorkTrust entries, the hook (and Codex's `notify` line and
|
|
135
|
+
`npx worktrust disconnect` removes the WorkTrust entries (Hermes's hook with them), the hook (and Codex's `notify` line and
|
|
119
136
|
Antigravity's `worktrust` hook when WorkTrust set them) and the key file, and empties
|
|
120
137
|
`~/.worktrust` but for one small note of which account it fed (masked), so that a later coupling by
|
|
121
138
|
another account offers none of this computer's earlier work.
|
package/config-edits.mjs
ADDED
|
@@ -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
|
|
200
|
-
* the
|
|
201
|
-
*
|
|
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
|
|
206
|
-
const
|
|
204
|
+
const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
|
|
205
|
+
const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw"]; // 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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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) ===
|
|
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":
|
|
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,
|
|
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;
|
|
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.
|
|
3
|
+
"version": "0.6.10",
|
|
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,308 @@
|
|
|
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 { 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
|
+
// ── database clients ──
|
|
292
|
+
/** The clients whose sessions live in a database, one registry: the hook and the counter ask it, not each client. */
|
|
293
|
+
const DATABASE_CLIENTS = {
|
|
294
|
+
hermes: { homes: hermesHomes, sessions: hermesSessions, lines: hermesLines },
|
|
295
|
+
goose: { homes: gooseHomes, sessions: gooseSessions, lines: gooseLines },
|
|
296
|
+
opencode: { homes: opencodeHomes, sessions: opencodeSessions, lines: opencodeLines },
|
|
297
|
+
openclaw: { homes: openclawHomes, sessions: openclawSessions, lines: openclawLines },
|
|
298
|
+
};
|
|
299
|
+
export const DATABASE_SESSION = new RegExp(`^(${Object.keys(DATABASE_CLIENTS).join("|")}):.+#[A-Za-z0-9_.:-]{1,120}$`);
|
|
300
|
+
/** Every attended session of every database client; `given` names a client's own home (a test), `onlyGiven` reads no other. */
|
|
301
|
+
export function* databaseSessions(given = {}, onlyGiven = false) {
|
|
302
|
+
for (const [client, reader] of Object.entries(DATABASE_CLIENTS)) {
|
|
303
|
+
const homes = given[client] ? reader.homes(given[client]) : onlyGiven ? [] : reader.homes();
|
|
304
|
+
for (const session of reader.sessions(homes)) yield { client, ...session };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
export const databaseLines = (pseudo) => DATABASE_CLIENTS[pseudo.slice(0, pseudo.indexOf(":"))]?.lines(pseudo) ?? null;
|
|
308
|
+
// ── 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
|
|
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) {
|
|
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.
|
|
68
|
+
const CLI_VERSION = "0.6.10";
|
|
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
|
|
75
|
-
const
|
|
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) {
|
|
@@ -323,10 +346,30 @@ function withLock(work) {
|
|
|
323
346
|
return work().finally(() => { closeSync(handle); try { rmSync(LOCK_FILE); } catch { /* gone */ } });
|
|
324
347
|
}
|
|
325
348
|
|
|
349
|
+
/**
|
|
350
|
+
* A KEY FILE ON OTHER HARDWARE (0.6.10, owner 2026-10-05: "a backup restored onto a new device must not upload to the
|
|
351
|
+
* passport unseen"). The key file remembers the profile it was made on; on any other computer it sends nothing, and
|
|
352
|
+
* WorkTrust is told once (`/api/cli/moved`), so the key is ended and the owner sees it. `npx worktrust connect` couples this one.
|
|
353
|
+
*/
|
|
354
|
+
async function copiedHere(key) {
|
|
355
|
+
const here = computerIds().profile_id;
|
|
356
|
+
if (!key.profile || !here || key.profile === here) return false;
|
|
357
|
+
try {
|
|
358
|
+
// Its own door, which only ends and marks the key: nothing is renewed, so no new secret is ever announced here.
|
|
359
|
+
const url = `${new URL(key.url).origin}/api/cli/moved`, body = JSON.stringify(computerIds());
|
|
360
|
+
await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key.token}`, ...proof(key.device, url, body) }, body });
|
|
361
|
+
} catch { /* offline: it is told next time */ }
|
|
362
|
+
process.stderr.write("WorkTrust: this key was made on another computer (copied from a backup?). Nothing is sent from here; run `npx worktrust connect` on this computer.\n");
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
326
366
|
async function freshKey() {
|
|
327
367
|
const first = keyStore.load();
|
|
328
368
|
if (!first) return null;
|
|
329
|
-
if (
|
|
369
|
+
if (await copiedHere(first)) return null;
|
|
370
|
+
// A key made before 0.6.10 renews now, once: the renewal carries the computer's identity. `identified` means it was
|
|
371
|
+
// tried, so a machine whose id cannot be read does not renew on every use.
|
|
372
|
+
if (!first.pending && first.identified && Date.now() - Date.parse(first.renewedAt ?? 0) < RENEW_EVERY_MS) return first;
|
|
330
373
|
const settled = await withLock(async () => {
|
|
331
374
|
const value = keyStore.load();
|
|
332
375
|
if (!value) return null;
|
|
@@ -335,14 +378,15 @@ async function freshKey() {
|
|
|
335
378
|
if (next === true) { const promoted = { ...value, token: value.pending, renewedAt: new Date().toISOString() }; delete promoted.pending; keyStore.save(promoted); return promoted; }
|
|
336
379
|
if (next === false) { delete value.pending; keyStore.save(value); }
|
|
337
380
|
}
|
|
338
|
-
if (Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
|
|
381
|
+
if (value.identified && Date.now() - Date.parse(value.renewedAt ?? 0) < RENEW_EVERY_MS) return value;
|
|
339
382
|
const pending = `wt_${randomBytes(32).toString("base64url")}`;
|
|
340
383
|
keyStore.save({ ...value, pending });
|
|
341
384
|
try {
|
|
342
385
|
const renewUrl = `${new URL(value.url).origin}/api/cli/renew`;
|
|
343
|
-
const
|
|
386
|
+
const ids = computerIds();
|
|
387
|
+
const body = JSON.stringify({ token_hash: createHash("sha256").update(pending).digest("hex"), token_prefix: pending.slice(0, 11), ...ids });
|
|
344
388
|
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; }
|
|
389
|
+
if (response.ok) { const renewed = { ...value, token: pending, renewedAt: new Date().toISOString(), identified: true, profile: value.profile ?? ids.profile_id }; keyStore.save(renewed); return renewed; }
|
|
346
390
|
// Refused: drop the pending key only if the file still holds the value this renewal read.
|
|
347
391
|
if (response.status === 400 || response.status === 401) { const now = keyStore.load(); if (now?.token === value.token && now?.pending === pending) keyStore.save(value); }
|
|
348
392
|
} catch { /* offline: the pending key is settled next time */ }
|
|
@@ -395,6 +439,7 @@ async function bridge() {
|
|
|
395
439
|
/** THE HOOK Claude Code runs (`worktrust.mjs hook`): the key from the key file, handed to the session hook. */
|
|
396
440
|
async function hook() {
|
|
397
441
|
if (has("antigravity-stop")) return antigravityStop();
|
|
442
|
+
if (has("hermes") || has("goose")) return sessionEnd();
|
|
398
443
|
const key = await freshKey();
|
|
399
444
|
if (!key) process.exit(0);
|
|
400
445
|
const child = spawn(process.execPath, [join(HOME_DIR, "log-session.mjs"), ...args.filter((arg) => arg !== "hook")], { stdio: "inherit", env: { ...process.env, WORKTRUST_MCP_URL: key.url, WORKTRUST_MCP_TOKEN: key.token, ...(key.device ? { WORKTRUST_DEVICE_KEY: key.device } : {}) } });
|
|
@@ -424,6 +469,20 @@ async function antigravityStop() {
|
|
|
424
469
|
process.exit(0);
|
|
425
470
|
}
|
|
426
471
|
|
|
472
|
+
/**
|
|
473
|
+
* A SESSION-END HOOK THAT READS JSON AND ANSWERS JSON: Hermes Agent's (`hook --hermes`) and Goose's SessionEnd
|
|
474
|
+
* (`hook --goose`, an Open Plugins hook: JSON on stdin, run with sh -c), 2026-10-05. Hermes pipes a JSON payload (ids, the
|
|
475
|
+
* model, the platform; no message body) to a shell hook on every `on_session_end` and reads stdout as JSON
|
|
476
|
+
* (hermes-agent.nousresearch.com/docs/user-guide/features/hooks). This answers `{}` (nothing to change), reads and drops
|
|
477
|
+
* the payload, and runs the sweep detached: the session hook reads Hermes's own database (transcript-readers.mjs).
|
|
478
|
+
*/
|
|
479
|
+
async function sessionEnd() {
|
|
480
|
+
writeSync(1, "{}\n");
|
|
481
|
+
if (!process.stdin.isTTY) await new Promise((resolve) => { const timer = setTimeout(resolve, 2000); process.stdin.on("data", () => {}); process.stdin.on("end", () => { clearTimeout(timer); resolve(); }); process.stdin.on("error", () => { clearTimeout(timer); resolve(); }); });
|
|
482
|
+
try { spawn(process.execPath, [fileURLToPath(import.meta.url), "hook"], { detached: true, stdio: "ignore" }).unref(); } catch { /* the next run sweeps it */ }
|
|
483
|
+
process.exit(0);
|
|
484
|
+
}
|
|
485
|
+
|
|
427
486
|
/**
|
|
428
487
|
* `npx worktrust update` (owner, 2026-10-04): THIS COMPUTER ONTO THE NEWEST CLI, WITHOUT PAIRING IT
|
|
429
488
|
* AGAIN. `connect` mints a new key and so a new device row to merge; an update keeps the key and the
|
|
@@ -504,7 +563,7 @@ async function connect() {
|
|
|
504
563
|
// The device key: made here, its private half kept with the key, its public half bound at WorkTrust.
|
|
505
564
|
const device = direct ? null : generateKeyPairSync("ed25519");
|
|
506
565
|
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 } : {}) };
|
|
566
|
+
const extra = { apps, ...computerIds(), ...(device ? { device_key: device.publicKey.export({ format: "jwk" }).x } : {}) };
|
|
508
567
|
const pairing = useDevice() ? await pairByCode(host, extra) : await pairByBrowser(host, extra);
|
|
509
568
|
const { token } = pairing;
|
|
510
569
|
// ANOTHER ACCOUNT THAN BEFORE: told by the opaque account names. Unknown counts as another, the safe side.
|
|
@@ -514,7 +573,7 @@ async function connect() {
|
|
|
514
573
|
// Out first, then in: an app that already held a WorkTrust key moves to the new one.
|
|
515
574
|
await run(paths["setup-mcp.mjs"], ["--remove", "--write"], token, true);
|
|
516
575
|
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" }) });
|
|
576
|
+
keyStore.save({ url: MCP, token, renewedAt: new Date().toISOString(), coupledAt: new Date().toISOString(), identified: true, profile: computerIds().profile_id, account: pairing.account, accountRef: pairing.accountRef, device: device.privateKey.export({ format: "pem", type: "pkcs8" }) });
|
|
518
577
|
previousNote.erase();
|
|
519
578
|
ensureHome();
|
|
520
579
|
copyFileSync(fileURLToPath(import.meta.url), STABLE);
|
|
@@ -720,6 +779,7 @@ function status() {
|
|
|
720
779
|
try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
|
|
721
780
|
say(hook ? " Session hook: installed" : " Session hook: not installed");
|
|
722
781
|
if (existsSync(join(homedir(), ".codex"))) { const notify = codexNotify().text ?? ""; say(isOurNotify(notify) ? " Codex: wakes the session hook" : " Codex: does not wake the session hook (npx worktrust@latest update asks)"); }
|
|
782
|
+
if (existsSync(join(homedir(), ".hermes"))) { let text = ""; try { text = readFileSync(join(homedir(), ".hermes", "config.yaml"), "utf8"); } catch { /* none */ } say(/worktrust\.mjs\\?" hook --hermes/.test(text) ? " Hermes Agent: wakes the session hook" : " Hermes Agent: does not wake the session hook (npx worktrust@latest update writes it)"); }
|
|
723
783
|
if (antigravityHere()) say(agyHooks()?.worktrust ? " Antigravity: wakes the session hook" : " Antigravity: does not wake the session hook (npx worktrust@latest update asks)");
|
|
724
784
|
say(existsSync(join(HOME_DIR, "pinned")) ? " Counter: from the package, pinned" : existsSync(join(HOME_DIR, "count-behaviour.mjs")) ? " Counter: follows the site" : " Counter: not installed");
|
|
725
785
|
}
|