worktrust 0.6.10 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,12 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
62
62
  same way as Hermes: clocks, roles, models and token counts, never a message. OpenClaw's Talk
63
63
  sessions are sent as voice, its chat-app sessions as remote; subagents and scheduled runs are
64
64
  not counted.
65
+ 9. **Cursor and GitHub Copilot in VS Code, read in place.** Cursor's chats are read from its own database
66
+ (`User/globalStorage/state.vscdb`), each message's type, clock, model and token counts through SQLite's own
67
+ `json_extract`, never the text. Copilot's are read from VS Code's `chatSessions` files: each request's two clocks
68
+ (asked, answered), its model and its token counts; the messages and responses are parsed with the file and never
69
+ kept or sent. Neither app runs a hook WorkTrust can give it, so the WorkTrust door, which both start when they
70
+ open, starts the session hook in the background, at most once an hour.
65
71
 
66
72
  ```
67
73
  npx worktrust history send this computer's earlier sessions later (asks first)
@@ -94,7 +100,7 @@ which would delete every password on the computer, and many people rightly keep
94
100
 
95
101
  | | |
96
102
  |---|---|
97
- | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files and the session databases of Hermes, Goose, OpenCode and OpenClaw on this computer to measure durations and token counts. |
103
+ | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files and the session databases of Hermes, Goose, OpenCode and OpenClaw on this computer to measure durations and token counts. |
98
104
  | **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
99
105
  | **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
100
106
  | **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
package/log-session.mjs CHANGED
@@ -202,7 +202,7 @@ const excluded = (path) => excludes.some((text) => text && path.includes(text));
202
202
  */
203
203
  const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
204
204
  const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
205
- const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw"]; // the registry's keys, the name each line carries
205
+ const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
206
206
  const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
207
207
  /** The readers go where the hook and the counter run: copied from beside this file, else (or for a newer counter) from the deployment. */
208
208
  async function installReaders(origin, fromNetwork = false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  * like transcript-readers.mjs; without it, or without node:sqlite, these clients are not read and nothing else changes.
9
9
  */
10
10
  import { createRequire } from "node:module";
11
- import { readdirSync, statSync } from "node:fs";
11
+ import { readFileSync, readdirSync, statSync } from "node:fs";
12
12
  import * as zlib from "node:zlib"; // zstd since Node 22.15; older Node reads OpenClaw's uncompressed rows only
13
13
  import { homedir, platform } from "node:os";
14
14
  import { join } from "node:path";
@@ -288,6 +288,144 @@ export function openclawLines(pseudo) {
288
288
  }
289
289
  // ── end openclaw session reader ──
290
290
 
291
+ // ── cursor session reader ──
292
+ /**
293
+ * CURSOR, READ FROM ITS OWN DATABASE (2026-10-06, the owner: "a person who works in Cursor all day has no measured
294
+ * hours"). Cursor keeps every chat ("composer") in one SQLite file, `User/globalStorage/state.vscdb` (~/Library/Application
295
+ * Support/Cursor on macOS, %APPDATA%\Cursor on Windows, ~/.config/Cursor on Linux), table cursorDiskKV: `composerData:<id>`
296
+ * per chat and `bubbleId:<id>:<bubble>` per message. Each message is read through json_extract INSIDE SQLite, its type
297
+ * (1 the person, 2 the model), its clock and its token counts, so no text ever leaves the database. A model message that
298
+ * carries a tool call is the loop's own step. The folder a chat belongs to is read from each workspace's own state
299
+ * (`composer.composerData`); a chat in no workspace has none. Needs node:sqlite (Node 22.5+).
300
+ */
301
+ const userDirs = (app) => {
302
+ const base = platform() === "win32" && process.env.APPDATA ? process.env.APPDATA : platform() === "darwin" ? join(homedir(), "Library", "Application Support") : join(homedir(), ".config");
303
+ return [join(base, app, "User")];
304
+ };
305
+ const folderOf = (uri) => { try { return typeof uri === "string" && uri.startsWith("file://") ? decodeURIComponent(new URL(uri).pathname).replace(/^\/([A-Za-z]:)/, "$1") : null; } catch { return null; } };
306
+ export function cursorHomes(given) { return given ? [given.replace(/^~(?=\/|$)/, homedir())] : userDirs("Cursor"); }
307
+ /** Which folder each composer belongs to, from every workspace's own state. */
308
+ function cursorFolders(home) {
309
+ const folders = new Map();
310
+ let dirs = []; try { dirs = readdirSync(join(home, "workspaceStorage")); } catch { return folders; }
311
+ for (const dir of dirs) {
312
+ let folder = null; try { folder = folderOf(JSON.parse(readFileSync(join(home, "workspaceStorage", dir, "workspace.json"), "utf8")).folder); } catch { continue; }
313
+ if (!folder) continue;
314
+ const db = openDatabase(join(home, "workspaceStorage", dir, "state.vscdb"));
315
+ if (!db) continue;
316
+ try {
317
+ const row = db.prepare("select json_extract(value, '$.allComposers') as ids from ItemTable where key = 'composer.composerData'").get();
318
+ for (const one of JSON.parse(row?.ids ?? "[]")) if (one?.composerId) folders.set(String(one.composerId), folder);
319
+ } catch { /* an older schema */ } finally { db.close(); }
320
+ }
321
+ return folders;
322
+ }
323
+ export function* cursorSessions(homes) {
324
+ for (const home of homes) {
325
+ const file = join(home, "globalStorage", "state.vscdb");
326
+ try { if (!statSync(file).isFile()) continue; } catch { continue; }
327
+ const folders = cursorFolders(home);
328
+ const db = openDatabase(file);
329
+ if (!db) continue;
330
+ try {
331
+ const rows = db.prepare("select substr(key, 14) as id, json_extract(value, '$.lastUpdatedAt') as last from cursorDiskKV where key like 'composerData:%'").all();
332
+ for (const row of rows) {
333
+ if (!/^[A-Za-z0-9_.:-]{1,120}$/.test(String(row.id))) continue;
334
+ yield { sessionId: String(row.id), file: `cursor:${file}#${row.id}`, cwd: folders.get(String(row.id)) ?? null, writtenAt: Number(row.last) || 0 };
335
+ }
336
+ } catch { /* an older or foreign schema: not read */ } finally { db.close(); }
337
+ }
338
+ }
339
+ export function cursorLines(pseudo) {
340
+ const { file, id } = splitSession(pseudo);
341
+ const db = openDatabase(file);
342
+ if (!db) return null;
343
+ try {
344
+ const chat = db.prepare("select json_extract(value, '$.modelConfig.modelName') as model from cursorDiskKV where key = ?").get(`composerData:${id}`);
345
+ const bubbles = db.prepare(`select json_extract(value, '$.type') as type, json_extract(value, '$.createdAt') as at,
346
+ json_extract(value, '$.tokenCount.inputTokens') as input, json_extract(value, '$.tokenCount.outputTokens') as output,
347
+ json_extract(value, '$.toolFormerData') is not null as tool
348
+ from cursorDiskKV where key > ? and key < ? and json_extract(value, '$.createdAt') is not null`).all(`bubbleId:${id}:`, `bubbleId:${id};`);
349
+ const model = cleanModel(chat?.model);
350
+ const lines = [];
351
+ for (const bubble of bubbles.sort((a, b) => String(a.at).localeCompare(String(b.at)))) {
352
+ const timestamp = new Date(bubble.at).toISOString();
353
+ if (Number(bubble.type) === 1) lines.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
354
+ else if (Number(bubble.type) === 2) {
355
+ const usage = Number(bubble.input) || Number(bubble.output) ? { usage: { input_tokens: Number(bubble.input) || 0, output_tokens: Number(bubble.output) || 0 } } : {};
356
+ lines.push({ type: "assistant", timestamp, message: { model, ...usage, content: bubble.tool ? [{ type: "tool_use", name: "tool", input: {} }] : [] } });
357
+ }
358
+ }
359
+ return lines;
360
+ } catch { return null; } finally { db.close(); }
361
+ }
362
+ // ── end cursor session reader ──
363
+
364
+ // ── copilot session reader ──
365
+ /**
366
+ * GITHUB COPILOT IN VS CODE (2026-10-06). VS Code keeps each Copilot chat as a file: `chatSessions/<id>.jsonl` under
367
+ * every workspace's storage (`User/workspaceStorage/<hash>/`, its folder in `workspace.json`) and, for a window with no
368
+ * folder, under `User/globalStorage/emptyWindowChatSessions/`. A .jsonl file is a log of edits (kind 0 the whole
369
+ * session, 1 a value set at a path, 2 items appended at a path); an older .json file is the session whole. Each
370
+ * request is read for its clocks and counts only: when it was asked (`timestamp`, the person's turn) and when the
371
+ * model finished (`modelState.completedAt`), the model it resolved to and the prompt and output tokens. The messages,
372
+ * the responses and the tool calls' text are parsed with the file and never kept. Agent mode runs a request's tools
373
+ * between those two clocks without stamping each step, so under the same rule as every other client that span counts
374
+ * up to the idle cap, as one gap.
375
+ */
376
+ export function copilotHomes(given) { return given ? [given.replace(/^~(?=\/|$)/, homedir())] : [...userDirs("Code"), ...userDirs("Code - Insiders")]; }
377
+ export function* copilotSessions(homes) {
378
+ for (const home of homes) {
379
+ const places = [];
380
+ try { for (const dir of readdirSync(join(home, "workspaceStorage"))) places.push({ dir: join(home, "workspaceStorage", dir, "chatSessions"), workspace: join(home, "workspaceStorage", dir, "workspace.json") }); } catch { /* none */ }
381
+ places.push({ dir: join(home, "globalStorage", "emptyWindowChatSessions"), workspace: null });
382
+ for (const place of places) {
383
+ let names = []; try { names = readdirSync(place.dir); } catch { continue; }
384
+ let cwd = null; if (place.workspace) { try { cwd = folderOf(JSON.parse(readFileSync(place.workspace, "utf8")).folder); } catch { /* none */ } }
385
+ for (const name of names) {
386
+ const id = name.replace(/\.jsonl?$/, "");
387
+ if (!/\.jsonl?$/.test(name) || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id)) continue;
388
+ let writtenAt = 0; try { writtenAt = statSync(join(place.dir, name)).mtimeMs; } catch { continue; }
389
+ yield { sessionId: id, file: `copilot:${join(place.dir, name)}#${id}`, cwd, writtenAt };
390
+ }
391
+ }
392
+ }
393
+ }
394
+ /** A session file back into the session: the whole object, or the edit log replayed. */
395
+ function copilotSession(text, jsonl) {
396
+ if (!jsonl) return JSON.parse(text);
397
+ let session = null;
398
+ for (const raw of text.split("\n")) {
399
+ if (!raw.trim()) continue;
400
+ let edit; try { edit = JSON.parse(raw); } catch { continue; }
401
+ if (edit.kind === 0) { session = edit.v; continue; }
402
+ if (!session || !Array.isArray(edit.k) || edit.k.length === 0) continue;
403
+ const parent = edit.k.slice(0, -1).reduce((at, key) => (at == null ? at : at[key]), session);
404
+ const key = edit.k.at(-1);
405
+ if (parent == null || typeof parent !== "object") continue;
406
+ if (edit.kind === 1) parent[key] = edit.v;
407
+ else if (edit.kind === 2) { if (!Array.isArray(parent[key])) parent[key] = []; parent[key].push(...(Array.isArray(edit.v) ? edit.v : [edit.v])); }
408
+ }
409
+ return session;
410
+ }
411
+ export function copilotLines(pseudo) {
412
+ const { file } = splitSession(pseudo);
413
+ let session; try { session = copilotSession(readFileSync(file, "utf8"), file.endsWith(".jsonl")); } catch { return null; }
414
+ const lines = [];
415
+ for (const request of Array.isArray(session?.requests) ? session.requests : []) {
416
+ const asked = Number(request?.timestamp);
417
+ if (!Number.isFinite(asked) || asked <= 0) continue;
418
+ lines.push({ type: "user", timestamp: new Date(asked).toISOString(), message: { content: [{ type: "text" }] } });
419
+ const done = Number(request?.modelState?.completedAt) || (Number(request?.result?.timings?.totalElapsed) ? asked + Number(request.result.timings.totalElapsed) : 0);
420
+ if (!done || done < asked) continue;
421
+ const meta = request?.result?.metadata ?? {};
422
+ const usage = Number(meta.promptTokens) || Number(meta.outputTokens) ? { usage: { input_tokens: Number(meta.promptTokens) || 0, output_tokens: Number(meta.outputTokens) || 0 } } : {};
423
+ lines.push({ type: "assistant", timestamp: new Date(done).toISOString(), message: { model: cleanModel(String(meta.resolvedModel ?? request?.modelId ?? "").replace(/^copilot\//, "")), ...usage, content: [] } });
424
+ }
425
+ return lines;
426
+ }
427
+ // ── end copilot session reader ──
428
+
291
429
  // ── database clients ──
292
430
  /** The clients whose sessions live in a database, one registry: the hook and the counter ask it, not each client. */
293
431
  const DATABASE_CLIENTS = {
@@ -295,6 +433,8 @@ const DATABASE_CLIENTS = {
295
433
  goose: { homes: gooseHomes, sessions: gooseSessions, lines: gooseLines },
296
434
  opencode: { homes: opencodeHomes, sessions: opencodeSessions, lines: opencodeLines },
297
435
  openclaw: { homes: openclawHomes, sessions: openclawSessions, lines: openclawLines },
436
+ cursor: { homes: cursorHomes, sessions: cursorSessions, lines: cursorLines },
437
+ copilot: { homes: copilotHomes, sessions: copilotSessions, lines: copilotLines },
298
438
  };
299
439
  export const DATABASE_SESSION = new RegExp(`^(${Object.keys(DATABASE_CLIENTS).join("|")}):.+#[A-Za-z0-9_.:-]{1,120}$`);
300
440
  /** Every attended session of every database client; `given` names a client's own home (a test), `onlyGiven` reads no other. */
package/worktrust.mjs CHANGED
@@ -65,7 +65,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
65
65
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
66
66
  const has = (name) => args.includes(`--${name}`);
67
67
  /** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
68
- const CLI_VERSION = "0.6.10";
68
+ const CLI_VERSION = "0.6.11";
69
69
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
70
70
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
71
71
  const HOME_DIR = join(homedir(), ".worktrust");
@@ -177,10 +177,14 @@ async function pairByBrowser(host, extra) {
177
177
  const state = randomBytes(24).toString("base64url");
178
178
  let deliver;
179
179
  const answer = new Promise((resolve) => { deliver = resolve; });
180
+ // Where the browser goes once this terminal has its code (0.6.11, owner 2026-10-06): WorkTrust's own "connected" page in
181
+ // the house style, not a bare page on 127.0.0.1. Known once the pairing has its code; until then the plain page.
182
+ let doneUrl = null;
180
183
  const server = createServer((request, response) => {
181
184
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
182
185
  if (url.pathname !== "/callback" || url.searchParams.get("state") !== state || !url.searchParams.get("code")) { response.writeHead(404).end(); return; }
183
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(DONE_PAGE);
186
+ if (doneUrl) response.writeHead(303, { location: doneUrl, "cache-control": "no-store" }).end();
187
+ else response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(DONE_PAGE);
184
188
  deliver(url.searchParams.get("code"));
185
189
  });
186
190
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -189,6 +193,7 @@ async function pairByBrowser(host, extra) {
189
193
  if (!pairing) { server.close(); fail(`cannot reach ${ORIGIN}.`); }
190
194
  if (pairing.status !== 200) { server.close(); fail(pairing.json.error === "rate_limited" ? "too many attempts from this network; wait a minute." : `pairing refused (${pairing.json.error ?? pairing.status}).`); }
191
195
  const { device_code: secret, authorize_url: url, user_code: shown, expires_in: lifetime = 600, features = [] } = pairing.json;
196
+ doneUrl = `${ORIGIN}/connect/computer/done?code=${encodeURIComponent(shown)}`;
192
197
  if (!features.includes("loopback")) { server.close(); fail(`${ORIGIN} is older than this command. Use the command WorkTrust shows under Sources → Add a computer.`); }
193
198
  say();
194
199
  say(` Your code: ${shown} (the WorkTrust page shows the same; approve only if it matches)`);
@@ -403,8 +408,21 @@ async function freshKey() {
403
408
  * the key from the key file, answers on stdout. It names the app that started it (from `initialize`),
404
409
  * so WorkTrust still sees Claude Code or Cursor, not the bridge.
405
410
  */
411
+ /**
412
+ * A SWEEP WHEN AN AI APP OPENS THE DOOR (0.6.11, owner 2026-10-06). Cursor and VS Code run no hook WorkTrust can give
413
+ * them, so the session hook never woke for a person who works there all day; both do start this bridge whenever they
414
+ * open. So the bridge starts the sweep once, detached and silent (stdout stays the MCP channel), at most once an hour.
415
+ */
416
+ function sweepAtMostHourly() {
417
+ const stamp = join(HOME_DIR, "bridge-swept-at");
418
+ try { if (Date.now() - Number(readFileSync(stamp, "utf8")) < 3_600_000) return; } catch { /* never swept here */ }
419
+ try { mkdirSync(HOME_DIR, { recursive: true }); writeFileSync(stamp, String(Date.now())); } catch { return; }
420
+ try { spawn(process.execPath, [fileURLToPath(import.meta.url), "hook"], { detached: true, stdio: "ignore" }).unref(); } catch { /* the next start sweeps */ }
421
+ }
422
+
406
423
  async function bridge() {
407
424
  let key = await freshKey();
425
+ if (key) sweepAtMostHourly();
408
426
  const answer = (id, message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32603, message } })}\n`);
409
427
  let session = null;
410
428
  let agent = "worktrust-bridge";