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.
@@ -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 claudeOnPath = () => spawnSync(platform() === "win32" ? "where" : "which", ["claude"], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
71
+ const onPath = (command) => spawnSync(platform() === "win32" ? "where" : "which", [command], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
72
+ const claudeOnPath = () => onPath("claude");
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
- try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { config = {}; }
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, JSON.stringify(config, null, 2) + "\n");
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
- try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { config = {}; }
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) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); touched += 1; }
153
- } else if (client.shape === "toml") {
154
- console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → [mcp_servers.${NAME}]`);
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
  }