worktrust 0.6.10 → 0.6.12
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 +10 -2
- package/log-session.mjs +116 -19
- package/package.json +1 -1
- package/session-databases.mjs +142 -1
- package/worktrust.mjs +20 -2
package/README.md
CHANGED
|
@@ -36,7 +36,9 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
|
|
|
36
36
|
this computer and asks `Send these as history? [y/N]`. Enter sends nothing. What goes is hours
|
|
37
37
|
and tokens per day, never text, and WorkTrust shows it as earlier work, never as verified hours.
|
|
38
38
|
Nothing from before the coupling is ever sent without that yes: the session hook starts at the
|
|
39
|
-
moment of coupling.
|
|
39
|
+
moment of coupling. Beside the seconds, each day says what they were (the model answering, tools
|
|
40
|
+
running, your own turns, pauses) and what the session's subagents did on their own clocks, as
|
|
41
|
+
counts; a subagent's hour is shown as an agent-hour and never as one of yours.
|
|
40
42
|
5. **Codex only if you say yes.** With Codex on this computer it asks `Let Codex wake the session
|
|
41
43
|
hook? [y/N]` and, on a yes, sets the one `notify` line in `~/.codex/config.toml` (the file is
|
|
42
44
|
kept as `config.toml.worktrust-backup` first). Another program's `notify` is never replaced.
|
|
@@ -62,6 +64,12 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
|
|
|
62
64
|
same way as Hermes: clocks, roles, models and token counts, never a message. OpenClaw's Talk
|
|
63
65
|
sessions are sent as voice, its chat-app sessions as remote; subagents and scheduled runs are
|
|
64
66
|
not counted.
|
|
67
|
+
9. **Cursor and GitHub Copilot in VS Code, read in place.** Cursor's chats are read from its own database
|
|
68
|
+
(`User/globalStorage/state.vscdb`), each message's type, clock, model and token counts through SQLite's own
|
|
69
|
+
`json_extract`, never the text. Copilot's are read from VS Code's `chatSessions` files: each request's two clocks
|
|
70
|
+
(asked, answered), its model and its token counts; the messages and responses are parsed with the file and never
|
|
71
|
+
kept or sent. Neither app runs a hook WorkTrust can give it, so the WorkTrust door, which both start when they
|
|
72
|
+
open, starts the session hook in the background, at most once an hour.
|
|
65
73
|
|
|
66
74
|
```
|
|
67
75
|
npx worktrust history send this computer's earlier sessions later (asks first)
|
|
@@ -94,7 +102,7 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
94
102
|
|
|
95
103
|
| | |
|
|
96
104
|
|---|---|
|
|
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. |
|
|
105
|
+
| **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
106
|
| **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
107
|
| **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
108
|
| **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
|
package/log-session.mjs
CHANGED
|
@@ -129,6 +129,14 @@ const MIN_SECONDS = 60;
|
|
|
129
129
|
* guessing where one piece of work ended and the next began.
|
|
130
130
|
*/
|
|
131
131
|
const MAX_SECONDS = 28800;
|
|
132
|
+
/** The door's ceilings for the layers (a day) and for a day's subagents: the same bounds its columns check. */
|
|
133
|
+
const DAY_SECONDS = 86400;
|
|
134
|
+
/** A tool gap is tool time up to thirty minutes (proposed, owner 2026-10-06); past that it is a wait, counted as idle. */
|
|
135
|
+
const TOOL_CAP = 1800;
|
|
136
|
+
/** Tools whose result waits for the person: the gap to it is the person's time. Names only; nothing of the call is read. */
|
|
137
|
+
const WAITS_FOR_PERSON = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
138
|
+
const AGENT_RUNS_MAX = 10000;
|
|
139
|
+
const AGENT_SECONDS_MAX = 2592000;
|
|
132
140
|
|
|
133
141
|
/**
|
|
134
142
|
* WHEN THE HOOK RUNS. SessionEnd alone was a bet that sessions end, and a session that stays open
|
|
@@ -202,7 +210,7 @@ const excluded = (path) => excludes.some((text) => text && path.includes(text));
|
|
|
202
210
|
*/
|
|
203
211
|
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
212
|
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
|
|
213
|
+
const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
|
|
206
214
|
const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
|
|
207
215
|
/** 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
216
|
async function installReaders(origin, fromNetwork = false) {
|
|
@@ -332,7 +340,7 @@ function stretchesOf(file, since) {
|
|
|
332
340
|
const at = Date.parse(line.timestamp);
|
|
333
341
|
if (!Number.isFinite(at)) continue;
|
|
334
342
|
if (since && at <= since) continue;
|
|
335
|
-
messages.push(
|
|
343
|
+
messages.push(messageOf(line, at));
|
|
336
344
|
}
|
|
337
345
|
if (messages.length === 0) return [];
|
|
338
346
|
messages.sort((a, b) => a.at - b.at);
|
|
@@ -342,9 +350,77 @@ function stretchesOf(file, since) {
|
|
|
342
350
|
if (!byDay.has(day)) byDay.set(day, []);
|
|
343
351
|
byDay.get(day).push(message);
|
|
344
352
|
}
|
|
345
|
-
|
|
353
|
+
const agents = agentsByDay(file, since);
|
|
354
|
+
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null }; }).filter(Boolean);
|
|
346
355
|
}
|
|
347
356
|
|
|
357
|
+
/** One transcript line as the measure reads it: the clock, the role, what KIND of message it is (for the layers), its counts. */
|
|
358
|
+
const messageOf = (line, at) => {
|
|
359
|
+
const content = line.message?.content ?? null;
|
|
360
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
361
|
+
return {
|
|
362
|
+
at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content,
|
|
363
|
+
// A tool's answer comes back on the user role; a line a reader marks `agentic` is an agent run (Copilot's asked → completed).
|
|
364
|
+
kind: line.type === "assistant" ? "assistant" : blocks.some((block) => block && block.type === "tool_result") ? "tool_result" : "user",
|
|
365
|
+
toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
|
|
366
|
+
// A tool that waits for the PERSON (a question, a plan to approve): the gap to its result is the person's, not the tool's. The name is read, never the input.
|
|
367
|
+
waitsForPerson: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use" && WAITS_FOR_PERSON.has(String(block.name))),
|
|
368
|
+
agentic: line.agentic === true,
|
|
369
|
+
};
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* SUBAGENTS AS AGENT TIME, NEVER AS HOURS (owner, 2026-10-06). Claude Code writes each subagent's transcript beside its
|
|
374
|
+
* session (`<session>/subagents/*.jsonl`: 279 of them beside 48 sessions on the computer this was built on), and the
|
|
375
|
+
* sweep read only the sessions, so delegated work was invisible. Now each run is read for its clocks and sent as
|
|
376
|
+
* METADATA beside the parent's day: how many runs started that day, their own measured seconds (the same gap rule, the
|
|
377
|
+
* same cap), and how many were open at once at the busiest moment. None of it enters `seconds`: four agents working an
|
|
378
|
+
* hour are four agent-hours and one hour of the person's, and the record says which is which. Only Claude Code keeps
|
|
379
|
+
* subagents this way; every other client answers an empty map.
|
|
380
|
+
*/
|
|
381
|
+
function subagentFiles(file) {
|
|
382
|
+
if (!/\.jsonl$/.test(String(file)) || CODEX_ROLLOUT.test(file) || ANTIGRAVITY_TRANSCRIPT.test(file) || DATABASE_SESSION.test(file)) return [];
|
|
383
|
+
const dir = join(String(file).replace(/\.jsonl$/, ""), "subagents");
|
|
384
|
+
try { return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name)); } catch { return []; }
|
|
385
|
+
}
|
|
386
|
+
function agentsByDay(file, since) {
|
|
387
|
+
const byDay = new Map();
|
|
388
|
+
for (const sub of subagentFiles(file)) {
|
|
389
|
+
const lines = parsedLines(sub);
|
|
390
|
+
if (!lines) continue;
|
|
391
|
+
const stamps = [];
|
|
392
|
+
let started = Infinity;
|
|
393
|
+
for (const line of lines) {
|
|
394
|
+
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
395
|
+
const at = Date.parse(line.timestamp);
|
|
396
|
+
if (!Number.isFinite(at)) continue;
|
|
397
|
+
started = Math.min(started, at);
|
|
398
|
+
if (!(since && at <= since)) stamps.push(at);
|
|
399
|
+
}
|
|
400
|
+
// A RUN IS COUNTED ONCE, on the side of the watermark where it STARTED (review, 2026-10-06): a run that straddles the
|
|
401
|
+
// watermark was sent with the day it began and is not a second run on the next sweep; what it did after the
|
|
402
|
+
// watermark stays unsent, which is the safe direction.
|
|
403
|
+
if (stamps.length === 0 || (since && started <= since)) continue;
|
|
404
|
+
stamps.sort((a, b) => a - b);
|
|
405
|
+
// A run is counted on the day it started; its seconds go with it, the same rule as the parent's.
|
|
406
|
+
const day = new Date(stamps[0]).toISOString().slice(0, 10);
|
|
407
|
+
let seconds = 0;
|
|
408
|
+
for (let i = 1; i < stamps.length; i += 1) seconds += Math.min(IDLE_CAP, Math.round((stamps[i] - stamps[i - 1]) / 1000));
|
|
409
|
+
if (!byDay.has(day)) byDay.set(day, { runs: 0, seconds: 0, spans: [] });
|
|
410
|
+
const entry = byDay.get(day);
|
|
411
|
+
entry.runs += 1; entry.seconds += seconds; entry.spans.push([stamps[0], stamps[stamps.length - 1]]);
|
|
412
|
+
}
|
|
413
|
+
for (const entry of byDay.values()) entry.peak = peakOf(entry.spans);
|
|
414
|
+
return byDay;
|
|
415
|
+
}
|
|
416
|
+
/** The most spans open at one instant; a span that ends where another starts does not overlap it. */
|
|
417
|
+
const peakOf = (spans) => {
|
|
418
|
+
const events = spans.flatMap(([from, to]) => [[from, 1], [to, -1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
419
|
+
let open = 0, peak = 0;
|
|
420
|
+
for (const [, delta] of events) { open += delta; peak = Math.max(peak, open); }
|
|
421
|
+
return peak;
|
|
422
|
+
};
|
|
423
|
+
|
|
348
424
|
/**
|
|
349
425
|
* IS THIS A HUMAN TURN? (2026-09-20)
|
|
350
426
|
*
|
|
@@ -387,7 +463,35 @@ function measure(cwd, messages) {
|
|
|
387
463
|
}
|
|
388
464
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
389
465
|
const tokens = tokensOf(messages);
|
|
390
|
-
return { cwd, seconds: measured, ...tokens, exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
|
|
466
|
+
return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* HOW THE SECONDS WERE SPENT (owner, 2026-10-06: "observed time → AI active → agent-directed → verified work activity").
|
|
471
|
+
* `seconds` keeps its exact meaning above; this reads the same gaps a second time and says what each one WAS, by the
|
|
472
|
+
* message that ends it: the model's message (model time, capped as before), a tool's result or an agent run (tool time,
|
|
473
|
+
* NOT capped at five minutes, because a test suite that ran twenty minutes was twenty minutes of the agent's work; bounded
|
|
474
|
+
* by the stretch's own ceiling), or the person's next turn (human time, capped). What a cap cut off is idle, counted as
|
|
475
|
+
* idle and never as work. The four sum to the stretch's span, so nothing is hidden and nothing is double-counted.
|
|
476
|
+
*/
|
|
477
|
+
function timeLayers(messages) {
|
|
478
|
+
const split = { model: 0, tool: 0, human: 0, idle: 0 };
|
|
479
|
+
for (let i = 1; i < messages.length; i += 1) {
|
|
480
|
+
const gap = Math.max(0, Math.round((messages[i].at - messages[i - 1].at) / 1000));
|
|
481
|
+
const next = messages[i], prev = messages[i - 1];
|
|
482
|
+
// WAITS FOR THE PERSON ARE NOT TOOL TIME (review, 2026-10-06). A tool's result arrives when the tool is done, or when the
|
|
483
|
+
// person answered a question, approved a plan, or sat on a permission prompt. The first two are named by the tool and
|
|
484
|
+
// counted as human time; the third is invisible in the transcript, so a tool gap is counted as tool time only up to
|
|
485
|
+
// TOOL_CAP, and the rest as idle: a tool runs minutes, a person away from the keyboard does not.
|
|
486
|
+
const tool = (next.kind === "tool_result" || next.agentic || (prev.toolUse && next.kind === "assistant")) && !prev.waitsForPerson;
|
|
487
|
+
// ONE DEFINITION OF A HUMAN TURN (review, 2026-10-06): the same `isHumanTurn` that counts `exchanges`; a user line that
|
|
488
|
+
// is not one (a meta line the client injected, a textless line) is the loop's own and counts as model time.
|
|
489
|
+
const human = !tool && ((prev.waitsForPerson && next.kind === "tool_result") || (next.kind === "user" && !next.meta && isHumanTurn(next.content)));
|
|
490
|
+
const counted = Math.min(tool ? TOOL_CAP : IDLE_CAP, gap);
|
|
491
|
+
split[tool ? "tool" : human ? "human" : "model"] += counted;
|
|
492
|
+
split.idle += gap - counted;
|
|
493
|
+
}
|
|
494
|
+
return split;
|
|
391
495
|
}
|
|
392
496
|
|
|
393
497
|
/**
|
|
@@ -455,6 +559,10 @@ function payloadFor(stretch) {
|
|
|
455
559
|
...(repo ? { repo } : {}),
|
|
456
560
|
...(touched.shas.length > 0 ? { commits: touched.shas.slice(0, 50) } : {}),
|
|
457
561
|
...(stretch.ref ? { stretch_ref: stretch.ref } : {}), ...(stretch.steered ? { steered_from: stretch.steered } : {}),
|
|
562
|
+
// THE LAYERS (2026-10-06), only on a measured stretch, each bounded by a day: what the seconds were. `seconds` above is untouched.
|
|
563
|
+
...(stretch.seconds !== null && stretch.layers ? { model_seconds: Math.min(DAY_SECONDS, stretch.layers.model), tool_seconds: Math.min(DAY_SECONDS, stretch.layers.tool), human_seconds: Math.min(DAY_SECONDS, stretch.layers.human), idle_seconds: Math.min(DAY_SECONDS, stretch.layers.idle) } : {}),
|
|
564
|
+
// THE SUBAGENTS, as agent metadata beside the day: runs, their own seconds, the peak open at once. Never in `seconds`.
|
|
565
|
+
...(stretch.agents && stretch.agents.runs > 0 ? { agent_runs: Math.min(AGENT_RUNS_MAX, stretch.agents.runs), agent_seconds: Math.min(AGENT_SECONDS_MAX, stretch.agents.seconds), agent_peak: Math.min(AGENT_RUNS_MAX, stretch.agents.peak) } : {}),
|
|
458
566
|
at: new Date(stretch.to).toISOString(),
|
|
459
567
|
};
|
|
460
568
|
}
|
|
@@ -540,8 +648,9 @@ function historyEntries(floor = null) {
|
|
|
540
648
|
if (!Number.isFinite(at)) continue;
|
|
541
649
|
const day = new Date(at).toISOString().slice(0, 10);
|
|
542
650
|
if (!byDay.has(day)) byDay.set(day, []);
|
|
543
|
-
byDay.get(day).push(
|
|
651
|
+
byDay.get(day).push(messageOf(line, at));
|
|
544
652
|
}
|
|
653
|
+
const agents = agentsByDay(file, null);
|
|
545
654
|
for (const [day, messages] of byDay) {
|
|
546
655
|
if (messages.length === 0) continue;
|
|
547
656
|
messages.sort((a, b) => a.at - b.at);
|
|
@@ -550,20 +659,8 @@ function historyEntries(floor = null) {
|
|
|
550
659
|
const last = messages[messages.length - 1].at;
|
|
551
660
|
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
552
661
|
if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const models = new Map();
|
|
556
|
-
let exchanges = 0;
|
|
557
|
-
for (const message of messages) {
|
|
558
|
-
if (message.type === "user" && !message.meta && isHumanTurn(message.content)) exchanges += 1;
|
|
559
|
-
if (message.model) models.set(message.model, (models.get(message.model) ?? 0) + 1);
|
|
560
|
-
}
|
|
561
|
-
const { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown } = tokensOf(messages);
|
|
562
|
-
const entry = payloadFor({
|
|
563
|
-
cwd, seconds: seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null,
|
|
564
|
-
tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown, exchanges, model: [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null,
|
|
565
|
-
from: messages[0].at, to: last, ref: stretchRef(file, day), steered,
|
|
566
|
-
});
|
|
662
|
+
// The same measure as a live day (seconds, layers, tokens, turns, model); history differs only in the door it goes through.
|
|
663
|
+
const entry = payloadFor({ ...measure(cwd, messages), ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null });
|
|
567
664
|
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
568
665
|
if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
|
|
569
666
|
if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
|
package/package.json
CHANGED
package/session-databases.mjs
CHANGED
|
@@ -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,145 @@ 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, in `seconds`; in the layers it is the agent's run, whole (the assistant line says `agentic`).
|
|
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
|
+
// `agentic` (owner, 2026-10-06): the request's own run, asked → completed, is the agent's time; the hook files that gap as tool time, uncapped.
|
|
424
|
+
lines.push({ type: "assistant", timestamp: new Date(done).toISOString(), agentic: true, message: { model: cleanModel(String(meta.resolvedModel ?? request?.modelId ?? "").replace(/^copilot\//, "")), ...usage, content: [] } });
|
|
425
|
+
}
|
|
426
|
+
return lines;
|
|
427
|
+
}
|
|
428
|
+
// ── end copilot session reader ──
|
|
429
|
+
|
|
291
430
|
// ── database clients ──
|
|
292
431
|
/** The clients whose sessions live in a database, one registry: the hook and the counter ask it, not each client. */
|
|
293
432
|
const DATABASE_CLIENTS = {
|
|
@@ -295,6 +434,8 @@ const DATABASE_CLIENTS = {
|
|
|
295
434
|
goose: { homes: gooseHomes, sessions: gooseSessions, lines: gooseLines },
|
|
296
435
|
opencode: { homes: opencodeHomes, sessions: opencodeSessions, lines: opencodeLines },
|
|
297
436
|
openclaw: { homes: openclawHomes, sessions: openclawSessions, lines: openclawLines },
|
|
437
|
+
cursor: { homes: cursorHomes, sessions: cursorSessions, lines: cursorLines },
|
|
438
|
+
copilot: { homes: copilotHomes, sessions: copilotSessions, lines: copilotLines },
|
|
298
439
|
};
|
|
299
440
|
export const DATABASE_SESSION = new RegExp(`^(${Object.keys(DATABASE_CLIENTS).join("|")}):.+#[A-Za-z0-9_.:-]{1,120}$`);
|
|
300
441
|
/** 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.
|
|
68
|
+
const CLI_VERSION = "0.6.12";
|
|
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(
|
|
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";
|