worktrust 0.6.11 → 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 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.
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
@@ -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({ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content: line.message?.content ?? null });
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
- return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered }; }).filter(Boolean);
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({ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content: line.message?.content ?? null });
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
- let seconds = 0;
554
- for (let i = 1; i < messages.length; i += 1) seconds += Math.min(IDLE_CAP, Math.round((messages[i].at - messages[i - 1].at) / 1000));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
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": {
@@ -371,7 +371,7 @@ export function cursorLines(pseudo) {
371
371
  * model finished (`modelState.completedAt`), the model it resolved to and the prompt and output tokens. The messages,
372
372
  * the responses and the tool calls' text are parsed with the file and never kept. Agent mode runs a request's tools
373
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.
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
375
  */
376
376
  export function copilotHomes(given) { return given ? [given.replace(/^~(?=\/|$)/, homedir())] : [...userDirs("Code"), ...userDirs("Code - Insiders")]; }
377
377
  export function* copilotSessions(homes) {
@@ -420,7 +420,8 @@ export function copilotLines(pseudo) {
420
420
  if (!done || done < asked) continue;
421
421
  const meta = request?.result?.metadata ?? {};
422
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: [] } });
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: [] } });
424
425
  }
425
426
  return lines;
426
427
  }
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.11";
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");