portable-agent-layer 0.95.1 → 0.96.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.95.1",
3
+ "version": "0.96.1",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "scripts": {
39
39
  "type-check": "tsc --noEmit",
40
- "test": "bun test --randomize --isolate",
40
+ "test": "bun .agents/scripts/test.ts",
41
41
  "test:mutate": "stryker run",
42
42
  "test:mutate:diff": "bun .agents/scripts/mutate-diff.ts",
43
43
  "format": "biome format",
package/src/cli/events.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isNagStage, logDiscordNag, NAG_STAGES } from "../hooks/lib/discord-events";
2
+ import { collectEvents } from "../tools/lib/collectors";
2
3
  import { group, leaf, UsageError } from "../tools/lib/command";
3
4
 
4
5
  function secondsFrom(raw: string | undefined): number | null {
@@ -26,9 +27,27 @@ function recordDiscordNag(
26
27
  console.log(id ? `Recorded ${id}` : "The event log is off; nothing recorded.");
27
28
  }
28
29
 
30
+ function reportCollected(): undefined {
31
+ const emitted = collectEvents();
32
+ if (emitted === null) {
33
+ console.log("Another collect is running; nothing collected.");
34
+ return;
35
+ }
36
+ const sources = Object.entries(emitted);
37
+ if (sources.length === 0) {
38
+ console.log("The event log is off; nothing collected.");
39
+ return;
40
+ }
41
+ for (const [source, count] of sources) console.log(`${source}: ${count}`);
42
+ }
43
+
29
44
  export const eventsCommand = group({
30
45
  summary: "PAL's local event log",
31
46
  commands: {
47
+ collect: leaf({
48
+ summary: "Read every agent's own records into the event log",
49
+ run: () => reportCollected(),
50
+ }),
32
51
  "discord-nag": leaf({
33
52
  summary: "Record a nag the Discord bot sent, or how long its answer took",
34
53
  args: "<sent|answered>",
@@ -198,8 +198,8 @@ async function finishShared(docs: number): Promise<Done> {
198
198
  */
199
199
  async function refreshControlRoom(): Promise<Done | null> {
200
200
  const { isRepoMode } = await import("../hooks/handlers/update-check");
201
- const { buildPage } = await import("../tools/control-room/static");
202
- const rebuilt = isRepoMode() ? buildPage() : null;
201
+ const { refreshPage } = await import("../tools/control-room/static");
202
+ const rebuilt = refreshPage(isRepoMode());
203
203
  if (rebuilt === false)
204
204
  return {
205
205
  detail: ["page not rebuilt"],
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Session stop runs `pal cli events collect` at most every quarter hour, in its
3
+ * own process, so a first backfill never holds up the agent that is closing.
4
+ */
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { statSync, utimesSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { logDebug, logError } from "./log";
10
+ import { palPkg, paths } from "./paths";
11
+ import { eventLogEnabled } from "./settings";
12
+
13
+ const COLLECT_INTERVAL_MS = 15 * 60_000;
14
+
15
+ function lastCollectPath(): string {
16
+ return resolve(paths.state(), "collect-last-run");
17
+ }
18
+
19
+ export function collectDue(now: number = Date.now()): boolean {
20
+ try {
21
+ return now - statSync(lastCollectPath()).mtimeMs >= COLLECT_INTERVAL_MS;
22
+ } catch {
23
+ return true;
24
+ }
25
+ }
26
+
27
+ export function markCollected(now: number = Date.now()): void {
28
+ writeFileSync(lastCollectPath(), "");
29
+ utimesSync(lastCollectPath(), now / 1000, now / 1000);
30
+ }
31
+
32
+ function readsRealAgentDirs(): boolean {
33
+ return !process.env.PAL_TEST_SANDBOX;
34
+ }
35
+
36
+ export function spawnCollectIfDue(): void {
37
+ if (!eventLogEnabled() || !readsRealAgentDirs() || !collectDue()) return;
38
+ try {
39
+ markCollected();
40
+ const cli = resolve(palPkg(), "src", "cli", "index.ts");
41
+ spawn("bun", [cli, "cli", "events", "collect"], {
42
+ detached: true,
43
+ stdio: "ignore",
44
+ windowsHide: true,
45
+ }).unref();
46
+ logDebug("collect", "detached collect spawned");
47
+ } catch (err) {
48
+ logError("collect:spawn", err);
49
+ }
50
+ }
@@ -7,6 +7,7 @@
7
7
  import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { currentAttribution } from "./actor";
10
+ import type { AgentType } from "./agent";
10
11
  import { logError } from "./log";
11
12
  import { ensureDir, paths } from "./paths";
12
13
  import { readAllProjects, resolveProjectFromCwd } from "./projects";
@@ -68,6 +69,35 @@ export const EVENT_FIELDS = {
68
69
  commitment: ["iscProject", "isc", "outcome", "surface", "snoozeDays"],
69
70
  entity_first_seen: ["domain", "entityType"],
70
71
  discord_nag: ["stage", "kind", "responseSec"],
72
+ model_call: [
73
+ "model",
74
+ "requestedModel",
75
+ "provider",
76
+ "inputTokens",
77
+ "outputTokens",
78
+ "cacheWrite5m",
79
+ "cacheWrite1h",
80
+ "cacheRead",
81
+ "thinkingTokens",
82
+ "thinkingMs",
83
+ "webSearches",
84
+ "webFetches",
85
+ "serviceTier",
86
+ "speed",
87
+ "effort",
88
+ "stopReason",
89
+ "entrypoint",
90
+ "sidechain",
91
+ "costUsd",
92
+ "agent",
93
+ "mode",
94
+ "durationMs",
95
+ "ttftMs",
96
+ "aiu",
97
+ "initiator",
98
+ "host",
99
+ ],
100
+ conversation: ["steps", "status", "depth", "subagent", "killed"],
71
101
  permission_prompt: ["tool"],
72
102
  notification: ["kind"],
73
103
  tool_call: [
@@ -118,6 +148,7 @@ export interface AppendOptions {
118
148
  id?: string;
119
149
  ts?: string;
120
150
  session?: string | null;
151
+ runtime?: AgentType;
121
152
  channel?: string | null;
122
153
  cwd?: string;
123
154
  content?: Record<string, unknown>;
@@ -177,9 +208,10 @@ function envelope(type: EventType, options: AppendOptions): EventEnvelope {
177
208
  type,
178
209
  session: options.session ?? null,
179
210
  ...currentAttribution(),
211
+ ...(options.runtime && { runtime: options.runtime }),
180
212
  channel:
181
213
  options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
182
- project: projectOf(cwd),
214
+ project: cwd ? projectOf(cwd) : null,
183
215
  cwd,
184
216
  };
185
217
  }
@@ -19,6 +19,7 @@ import { runSynthesis } from "../handlers/synthesis";
19
19
  import { resetTab } from "../handlers/tab";
20
20
  import { updateCounts } from "../handlers/update-counts";
21
21
  import { captureWorkSession } from "../handlers/work-session";
22
+ import { spawnCollectIfDue } from "./collect-trigger";
22
23
  import { acknowledgeMentioned } from "./daily-nudge";
23
24
  import { spawnDetachedInference } from "./detached-inference";
24
25
  import { startHookTiming } from "./hook-timing";
@@ -193,6 +194,7 @@ export async function runStopHandlers(
193
194
  "project-touch": projectTouch(options.lastAssistantMessage),
194
195
  "desktop-notify": notifyDesktop(options.sessionId),
195
196
  "context-digests": Promise.resolve(writeContextDigests()),
197
+ "collect-events": Promise.resolve(spawnCollectIfDue()),
196
198
  };
197
199
  const names = Object.keys(handlers);
198
200
  const results = await Promise.allSettled(
@@ -5,35 +5,110 @@
5
5
  */
6
6
 
7
7
  import { spawnSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
9
9
  import { resolve } from "node:path";
10
10
  import { isInside } from "../../hooks/lib/paths";
11
11
 
12
- const UI_DIST = resolve(import.meta.dir, "ui", "dist");
12
+ const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..");
13
+ const UI_ROOT = resolve(import.meta.dir, "ui");
14
+ const UI_DIST = resolve(UI_ROOT, "dist");
15
+ const UI_INDEX = resolve(UI_DIST, "index.html");
16
+ export const ABANDONED_LOCK_MS = 120_000;
13
17
 
14
18
  export const BUILD_COMMAND = "bun run build:ui";
15
19
 
20
+ /** Where the page is built from and to, and how; tests point it at a sandbox. */
21
+ export interface PageBuild {
22
+ sources: string;
23
+ lockfile: string;
24
+ dist: string;
25
+ lock: string;
26
+ build: () => boolean;
27
+ }
28
+
29
+ const THE_PAGE: PageBuild = {
30
+ sources: resolve(REPO_ROOT, "src"),
31
+ lockfile: resolve(REPO_ROOT, "bun.lock"),
32
+ dist: UI_DIST,
33
+ lock: resolve(UI_ROOT, "dist.lock"),
34
+ build: runViteBuild,
35
+ };
36
+
16
37
  export function isBuilt(): boolean {
17
- return existsSync(resolve(UI_DIST, "index.html"));
38
+ return existsSync(UI_INDEX);
18
39
  }
19
40
 
20
- /**
21
- * A clone has vite; a published install has the prepacked dist and never needs
22
- * it. Either way the page is there by the time the server answers.
23
- */
24
- export function buildPage(): boolean {
25
- const repoRoot = resolve(import.meta.dir, "..", "..", "..");
41
+ function newestSourceChange(page: PageBuild): number {
42
+ const sources = readdirSync(page.sources, { recursive: true, withFileTypes: true })
43
+ .filter((entry) => entry.isFile() && !isInside(page.dist, entry.parentPath))
44
+ .map((entry) => resolve(entry.parentPath, entry.name));
45
+ if (existsSync(page.lockfile)) sources.push(page.lockfile);
46
+ return Math.max(...sources.map((file) => statSync(file).mtimeMs));
47
+ }
48
+
49
+ export function isUpToDate(page: PageBuild = THE_PAGE): boolean {
50
+ const index = resolve(page.dist, "index.html");
51
+ return existsSync(index) && statSync(index).mtimeMs >= newestSourceChange(page);
52
+ }
53
+
54
+ function clearAbandonedLock(lock: string): void {
55
+ try {
56
+ if (Date.now() - statSync(lock).mtimeMs > ABANDONED_LOCK_MS)
57
+ rmSync(lock, { recursive: true, force: true });
58
+ } catch {}
59
+ }
60
+
61
+ function tryTakeBuildLock(lock: string): boolean {
62
+ try {
63
+ mkdirSync(lock);
64
+ return true;
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function whileHoldingBuildLock(lock: string, build: () => boolean): boolean {
72
+ while (!tryTakeBuildLock(lock)) {
73
+ clearAbandonedLock(lock);
74
+ Bun.sleepSync(50);
75
+ }
76
+ try {
77
+ return build();
78
+ } finally {
79
+ rmSync(lock, { recursive: true, force: true });
80
+ }
81
+ }
82
+
83
+ /** A clone has vite; a published install has the prepacked dist and never needs it. */
84
+ function runViteBuild(): boolean {
26
85
  // node_modules/.bin/vite is a shell shim that Windows cannot spawn directly;
27
86
  // the package's own JS entry runs the same build under any runtime.
28
- const vite = resolve(repoRoot, "node_modules", "vite", "bin", "vite.js");
87
+ const vite = resolve(REPO_ROOT, "node_modules", "vite", "bin", "vite.js");
29
88
  if (!existsSync(vite)) return false;
30
89
  const built = spawnSync(process.execPath, [vite, "build"], {
31
- cwd: resolve(import.meta.dir, "ui"),
90
+ cwd: UI_ROOT,
32
91
  encoding: "utf-8",
33
92
  });
34
93
  return built.status === 0 && isBuilt();
35
94
  }
36
95
 
96
+ /**
97
+ * vite empties dist before writing it, so one build at a time, and none once
98
+ * another process has already brought it up to date.
99
+ */
100
+ export function buildPage(page: PageBuild = THE_PAGE): boolean {
101
+ return whileHoldingBuildLock(page.lock, () => isUpToDate(page) || page.build());
102
+ }
103
+
104
+ /** A checkout's dist is whatever was built last, so an install rebuilds it if the sources moved on. */
105
+ export function refreshPage(
106
+ inCheckout: boolean,
107
+ page: PageBuild = THE_PAGE
108
+ ): boolean | null {
109
+ return inCheckout && !isUpToDate(page) ? buildPage(page) : null;
110
+ }
111
+
37
112
  function unbuiltPage(): Response {
38
113
  return new Response(
39
114
  `<!doctype html><meta charset="utf-8"><title>PAL control room</title>` +
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { Database } from "bun:sqlite";
8
8
  import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
9
10
  import { basename, resolve } from "node:path";
10
11
  import {
11
12
  type AgentUsage,
@@ -107,14 +108,16 @@ export function readCodex(
107
108
  now: Date = new Date()
108
109
  ): AgentUsage {
109
110
  const tally = usageTally(projectFilter, now);
111
+ for (const filepath of codexRollouts(codexDir)) recordCodexRollout(filepath, tally);
112
+ return tally.usage;
113
+ }
114
+
115
+ export function codexRollouts(codexDir: string): string[] {
110
116
  const isRollout = (name: string) =>
111
117
  name.startsWith("rollout-") && name.endsWith(".jsonl");
112
- for (const sub of ["sessions", "archived_sessions"]) {
113
- for (const filepath of filesUnder(resolve(codexDir, sub), isRollout)) {
114
- recordCodexRollout(filepath, tally);
115
- }
116
- }
117
- return tally.usage;
118
+ return ["sessions", "archived_sessions"].flatMap((sub) =>
119
+ filesUnder(resolve(codexDir, sub), isRollout)
120
+ );
118
121
  }
119
122
 
120
123
  interface OpencodeMessage {
@@ -144,6 +147,11 @@ function opencodeTokens(message: OpencodeMessage): Tokens {
144
147
 
145
148
  const anyTokens = (tokens: Tokens) => Object.values(tokens).some((n) => n > 0);
146
149
 
150
+ export function opencodeDatabase(): string {
151
+ const dataHome = process.env.XDG_DATA_HOME || resolve(homedir(), ".local", "share");
152
+ return resolve(dataHome, "opencode", "opencode.db");
153
+ }
154
+
147
155
  interface OpencodeRow {
148
156
  time_created: number;
149
157
  data: string;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Antigravity's conversations, read off its summaries table into the event
3
+ * log. A row is rewritten as the conversation goes on, so each change is a
4
+ * new snapshot and the latest per session is its current state. PAL's own
5
+ * inference spawns are left out: inference_call already records them.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { appendEvent } from "../../../hooks/lib/events";
12
+ import { readCursor, writeCursor } from "./cursors";
13
+ import { type AllowList, selectAllowed } from "./sqlite";
14
+
15
+ const PAL_INFERENCE_AGENT = "pal-inference";
16
+ const SUMMARY_COLUMNS = [
17
+ "conversation_id",
18
+ "step_count",
19
+ "last_modified_time",
20
+ "workspace_uris",
21
+ "status",
22
+ "agent_name",
23
+ "parent_conversation_id",
24
+ "nesting_depth",
25
+ "killed",
26
+ ] as const;
27
+
28
+ export const ANTIGRAVITY_ALLOW_LIST = {
29
+ conversation_summaries: SUMMARY_COLUMNS,
30
+ } satisfies AllowList;
31
+
32
+ interface SummaryRow {
33
+ conversation_id: string;
34
+ step_count: number;
35
+ last_modified_time: string;
36
+ workspace_uris: string;
37
+ status: string;
38
+ agent_name: string;
39
+ parent_conversation_id: string;
40
+ nesting_depth: number;
41
+ killed: number;
42
+ }
43
+
44
+ export function antigravityDatabase(antigravityCliDir: string): string {
45
+ return resolve(antigravityCliDir, "conversation_summaries.db");
46
+ }
47
+
48
+ function isoOf(antigravityTime: string): string {
49
+ return new Date(
50
+ antigravityTime.replace(" ", "T").replace(/(\.\d{3})\d*/, "$1")
51
+ ).toISOString();
52
+ }
53
+
54
+ function workspaceOf(row: SummaryRow): string {
55
+ try {
56
+ const [first] = JSON.parse(row.workspace_uris) as string[];
57
+ return first?.startsWith("file://") ? fileURLToPath(first) : "";
58
+ } catch {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ function emitConversation(row: SummaryRow): void {
64
+ appendEvent(
65
+ "conversation",
66
+ {
67
+ steps: row.step_count,
68
+ status: row.status.replace("CASCADE_RUN_STATUS_", "").toLowerCase(),
69
+ depth: row.nesting_depth,
70
+ subagent: row.parent_conversation_id !== "",
71
+ killed: row.killed === 1,
72
+ },
73
+ {
74
+ ts: isoOf(row.last_modified_time),
75
+ session: row.conversation_id,
76
+ runtime: "antigravity",
77
+ cwd: workspaceOf(row),
78
+ }
79
+ );
80
+ }
81
+
82
+ export function collectAntigravity(dbPath: string): number {
83
+ if (!existsSync(dbPath)) return 0;
84
+ const cursor = readCursor("antigravity");
85
+ const rows = selectAllowed<SummaryRow>(
86
+ dbPath,
87
+ ANTIGRAVITY_ALLOW_LIST,
88
+ "conversation_summaries",
89
+ SUMMARY_COLUMNS,
90
+ {
91
+ orderColumn: "last_modified_time",
92
+ idColumn: "conversation_id",
93
+ order: String(cursor.modified ?? ""),
94
+ id: String(cursor.id ?? ""),
95
+ }
96
+ );
97
+ const conversations = rows.filter((row) => row.agent_name !== PAL_INFERENCE_AGENT);
98
+ for (const row of conversations) emitConversation(row);
99
+ const last = rows.at(-1);
100
+ if (last) {
101
+ writeCursor("antigravity", {
102
+ modified: last.last_modified_time,
103
+ id: last.conversation_id,
104
+ });
105
+ }
106
+ return conversations.length;
107
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Claude Code's model calls, read off its transcripts into the event log.
3
+ *
4
+ * A message's lines are not all written at once: tool results land between
5
+ * them, and an early line can carry the usage mid-stream. So the newest
6
+ * message of a file still being written is left for the next run, and the
7
+ * cursor stops where it starts.
8
+ */
9
+
10
+ import { readFileSync, statSync } from "node:fs";
11
+ import { relative, sep } from "node:path";
12
+ import { appendEvent } from "../../../hooks/lib/events";
13
+ import { cacheWritesOf, type TranscriptUsage } from "../transcript-usage";
14
+ import { claudeTranscripts } from "../usage-buckets";
15
+ import { readCursor, writeCursor } from "./cursors";
16
+
17
+ const SETTLE_MS = 5 * 60_000;
18
+
19
+ interface ClaudeLine {
20
+ type?: string;
21
+ timestamp?: string;
22
+ sessionId?: string;
23
+ cwd?: string;
24
+ isSidechain?: boolean;
25
+ effort?: string;
26
+ entrypoint?: string;
27
+ requestedModel?: string;
28
+ thinkingDurationMs?: number;
29
+ message?: {
30
+ id?: string;
31
+ model?: string;
32
+ stop_reason?: string;
33
+ usage?: TranscriptUsage;
34
+ };
35
+ }
36
+
37
+ interface SettledRead {
38
+ messages: ClaudeLine[];
39
+ resumeAt: number;
40
+ }
41
+
42
+ function isModelCall(line: ClaudeLine | null): line is ClaudeLine {
43
+ return (
44
+ line?.type === "assistant" &&
45
+ !!line.message?.usage &&
46
+ !!line.message.model &&
47
+ !!line.timestamp &&
48
+ !!line.cwd
49
+ );
50
+ }
51
+
52
+ function parsed(text: string): ClaudeLine | null {
53
+ try {
54
+ return JSON.parse(text) as ClaudeLine;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function completeLinesFrom(filepath: string, offset: number) {
61
+ const bytes = readFileSync(filepath);
62
+ const end = bytes.lastIndexOf(10) + 1;
63
+ if (end <= offset) return { lines: [], end: offset };
64
+ const lines: { text: string; start: number }[] = [];
65
+ let start = offset;
66
+ for (const text of bytes
67
+ .subarray(offset, end - 1)
68
+ .toString("utf-8")
69
+ .split("\n")) {
70
+ lines.push({ text, start });
71
+ start += Buffer.byteLength(text) + 1;
72
+ }
73
+ return { lines, end };
74
+ }
75
+
76
+ function settledMessages(
77
+ filepath: string,
78
+ offset: number,
79
+ settled: boolean
80
+ ): SettledRead {
81
+ const { lines, end } = completeLinesFrom(filepath, offset);
82
+ const groups = new Map<string, { start: number; line: ClaudeLine }>();
83
+ let newest: string | undefined;
84
+ for (const { text, start } of lines) {
85
+ if (!text.includes('"usage"')) continue;
86
+ const line = parsed(text);
87
+ if (!isModelCall(line)) continue;
88
+ const id = line.message?.id ?? `@${start}`;
89
+ groups.set(id, { start: groups.get(id)?.start ?? start, line });
90
+ newest = id;
91
+ }
92
+ const unfinished = settled || !newest ? undefined : groups.get(newest);
93
+ if (unfinished && newest) groups.delete(newest);
94
+ return {
95
+ messages: [...groups.values()].map((group) => group.line),
96
+ resumeAt: unfinished?.start ?? end,
97
+ };
98
+ }
99
+
100
+ function emitModelCall(line: ClaudeLine): void {
101
+ const usage = line.message?.usage ?? {};
102
+ appendEvent(
103
+ "model_call",
104
+ {
105
+ model: line.message?.model,
106
+ requestedModel: line.requestedModel,
107
+ inputTokens: usage.input_tokens,
108
+ outputTokens: usage.output_tokens,
109
+ ...cacheWritesOf(usage),
110
+ cacheRead: usage.cache_read_input_tokens,
111
+ thinkingTokens: usage.output_tokens_details?.thinking_tokens,
112
+ thinkingMs: line.thinkingDurationMs,
113
+ webSearches: usage.server_tool_use?.web_search_requests,
114
+ webFetches: usage.server_tool_use?.web_fetch_requests,
115
+ serviceTier: usage.service_tier,
116
+ speed: usage.speed,
117
+ effort: line.effort,
118
+ stopReason: line.message?.stop_reason,
119
+ entrypoint: line.entrypoint,
120
+ sidechain: line.isSidechain,
121
+ },
122
+ {
123
+ ts: line.timestamp,
124
+ session: line.sessionId ?? null,
125
+ runtime: "claude",
126
+ cwd: line.cwd,
127
+ }
128
+ );
129
+ }
130
+
131
+ function isSettled(filepath: string, now: Date): boolean {
132
+ return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
133
+ }
134
+
135
+ export function collectClaude(projectsDir: string, now: Date = new Date()): number {
136
+ const cursor = readCursor("claude");
137
+ let emitted = 0;
138
+ for (const filepath of claudeTranscripts(projectsDir)) {
139
+ const key = relative(projectsDir, filepath).split(sep).join("/");
140
+ const offset = Number(cursor[key] ?? 0);
141
+ const read = settledMessages(filepath, offset, isSettled(filepath, now));
142
+ if (read.resumeAt === offset) continue;
143
+ for (const line of read.messages) emitModelCall(line);
144
+ emitted += read.messages.length;
145
+ cursor[key] = read.resumeAt;
146
+ writeCursor("claude", cursor);
147
+ }
148
+ return emitted;
149
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Codex's model calls, read off its rollout files into the event log. Codex
3
+ * repeats a token_count without a new call, and its running total only grows,
4
+ * so the total is both the dedupe and the cursor.
5
+ */
6
+
7
+ import { readFileSync, statSync } from "node:fs";
8
+ import { relative, sep } from "node:path";
9
+ import { appendEvent } from "../../../hooks/lib/events";
10
+ import { codexRollouts } from "../agent-usage";
11
+ import { readCursor, writeCursor } from "./cursors";
12
+
13
+ interface CodexUsage {
14
+ input_tokens?: number;
15
+ cached_input_tokens?: number;
16
+ cache_write_input_tokens?: number;
17
+ output_tokens?: number;
18
+ reasoning_output_tokens?: number;
19
+ total_tokens?: number;
20
+ }
21
+
22
+ interface RolloutLine {
23
+ timestamp?: string;
24
+ type?: string;
25
+ payload?: {
26
+ type?: string;
27
+ id?: string;
28
+ session_id?: string;
29
+ cwd?: string;
30
+ model?: string;
31
+ effort?: string;
32
+ model_provider?: string;
33
+ info?: { last_token_usage?: CodexUsage; total_token_usage?: CodexUsage } | null;
34
+ };
35
+ }
36
+
37
+ interface RolloutContext {
38
+ session?: string;
39
+ cwd?: string;
40
+ model?: string;
41
+ effort?: string;
42
+ provider?: string;
43
+ }
44
+
45
+ interface CodexCall {
46
+ ts: string;
47
+ total: number;
48
+ usage: CodexUsage;
49
+ context: RolloutContext;
50
+ }
51
+
52
+ function rolloutLines(filepath: string): RolloutLine[] {
53
+ return readFileSync(filepath, "utf-8")
54
+ .split("\n")
55
+ .flatMap((text) => {
56
+ try {
57
+ return [JSON.parse(text) as RolloutLine];
58
+ } catch {
59
+ return [];
60
+ }
61
+ });
62
+ }
63
+
64
+ function withContext(context: RolloutContext, line: RolloutLine): RolloutContext {
65
+ const payload = line.payload ?? {};
66
+ if (line.type === "session_meta")
67
+ return {
68
+ ...context,
69
+ session: payload.session_id ?? payload.id,
70
+ cwd: payload.cwd,
71
+ provider: payload.model_provider,
72
+ };
73
+ if (line.type === "turn_context")
74
+ return { ...context, model: payload.model, effort: payload.effort };
75
+ return context;
76
+ }
77
+
78
+ function callsOf(filepath: string): CodexCall[] {
79
+ const calls: CodexCall[] = [];
80
+ let context: RolloutContext = {};
81
+ let lastTotal = -1;
82
+ for (const line of rolloutLines(filepath)) {
83
+ context = withContext(context, line);
84
+ const info = line.payload?.type === "token_count" ? line.payload.info : null;
85
+ const total = info?.total_token_usage?.total_tokens ?? -1;
86
+ if (!info?.last_token_usage || !line.timestamp || total === lastTotal) continue;
87
+ lastTotal = total;
88
+ calls.push({ ts: line.timestamp, total, usage: info.last_token_usage, context });
89
+ }
90
+ return calls;
91
+ }
92
+
93
+ function emitModelCall({ ts, usage, context }: CodexCall): void {
94
+ const cacheRead = usage.cached_input_tokens ?? 0;
95
+ const cacheWrite = usage.cache_write_input_tokens ?? 0;
96
+ appendEvent(
97
+ "model_call",
98
+ {
99
+ model: context.model,
100
+ provider: context.provider,
101
+ inputTokens: (usage.input_tokens ?? 0) - cacheRead - cacheWrite,
102
+ outputTokens: usage.output_tokens,
103
+ thinkingTokens: usage.reasoning_output_tokens,
104
+ cacheWrite5m: cacheWrite,
105
+ cacheRead,
106
+ effort: context.effort,
107
+ },
108
+ { ts, session: context.session ?? null, runtime: "codex", cwd: context.cwd ?? "" }
109
+ );
110
+ }
111
+
112
+ export function collectCodex(codexDir: string): number {
113
+ const cursor = readCursor("codex");
114
+ let emitted = 0;
115
+ for (const filepath of codexRollouts(codexDir)) {
116
+ const key = relative(codexDir, filepath).split(sep).join("/");
117
+ const size = statSync(filepath).size;
118
+ if (cursor[`${key}@size`] === size) continue;
119
+ const collectedTotal = Number(cursor[key] ?? -1);
120
+ for (const call of callsOf(filepath)) {
121
+ if (call.total <= collectedTotal) continue;
122
+ emitModelCall(call);
123
+ emitted++;
124
+ cursor[key] = call.total;
125
+ }
126
+ cursor[`${key}@size`] = size;
127
+ writeCursor("codex", cursor);
128
+ }
129
+ return emitted;
130
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Copilot's model calls, read off its session store into the event log. A row
3
+ * is written once the call has finished, so its id is the cursor. Copilot
4
+ * counts cache reads inside input_tokens and reasoning inside output_tokens.
5
+ */
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { appendEvent } from "../../../hooks/lib/events";
10
+ import { readCursor, writeCursor } from "./cursors";
11
+ import { type AllowList, selectAllowed } from "./sqlite";
12
+
13
+ const NANO = 1_000_000_000;
14
+ const USAGE_COLUMNS = [
15
+ "id",
16
+ "session_id",
17
+ "agent_id",
18
+ "model",
19
+ "input_tokens",
20
+ "output_tokens",
21
+ "cache_read_tokens",
22
+ "cache_write_tokens",
23
+ "reasoning_tokens",
24
+ "total_nano_aiu",
25
+ "duration_ms",
26
+ "time_to_first_token_ms",
27
+ "initiator",
28
+ "reasoning_effort",
29
+ "finish_reason",
30
+ "created_at",
31
+ ] as const;
32
+ const SESSION_COLUMNS = ["id", "cwd", "host_type", "created_at"] as const;
33
+
34
+ export const COPILOT_ALLOW_LIST = {
35
+ assistant_usage_events: USAGE_COLUMNS,
36
+ sessions: SESSION_COLUMNS,
37
+ } satisfies AllowList;
38
+
39
+ type UsageRow = Record<(typeof USAGE_COLUMNS)[number], string | number | null>;
40
+
41
+ interface SessionRow {
42
+ id: string;
43
+ cwd: string | null;
44
+ host_type: string | null;
45
+ }
46
+
47
+ export function copilotDatabase(copilotDir: string): string {
48
+ return resolve(copilotDir, "session-store.db");
49
+ }
50
+
51
+ function sessionsOf(dbPath: string): Map<string, SessionRow> {
52
+ const rows = selectAllowed<SessionRow>(
53
+ dbPath,
54
+ COPILOT_ALLOW_LIST,
55
+ "sessions",
56
+ SESSION_COLUMNS,
57
+ { orderColumn: "created_at", idColumn: "id", order: "", id: "" }
58
+ );
59
+ return new Map(rows.map((row) => [row.id, row]));
60
+ }
61
+
62
+ const num = (value: unknown) => (typeof value === "number" ? value : 0);
63
+
64
+ function emitModelCall(row: UsageRow, session: SessionRow | undefined): void {
65
+ const cacheRead = num(row.cache_read_tokens);
66
+ appendEvent(
67
+ "model_call",
68
+ {
69
+ model: row.model,
70
+ inputTokens: num(row.input_tokens) - cacheRead,
71
+ outputTokens: row.output_tokens,
72
+ thinkingTokens: row.reasoning_tokens,
73
+ cacheWrite5m: row.cache_write_tokens,
74
+ cacheRead,
75
+ aiu: num(row.total_nano_aiu) / NANO,
76
+ durationMs: row.duration_ms,
77
+ ttftMs:
78
+ typeof row.time_to_first_token_ms === "number"
79
+ ? Math.round(row.time_to_first_token_ms)
80
+ : undefined,
81
+ initiator: row.initiator,
82
+ effort: row.reasoning_effort,
83
+ stopReason: row.finish_reason,
84
+ sidechain: row.agent_id !== null,
85
+ host: session?.host_type ?? "local",
86
+ },
87
+ {
88
+ ts: String(row.created_at),
89
+ session: String(row.session_id),
90
+ runtime: "copilot",
91
+ cwd: session?.cwd ?? "",
92
+ }
93
+ );
94
+ }
95
+
96
+ export function collectCopilot(dbPath: string): number {
97
+ if (!existsSync(dbPath)) return 0;
98
+ const cursor = readCursor("copilot");
99
+ const lastId = Number(cursor.usageId ?? 0);
100
+ const rows = selectAllowed<UsageRow>(
101
+ dbPath,
102
+ COPILOT_ALLOW_LIST,
103
+ "assistant_usage_events",
104
+ USAGE_COLUMNS,
105
+ { orderColumn: "id", idColumn: "id", order: lastId, id: lastId }
106
+ );
107
+ if (rows.length === 0) return 0;
108
+ const sessions = sessionsOf(dbPath);
109
+ for (const row of rows) emitModelCall(row, sessions.get(String(row.session_id)));
110
+ cursor.usageId = num(rows.at(-1)?.id);
111
+ writeCursor("copilot", cursor);
112
+ return rows.length;
113
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How far each collector has read, per source, so a second run emits nothing
3
+ * the first one already did.
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { paths } from "../../../hooks/lib/paths";
9
+
10
+ export type SourceCursor = Record<string, number | string>;
11
+
12
+ function cursorsFile(): string {
13
+ return resolve(paths.state(), "collector-cursors.json");
14
+ }
15
+
16
+ function readAll(): Record<string, SourceCursor> {
17
+ if (!existsSync(cursorsFile())) return {};
18
+ try {
19
+ return JSON.parse(readFileSync(cursorsFile(), "utf-8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ export function readCursor(source: string): SourceCursor {
26
+ return readAll()[source] ?? {};
27
+ }
28
+
29
+ export function writeCursor(source: string, cursor: SourceCursor): void {
30
+ writeFileSync(
31
+ cursorsFile(),
32
+ `${JSON.stringify({ ...readAll(), [source]: cursor }, null, 2)}\n`
33
+ );
34
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Every agent's own records, read into the event log. A collector that fails
3
+ * is logged and skipped; the others still run. One collect at a time: two
4
+ * would read from the same cursors and emit the same events twice.
5
+ */
6
+
7
+ import { rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { markCollected } from "../../../hooks/lib/collect-trigger";
10
+ import { logError } from "../../../hooks/lib/log";
11
+ import { paths, platform } from "../../../hooks/lib/paths";
12
+ import { eventLogEnabled } from "../../../hooks/lib/settings";
13
+ import { opencodeDatabase } from "../agent-usage";
14
+ import { antigravityDatabase, collectAntigravity } from "./antigravity";
15
+ import { collectClaude } from "./claude";
16
+ import { collectCodex } from "./codex";
17
+ import { collectCopilot, copilotDatabase } from "./copilot";
18
+ import { collectOpencode } from "./opencode";
19
+
20
+ const LOCK_STALE_MS = 10 * 60_000;
21
+
22
+ const COLLECTORS: Record<string, () => number> = {
23
+ claude: () => collectClaude(resolve(platform.claudeDir(), "projects")),
24
+ opencode: () => collectOpencode(opencodeDatabase()),
25
+ codex: () => collectCodex(platform.codexDir()),
26
+ copilot: () => collectCopilot(copilotDatabase(platform.copilotDir())),
27
+ antigravity: () =>
28
+ collectAntigravity(antigravityDatabase(platform.antigravityCliDir())),
29
+ };
30
+
31
+ function lockPath(): string {
32
+ return resolve(paths.state(), "collect.lock");
33
+ }
34
+
35
+ function lockIsFresh(now: number): boolean {
36
+ try {
37
+ return now - statSync(lockPath()).mtimeMs < LOCK_STALE_MS;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function takeLock(now: number): boolean {
44
+ try {
45
+ writeFileSync(lockPath(), String(process.pid), { flag: "wx" });
46
+ return true;
47
+ } catch {
48
+ if (lockIsFresh(now)) return false;
49
+ writeFileSync(lockPath(), String(process.pid));
50
+ return true;
51
+ }
52
+ }
53
+
54
+ function runCollectors(): Record<string, number | "failed"> {
55
+ const emitted: Record<string, number | "failed"> = {};
56
+ for (const [source, collect] of Object.entries(COLLECTORS)) {
57
+ try {
58
+ emitted[source] = collect();
59
+ } catch (err) {
60
+ logError(`collect:${source}`, err);
61
+ emitted[source] = "failed";
62
+ }
63
+ }
64
+ return emitted;
65
+ }
66
+
67
+ /** Null while another collect holds the lock. */
68
+ export function collectEvents(
69
+ now: number = Date.now()
70
+ ): Record<string, number | "failed"> | null {
71
+ if (!eventLogEnabled()) return {};
72
+ if (!takeLock(now)) return null;
73
+ try {
74
+ markCollected(now);
75
+ return runCollectors();
76
+ } finally {
77
+ rmSync(lockPath(), { force: true });
78
+ }
79
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * opencode's model calls, read off its message table into the event log. A
3
+ * message is written while it streams, so one not yet completed stops the
4
+ * read until it is, or until it is old enough to have been abandoned.
5
+ */
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { appendEvent } from "../../../hooks/lib/events";
9
+ import { readCursor, writeCursor } from "./cursors";
10
+ import { type AllowList, selectAllowed } from "./sqlite";
11
+
12
+ const ABANDONED_MS = 60 * 60_000;
13
+ const MESSAGE_COLUMNS = ["id", "session_id", "time_created", "data"] as const;
14
+
15
+ export const OPENCODE_ALLOW_LIST = { message: MESSAGE_COLUMNS } satisfies AllowList;
16
+
17
+ interface MessageRow {
18
+ id: string;
19
+ session_id: string;
20
+ time_created: number;
21
+ data: string;
22
+ }
23
+
24
+ interface AssistantMessage {
25
+ role?: string;
26
+ modelID?: string;
27
+ providerID?: string;
28
+ cost?: number;
29
+ mode?: string;
30
+ agent?: string;
31
+ finish?: string;
32
+ path?: { cwd?: string };
33
+ time?: { created?: number; completed?: number };
34
+ tokens?: {
35
+ input?: number;
36
+ output?: number;
37
+ reasoning?: number;
38
+ cache?: { read?: number; write?: number };
39
+ };
40
+ }
41
+
42
+ function assistantMessageOf(row: MessageRow): AssistantMessage | null {
43
+ try {
44
+ const message = JSON.parse(row.data) as AssistantMessage;
45
+ return message.role === "assistant" ? message : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function stillStreaming(message: AssistantMessage, row: MessageRow, now: Date): boolean {
52
+ return !message.time?.completed && now.getTime() - row.time_created < ABANDONED_MS;
53
+ }
54
+
55
+ function durationOf(message: AssistantMessage): number | undefined {
56
+ const { created, completed } = message.time ?? {};
57
+ return created && completed ? completed - created : undefined;
58
+ }
59
+
60
+ function emitModelCall(row: MessageRow, message: AssistantMessage): void {
61
+ const tokens = message.tokens ?? {};
62
+ appendEvent(
63
+ "model_call",
64
+ {
65
+ model: message.modelID,
66
+ provider: message.providerID,
67
+ inputTokens: tokens.input,
68
+ outputTokens: (tokens.output ?? 0) + (tokens.reasoning ?? 0),
69
+ thinkingTokens: tokens.reasoning,
70
+ cacheWrite5m: tokens.cache?.write,
71
+ cacheRead: tokens.cache?.read,
72
+ costUsd: message.cost,
73
+ agent: message.agent,
74
+ mode: message.mode,
75
+ stopReason: message.finish,
76
+ durationMs: durationOf(message),
77
+ },
78
+ {
79
+ ts: new Date(row.time_created).toISOString(),
80
+ session: row.session_id,
81
+ runtime: "opencode",
82
+ cwd: message.path?.cwd ?? "",
83
+ }
84
+ );
85
+ }
86
+
87
+ export function collectOpencode(dbPath: string, now: Date = new Date()): number {
88
+ if (!existsSync(dbPath)) return 0;
89
+ const cursor = readCursor("opencode");
90
+ const rows = selectAllowed<MessageRow>(
91
+ dbPath,
92
+ OPENCODE_ALLOW_LIST,
93
+ "message",
94
+ MESSAGE_COLUMNS,
95
+ {
96
+ orderColumn: "time_created",
97
+ idColumn: "id",
98
+ order: Number(cursor.order ?? 0),
99
+ id: String(cursor.id ?? ""),
100
+ }
101
+ );
102
+ let emitted = 0;
103
+ for (const row of rows) {
104
+ const message = assistantMessageOf(row);
105
+ if (message && stillStreaming(message, row, now)) break;
106
+ if (message) {
107
+ emitModelCall(row, message);
108
+ emitted++;
109
+ }
110
+ cursor.order = row.time_created;
111
+ cursor.id = row.id;
112
+ }
113
+ writeCursor("opencode", cursor);
114
+ return emitted;
115
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The only way a collector reads an agent's database: named columns of named
3
+ * tables, read-only, from an allow-list that may never name a credential.
4
+ * Agents keep login tokens in the same file as their usage, so a query is
5
+ * built here from the allow-list rather than written by the collector.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+
10
+ export type AllowList = Readonly<Record<string, readonly string[]>>;
11
+
12
+ const NEVER_READ_TABLES = ["account", "control_account", "credential", "session_share"];
13
+ const NEVER_READ_COLUMN = /token$|secret|credential|password|api_?key|^value$/i;
14
+
15
+ export function assertNoCredentials(allowList: AllowList): void {
16
+ for (const [table, columns] of Object.entries(allowList)) {
17
+ if (NEVER_READ_TABLES.includes(table.toLowerCase()))
18
+ throw new Error(`table ${table} holds credentials and is never read`);
19
+ const secret = columns.find((column) => NEVER_READ_COLUMN.test(column));
20
+ if (secret) throw new Error(`column ${table}.${secret} is never read`);
21
+ }
22
+ }
23
+
24
+ export interface ResumeAfter {
25
+ orderColumn: string;
26
+ idColumn: string;
27
+ order: number | string;
28
+ id: number | string;
29
+ }
30
+
31
+ function quoted(identifier: string): string {
32
+ return `"${identifier.replaceAll('"', '""')}"`;
33
+ }
34
+
35
+ function allowedColumns(allowList: AllowList, table: string, columns: readonly string[]) {
36
+ const allowed = allowList[table];
37
+ if (!allowed) throw new Error(`table ${table} is not on the allow-list`);
38
+ const stray = columns.find((column) => !allowed.includes(column));
39
+ if (stray) throw new Error(`column ${table}.${stray} is not on the allow-list`);
40
+ }
41
+
42
+ export function selectAllowed<Row>(
43
+ dbPath: string,
44
+ allowList: AllowList,
45
+ table: string,
46
+ columns: readonly string[],
47
+ after: ResumeAfter
48
+ ): Row[] {
49
+ assertNoCredentials(allowList);
50
+ allowedColumns(allowList, table, [...columns, after.orderColumn, after.idColumn]);
51
+ const order = quoted(after.orderColumn);
52
+ const id = quoted(after.idColumn);
53
+ const sql = `SELECT ${columns.map(quoted).join(", ")} FROM ${quoted(table)} WHERE ${order} > ?1 OR (${order} = ?1 AND ${id} > ?2) ORDER BY ${order}, ${id}`;
54
+ const db = new Database(dbPath, { readonly: true });
55
+ try {
56
+ return db
57
+ .query<Row, [number | string, number | string]>(sql)
58
+ .all(after.order, after.id);
59
+ } finally {
60
+ db.close();
61
+ }
62
+ }
@@ -16,6 +16,10 @@ export interface TranscriptUsage {
16
16
  ephemeral_5m_input_tokens?: number;
17
17
  ephemeral_1h_input_tokens?: number;
18
18
  };
19
+ output_tokens_details?: { thinking_tokens?: number };
20
+ server_tool_use?: { web_search_requests?: number; web_fetch_requests?: number };
21
+ service_tier?: string;
22
+ speed?: string;
19
23
  }
20
24
 
21
25
  /**
@@ -208,6 +208,13 @@ function transcriptsIn(projPath: string): string[] {
208
208
  return files;
209
209
  }
210
210
 
211
+ export function claudeTranscripts(projectsDir: string): string[] {
212
+ if (!existsSync(projectsDir)) return [];
213
+ return readdirSync(projectsDir, { withFileTypes: true })
214
+ .filter((entry) => entry.isDirectory())
215
+ .flatMap((entry) => transcriptsIn(resolve(projectsDir, entry.name)));
216
+ }
217
+
211
218
  interface PricedCall {
212
219
  messageId?: string;
213
220
  ts: string;
@@ -4,12 +4,16 @@
4
4
  */
5
5
 
6
6
  import { spawnSync } from "node:child_process";
7
- import { homedir } from "node:os";
8
7
  import { resolve } from "node:path";
9
8
  import { AGENT_REGISTRY, type AgentName } from "../hooks/lib/agent-registry";
10
9
  import { palHome, platform } from "../hooks/lib/paths";
11
10
  import { findBinaryOnPath } from "../hooks/lib/which";
12
- import { readCodex, readCopilot, readOpencode } from "./lib/agent-usage";
11
+ import {
12
+ opencodeDatabase,
13
+ readCodex,
14
+ readCopilot,
15
+ readOpencode,
16
+ } from "./lib/agent-usage";
13
17
  import {
14
18
  type AgentReport,
15
19
  parseRtkSummary,
@@ -20,11 +24,6 @@ import { readClaudeCode, readPalInference } from "./lib/usage-buckets";
20
24
 
21
25
  const UNTRACKED_AGENTS: AgentName[] = ["cursor", "antigravity"];
22
26
 
23
- function opencodeDatabase(): string {
24
- const dataHome = process.env.XDG_DATA_HOME || resolve(homedir(), ".local", "share");
25
- return resolve(dataHome, "opencode", "opencode.db");
26
- }
27
-
28
27
  function agentReports(project: string | undefined): AgentReport[] {
29
28
  const report = (agent: AgentName, read: () => AgentReport["usage"]) => ({
30
29
  label: AGENT_REGISTRY[agent].label,