worktrust 0.6.7 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -5
- package/config-edits.mjs +69 -0
- package/count-behaviour.mjs +26 -97
- package/log-session.mjs +124 -117
- package/package.json +5 -1
- package/session-databases.mjs +308 -0
- package/setup-mcp.mjs +177 -12
- package/transcript-readers.mjs +217 -0
- package/worktrust.mjs +335 -104
|
@@ -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. 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, 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,8 +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");
|
|
73
|
+
const VIBE_DIR = process.env.VIBE_HOME?.trim() || join(home, ".vibe");
|
|
74
|
+
const GROK_DIR = process.env.GROK_HOME?.trim() || join(home, ".grok");
|
|
75
|
+
const ZED_DIR = platform() === "win32" ? join(process.env.APPDATA ?? home, "Zed") : join(home, ".config", "zed");
|
|
71
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");
|
|
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}` } };
|
|
72
174
|
|
|
73
175
|
/** Each client: where it lives, how it says "an HTTP MCP server with a header". */
|
|
74
176
|
const CLIENTS = [
|
|
@@ -77,6 +179,25 @@ const CLIENTS = [
|
|
|
77
179
|
{ key: "gemini", label: "Gemini CLI", present: () => existsSync(join(home, ".gemini")), file: join(home, ".gemini", "settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { httpUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
78
180
|
{ key: "copilot", label: "VS Code (Copilot agent mode)", present: () => existsSync(vscodeUser), file: join(vscodeUser, "mcp.json"), shape: "json", root: "servers", entry: BRIDGE ? { type: "stdio", ...stdio } : { type: "http", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
79
181
|
{ key: "windsurf", label: "Windsurf", present: () => existsSync(join(home, ".codeium", "windsurf")), file: join(home, ".codeium", "windsurf", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
182
|
+
// MISTRAL VIBE (2026-10-04, docs.mistral.ai/vibe/code/cli/mcp-servers): `[[mcp_servers]]` tables in
|
|
183
|
+
// ~/.vibe/config.toml (or $VIBE_HOME), each with a name, a transport and a url or a command.
|
|
184
|
+
{ key: "vibe", label: "Mistral Vibe", present: () => existsSync(VIBE_DIR), file: join(VIBE_DIR, "config.toml"), shape: "vibe", block: BRIDGE ? `\n[[mcp_servers]]\nname = "${NAME}"\ntransport = "stdio"\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[[mcp_servers]]\nname = "${NAME}"\ntransport = "streamable-http"\nurl = "${URL_}"\nheaders = { "Authorization" = "Bearer ${TOKEN}" }\n` },
|
|
185
|
+
// GOOGLE ANTIGRAVITY (2026-10-04, antigravity.google/docs/mcp/): the 2.0 app, the `agy` CLI and the IDE read
|
|
186
|
+
// ~/.gemini/config/mcp_config.json; a remote server is `serverUrl` with headers (`url` and `httpUrl` are not
|
|
187
|
+
// read), a local one `command` and `args`. Present when its config folder or one of its data folders is here.
|
|
188
|
+
{ key: "antigravity", label: "Antigravity", present: () => existsSync(join(home, ".gemini", "config")) || (() => { try { return readdirSync(join(home, ".gemini")).some((name) => /^antigravity/.test(name)); } catch { return false; } })(), file: join(home, ".gemini", "config", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
189
|
+
// GROK BUILD (2026-10-04, docs.x.ai/build/settings/reference): `[mcp_servers.<name>]` in ~/.grok/config.toml (or
|
|
190
|
+
// $GROK_HOME), `url` and `headers` for a remote server, `command` and `args` for a local one.
|
|
191
|
+
{ key: "grok", label: "Grok Build", present: () => existsSync(GROK_DIR), file: join(GROK_DIR, "config.toml"), shape: "toml", block: BRIDGE ? `\n[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[mcp_servers.${NAME}]\nurl = "${URL_}"\nheaders = { Authorization = "Bearer ${TOKEN}" }\n` },
|
|
192
|
+
// ZED (zed.dev/docs/ai/mcp): `context_servers` in its settings.json, `url` and `headers` for a remote server.
|
|
193
|
+
{ key: "zed", label: "Zed", present: () => existsSync(ZED_DIR), file: join(ZED_DIR, "settings.json"), shape: "json", root: "context_servers", entry: BRIDGE ? { ...stdio, env: {} } : { url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
194
|
+
// CLINE (docs.cline.bot/mcp): `mcpServers` in the extension's cline_mcp_settings.json; `type` must be
|
|
195
|
+
// "streamableHttp" exactly, or Cline falls back to SSE and the door answers 405.
|
|
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" },
|
|
80
201
|
{ key: "claude", label: "Claude Code", present: () => existsSync(join(home, ".claude.json")), file: join(home, ".claude.json"), shape: "claude" },
|
|
81
202
|
];
|
|
82
203
|
|
|
@@ -85,6 +206,19 @@ let touched = 0;
|
|
|
85
206
|
let standing = 0;
|
|
86
207
|
for (const client of CLIENTS) {
|
|
87
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
|
+
}
|
|
88
222
|
if (client.shape === "claude") {
|
|
89
223
|
// Claude Code owns this file and has its own verb for it, so this never edits the file: it RUNS
|
|
90
224
|
// the verb when the `claude` command is on this machine (2026-09-28; before, the line was only
|
|
@@ -112,7 +246,7 @@ for (const client of CLIENTS) {
|
|
|
112
246
|
}
|
|
113
247
|
const text = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
|
|
114
248
|
// With the bridge, coupled means the bridge is in AND no entry with a key in it is left beside it.
|
|
115
|
-
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_);
|
|
116
250
|
// THE WAY OUT, ON THE MACHINE. Uncoupling in the app ends the TOKEN; the config that names the
|
|
117
251
|
// door stays behind on every client here, so the next person to read this file believes the
|
|
118
252
|
// machine is coupled. `--remove` takes the entry out; the account side is a separate act and
|
|
@@ -121,15 +255,27 @@ for (const client of CLIENTS) {
|
|
|
121
255
|
if (!already) { console.log(` – ${client.label}: does not name the door`); continue; }
|
|
122
256
|
if (client.shape === "json") {
|
|
123
257
|
let config = {};
|
|
124
|
-
|
|
258
|
+
// A file that does not parse (comments, a trailing comma: Zed and VS Code allow both) is never rewritten.
|
|
259
|
+
try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { console.log(` ! ${client.label}: ${client.file} is not plain JSON; take the "${NAME}" entry out by hand`); continue; }
|
|
125
260
|
const root = config[client.root] ?? {};
|
|
126
261
|
for (const [key, entry] of Object.entries(root)) {
|
|
127
262
|
const named = JSON.stringify(entry ?? {}).includes(URL_) || /worktrust\.mjs/.test(JSON.stringify(entry ?? {}));
|
|
128
263
|
if (key === NAME || named) delete root[key];
|
|
129
264
|
}
|
|
130
265
|
config[client.root] = root;
|
|
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); }
|
|
272
|
+
} else if (client.shape === "vibe") {
|
|
273
|
+
// Vibe's servers are an ARRAY of tables, so a block is found by what it says, not by its header: every
|
|
274
|
+
// `[[mcp_servers]]` that names ours, the door, or the bridge goes, up to the next header at a line start.
|
|
275
|
+
const text = readFileSync(client.file, "utf8");
|
|
276
|
+
const stripped = text.replace(/\n?\[\[mcp_servers\]\][\s\S]*?(?=\n\[|$)/g, (block) => (block.includes(`name = "${NAME}"`) || block.includes(URL_) || /worktrust\.mjs/.test(block) ? "" : block));
|
|
131
277
|
console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
|
|
132
|
-
if (WRITE) writeFileSync(client.file,
|
|
278
|
+
if (WRITE) writeFileSync(client.file, stripped);
|
|
133
279
|
} else if (client.shape === "toml") {
|
|
134
280
|
const text = readFileSync(client.file, "utf8");
|
|
135
281
|
// The block and nothing after it: from its own header to the next one, or the end.
|
|
@@ -143,15 +289,34 @@ for (const client of CLIENTS) {
|
|
|
143
289
|
touched += 1;
|
|
144
290
|
continue;
|
|
145
291
|
}
|
|
146
|
-
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; }
|
|
147
293
|
if (client.shape === "json") {
|
|
148
294
|
let config = {};
|
|
149
|
-
|
|
295
|
+
// NEVER OVERWRITE WHAT DOES NOT PARSE (2026-10-04): Zed's and VS Code's settings allow comments and trailing
|
|
296
|
+
// commas, and reading such a file as empty would write the person's settings away. An empty or absent file is
|
|
297
|
+
// a new one; anything else that fails to parse is left alone and the entry is printed to add by hand.
|
|
298
|
+
const raw = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
|
|
299
|
+
try { config = raw.trim() ? JSON.parse(raw) : {}; } catch {
|
|
300
|
+
console.log(` ! ${client.label}: ${client.file} is not plain JSON (comments?), so it is left as it is. Add this under "${client.root}" yourself:\n "${NAME}": ${JSON.stringify(client.entry).replace(/Bearer [^"]+/, "Bearer <token>")}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
150
303
|
config[client.root] = { ...(config[client.root] ?? {}), [NAME]: client.entry };
|
|
151
|
-
console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}`);
|
|
152
|
-
if (WRITE) {
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
318
|
+
} else if (client.shape === "toml" || client.shape === "vibe") {
|
|
319
|
+
console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → ${client.shape === "vibe" ? `[[mcp_servers]] name = "${NAME}"` : `[mcp_servers.${NAME}]`}`);
|
|
155
320
|
if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, (existsSync(client.file) ? readFileSync(client.file, "utf8") : "") + client.block); touched += 1; }
|
|
156
321
|
}
|
|
157
322
|
}
|