portable-agent-layer 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Every commit that still exists in a registered project's repository, as a
3
+ * commit event whose id is its hash. Names, emails and the subject are text,
4
+ * so they go to the content store; the event keeps counts and flags.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { appendEvent } from "../../../hooks/lib/events";
9
+ import { PAL_REPO_URL } from "../../../hooks/lib/remote";
10
+
11
+ const RECORD = "\x1e";
12
+ const FIELD = "\x1f";
13
+ const FORMAT = [
14
+ "%H",
15
+ "%aI",
16
+ "%cI",
17
+ "%an",
18
+ "%ae",
19
+ "%cn",
20
+ "%ce",
21
+ "%D",
22
+ "%P",
23
+ "%s",
24
+ "%b",
25
+ "",
26
+ ]
27
+ .join(FIELD)
28
+ .replace(/^/, RECORD);
29
+ const AI_CO_AUTHOR = new RegExp(
30
+ String.raw`noreply@anthropic\.com|${PAL_REPO_URL.replace(/[.]/g, String.raw`\.`)}|copilot|cursor|openai|codex|gemini`,
31
+ "i"
32
+ );
33
+ const CONVENTIONAL = /^(\w+)(\([^)]*\))?(!)?:/;
34
+
35
+ interface Commit {
36
+ hash: string;
37
+ authoredAt: string;
38
+ committedAt: string;
39
+ author: string;
40
+ authorEmail: string;
41
+ committer: string;
42
+ committerEmail: string;
43
+ subject: string;
44
+ files: number;
45
+ added: number;
46
+ deleted: number;
47
+ commitType: string | null;
48
+ breaking: boolean;
49
+ revert: boolean;
50
+ merge: boolean;
51
+ tags: string[];
52
+ coAuthors: number;
53
+ aiCoAuthored: boolean;
54
+ }
55
+
56
+ function gitLog(repo: string, since: string | undefined): string | null {
57
+ const args = [
58
+ "-C",
59
+ repo,
60
+ "log",
61
+ "--branches",
62
+ "--tags",
63
+ "--remotes",
64
+ "--numstat",
65
+ `--format=${FORMAT}`,
66
+ ];
67
+ if (since) args.push(`--since=${since}`);
68
+ const result = spawnSync("git", args, {
69
+ encoding: "utf-8",
70
+ maxBuffer: 256 * 1024 * 1024,
71
+ });
72
+ return result.status === 0 ? result.stdout : null;
73
+ }
74
+
75
+ function numstatOf(text: string) {
76
+ const rows = text
77
+ .split("\n")
78
+ .map((line) => line.split("\t"))
79
+ .filter((cells) => cells.length === 3);
80
+ const count = (cell: string) => (cell === "-" ? 0 : Number(cell));
81
+ return {
82
+ files: rows.length,
83
+ added: rows.reduce((sum, [added]) => sum + count(added), 0),
84
+ deleted: rows.reduce((sum, [, deleted]) => sum + count(deleted), 0),
85
+ };
86
+ }
87
+
88
+ function coAuthorLines(body: string): string[] {
89
+ return body.split("\n").filter((line) => /^co-authored[- ]by\b/i.test(line.trim()));
90
+ }
91
+
92
+ function tagsOf(refs: string): string[] {
93
+ return refs
94
+ .split(", ")
95
+ .filter((ref) => ref.startsWith("tag: "))
96
+ .map((ref) => ref.slice("tag: ".length));
97
+ }
98
+
99
+ function commitOf(record: string): Commit | null {
100
+ const [hash, aI, cI, an, ae, cn, ce, refs, parents, subject, body, numstat] =
101
+ record.split(FIELD);
102
+ if (!hash || !aI || !cI) return null;
103
+ const conventional = CONVENTIONAL.exec(subject ?? "");
104
+ const coAuthors = coAuthorLines(body ?? "");
105
+ return {
106
+ hash,
107
+ authoredAt: new Date(aI).toISOString(),
108
+ committedAt: new Date(cI).toISOString(),
109
+ author: an ?? "",
110
+ authorEmail: ae ?? "",
111
+ committer: cn ?? "",
112
+ committerEmail: ce ?? "",
113
+ subject: subject ?? "",
114
+ ...numstatOf(numstat ?? ""),
115
+ commitType: conventional?.[1] ?? null,
116
+ breaking: !!conventional?.[3] || /^BREAKING CHANGE:/m.test(body ?? ""),
117
+ revert: conventional?.[1] === "revert" || (subject ?? "").startsWith("Revert "),
118
+ merge: (parents ?? "").trim().split(" ").length > 1,
119
+ tags: tagsOf(refs ?? ""),
120
+ coAuthors: coAuthors.length,
121
+ aiCoAuthored: coAuthors.some((line) => AI_CO_AUTHOR.test(line)),
122
+ };
123
+ }
124
+
125
+ function readCommits(repo: string, since?: string): Commit[] {
126
+ const log = gitLog(repo, since);
127
+ if (log === null) return [];
128
+ return log
129
+ .split(RECORD)
130
+ .map(commitOf)
131
+ .filter((commit): commit is Commit => commit !== null);
132
+ }
133
+
134
+ const commitEventId = (hash: string) => `git:${hash}`;
135
+
136
+ function emitCommit(repo: string, commit: Commit): void {
137
+ appendEvent(
138
+ "commit",
139
+ {
140
+ hash: commit.hash,
141
+ committedAt: commit.committedAt,
142
+ files: commit.files,
143
+ added: commit.added,
144
+ deleted: commit.deleted,
145
+ commitType: commit.commitType,
146
+ breaking: commit.breaking,
147
+ revert: commit.revert,
148
+ merge: commit.merge,
149
+ tags: commit.tags,
150
+ coAuthors: commit.coAuthors,
151
+ aiCoAuthored: commit.aiCoAuthored,
152
+ },
153
+ {
154
+ id: commitEventId(commit.hash),
155
+ ts: commit.authoredAt,
156
+ session: null,
157
+ runtime: "git",
158
+ channel: null,
159
+ cwd: repo,
160
+ content: {
161
+ author: commit.author,
162
+ authorEmail: commit.authorEmail,
163
+ committer: commit.committer,
164
+ committerEmail: commit.committerEmail,
165
+ subject: commit.subject,
166
+ },
167
+ }
168
+ );
169
+ }
170
+
171
+ /** Appends the commits not yet logged and returns how many. */
172
+ export function backfillGit(repos: string[], known: Set<string>, since?: string): number {
173
+ let emitted = 0;
174
+ for (const repo of repos) {
175
+ for (const commit of readCommits(repo, since)) {
176
+ const id = commitEventId(commit.hash);
177
+ if (known.has(id)) continue;
178
+ known.add(id);
179
+ emitCommit(repo, commit);
180
+ emitted++;
181
+ }
182
+ }
183
+ return emitted;
184
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Everything that still exists, read into the event log once. Every event's id
3
+ * comes from its source's own id, so a second run appends nothing.
4
+ */
5
+
6
+ import { loggedEventIds } from "../../../hooks/lib/events";
7
+ import { readAllProjects } from "../../../hooks/lib/projects";
8
+ import { eventLogEnabled } from "../../../hooks/lib/settings";
9
+ import { backfillGit } from "./git";
10
+ import { backfillLedger } from "./ledger";
11
+ import { backfillRatings } from "./ratings";
12
+ import { backfillSessions } from "./sessions";
13
+ import { backfillTurns } from "./turns";
14
+
15
+ function projectRepos(): string[] {
16
+ const paths = readAllProjects().flatMap((project) =>
17
+ project.path ? [project.path] : []
18
+ );
19
+ return [...new Set(paths)];
20
+ }
21
+
22
+ export function backfillEvents(since?: string): Record<string, number> {
23
+ if (!eventLogEnabled()) return {};
24
+ const known = loggedEventIds();
25
+ return {
26
+ git: backfillGit(projectRepos(), known, since),
27
+ sessions: backfillSessions(since),
28
+ turns: backfillTurns(known, since),
29
+ ratings: backfillRatings(known, since),
30
+ ledger: backfillLedger(known, since),
31
+ };
32
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Every ledger entry, as the action event it would have streamed. Rows the
3
+ * test suite leaked into the real ledger before it was sandboxed are left out.
4
+ */
5
+
6
+ import {
7
+ actionEventId,
8
+ type LedgerEntry,
9
+ logActionEvent,
10
+ } from "../../../hooks/lib/ledger";
11
+ import { readLedger } from "../../ledger/query";
12
+ import { emitUnseen, recordedAttribution } from "./rows";
13
+
14
+ const SUITE_SANDBOXED_AT = "2026-09-23T14:35:01.000Z";
15
+
16
+ function leakedFromTestSuite(entry: LedgerEntry): boolean {
17
+ return entry.command === "rm -rf /" && entry.ts < SUITE_SANDBOXED_AT;
18
+ }
19
+
20
+ function emitAction(entry: LedgerEntry): void {
21
+ logActionEvent(entry, recordedAttribution(entry));
22
+ }
23
+
24
+ /** Appends the ledger entries not yet logged and returns how many. */
25
+ export function backfillLedger(known: Set<string>, since?: string): number {
26
+ const entries = readLedger().filter((entry) => !leakedFromTestSuite(entry));
27
+ return emitUnseen(entries, actionEventId, emitAction, known, since);
28
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Every rating in the signals file, as a rating event. The rated context and
3
+ * the reply preview are text, so they go to the content store.
4
+ */
5
+
6
+ import {
7
+ logRatingEvent,
8
+ type RatingSignal,
9
+ ratingEventId,
10
+ readRatings,
11
+ } from "../../../hooks/lib/signals";
12
+ import { emitUnseen, recordedAttribution } from "./rows";
13
+
14
+ function emitRating(row: RatingSignal): void {
15
+ logRatingEvent(row, { cwd: "", attribution: recordedAttribution(row) });
16
+ }
17
+
18
+ /** Appends the ratings not yet logged and returns how many. */
19
+ export function backfillRatings(known: Set<string>, since?: string): number {
20
+ return emitUnseen(readRatings(), ratingEventId, emitRating, known, since);
21
+ }
@@ -0,0 +1,33 @@
1
+ import type { RecordAttribution } from "../../../hooks/lib/actor";
2
+
3
+ export type AttributedRow = Partial<RecordAttribution> & { ts: string };
4
+
5
+ /** The origin a row was written with; a field it never carried is unknown, not this machine's. */
6
+ export function recordedAttribution(row: AttributedRow): Partial<RecordAttribution> {
7
+ return {
8
+ machine: row.machine ?? "unknown",
9
+ actor: row.actor ?? "unknown",
10
+ runtime: row.runtime ?? "unknown",
11
+ ...(row.authority && { authority: row.authority }),
12
+ };
13
+ }
14
+
15
+ /** Emits each row whose id is not logged yet and that falls on or after `since`; returns how many. */
16
+ export function emitUnseen<Row extends AttributedRow>(
17
+ rows: Row[],
18
+ idOf: (row: Row) => string,
19
+ emit: (row: Row, id: string) => void,
20
+ known: Set<string>,
21
+ since?: string
22
+ ): number {
23
+ const from = new Date(since ?? 0).toISOString();
24
+ let emitted = 0;
25
+ for (const row of rows) {
26
+ const id = idOf(row);
27
+ if (known.has(id) || row.ts < from) continue;
28
+ known.add(id);
29
+ emit(row, id);
30
+ emitted++;
31
+ }
32
+ return emitted;
33
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Every session PAL still has a record of, as the session_end it would have
3
+ * logged. A record is the session's last known state, so it carries no reason,
4
+ * duration or turn count; its name, summary and handoff are text and go to the
5
+ * content store.
6
+ */
7
+
8
+ import { appendEvent, eventsSince } from "../../../hooks/lib/events";
9
+ import { readSessionHistory, type SessionRecord } from "../../../hooks/lib/work-tracking";
10
+
11
+ const sessionEventId = (sessionId: string) => `session:${sessionId}`;
12
+
13
+ function endedSessions(): Set<string> {
14
+ return new Set(
15
+ eventsSince("session_end", new Date(0)).flatMap((e) => (e.session ? [e.session] : []))
16
+ );
17
+ }
18
+
19
+ function emitSession(record: SessionRecord): void {
20
+ appendEvent(
21
+ "session_end",
22
+ {
23
+ reason: "backfill",
24
+ durationSec: null,
25
+ turns: null,
26
+ messages: record.messageCount,
27
+ status: record.status,
28
+ },
29
+ {
30
+ id: sessionEventId(record.sessionId),
31
+ ts: record.ts,
32
+ session: record.sessionId,
33
+ runtime: "unknown",
34
+ channel: null,
35
+ cwd: record.cwd,
36
+ content: {
37
+ name: record.name,
38
+ summary: record.summary,
39
+ handoff: record.handoff,
40
+ artifacts: record.artifacts,
41
+ },
42
+ }
43
+ );
44
+ }
45
+
46
+ /** Appends the sessions with no session_end yet and returns how many. */
47
+ export function backfillSessions(since?: string): number {
48
+ const ended = endedSessions();
49
+ const fresh = readSessionHistory().filter(
50
+ (record) =>
51
+ !ended.has(record.sessionId) &&
52
+ (!since || record.ts >= new Date(since).toISOString())
53
+ );
54
+ for (const record of fresh) emitSession(record);
55
+ return fresh.length;
56
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Every turn measured before turns streamed into the event log. A row keeps
3
+ * the machine, actor and runtime it was written with, since an imported file
4
+ * holds other machines' rows too.
5
+ */
6
+
7
+ import { appendEvent } from "../../../hooks/lib/events";
8
+ import {
9
+ legacyTurnId,
10
+ legacyTurns,
11
+ type TurnEvent,
12
+ } from "../../../hooks/lib/interaction";
13
+ import { type AttributedRow, emitUnseen, recordedAttribution } from "./rows";
14
+
15
+ type LegacyTurn = TurnEvent & AttributedRow;
16
+
17
+ function emitTurn(turn: LegacyTurn, id: string): void {
18
+ const { ts, session, channel, ...fields } = turn;
19
+ appendEvent("turn", fields, {
20
+ id,
21
+ ts,
22
+ session,
23
+ channel: channel ?? null,
24
+ cwd: "",
25
+ attribution: recordedAttribution(turn),
26
+ });
27
+ }
28
+
29
+ /** Appends the legacy turns not yet logged and returns how many. */
30
+ export function backfillTurns(known: Set<string>, since?: string): number {
31
+ return emitUnseen(legacyTurns() as LegacyTurn[], legacyTurnId, emitTurn, known, since);
32
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * How each agent's model calls are paid for: a subscription, an API key, or
3
+ * unknown. Each reader takes the few fields that say so and nothing else from
4
+ * files that also hold the agent's credentials.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
9
+ import { platform } from "../../../hooks/lib/paths";
10
+
11
+ export interface Billing {
12
+ billing: "subscription" | "api" | "unknown";
13
+ plan: string | null;
14
+ extraUsage: boolean | null;
15
+ }
16
+
17
+ export const UNKNOWN: Billing = { billing: "unknown", plan: null, extraUsage: null };
18
+ const API: Billing = { billing: "api", plan: null, extraUsage: null };
19
+ const SUBSCRIPTION: Billing = { billing: "subscription", plan: null, extraUsage: null };
20
+
21
+ type Json = Record<string, unknown>;
22
+
23
+ function readJson(path: string): Json {
24
+ try {
25
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf-8")) : {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function stringField(record: unknown, name: string): string | null {
32
+ const value = (record as Json | undefined)?.[name];
33
+ return typeof value === "string" ? value : null;
34
+ }
35
+
36
+ function booleanField(record: unknown, name: string): boolean | null {
37
+ const value = (record as Json | undefined)?.[name];
38
+ return typeof value === "boolean" ? value : null;
39
+ }
40
+
41
+ interface ClaudeSubscription {
42
+ plan: string | null;
43
+ extraUsage: boolean | null;
44
+ since: number;
45
+ }
46
+
47
+ function claudeSubscriptionOf(config: Json): ClaudeSubscription | null {
48
+ const account = config.oauthAccount;
49
+ if (!stringField(account, "billingType")) return null;
50
+ return {
51
+ plan:
52
+ stringField(account, "organizationRateLimitTier") ??
53
+ stringField(account, "organizationType"),
54
+ extraUsage: booleanField(account, "hasExtraUsageEnabled"),
55
+ since: Date.parse(stringField(account, "subscriptionCreatedAt") ?? ""),
56
+ };
57
+ }
58
+
59
+ export function claudeConfigFile(): string {
60
+ return resolve(dirname(platform.claudeDir()), ".claude.json");
61
+ }
62
+
63
+ /** Billing for a call at `at`; a call older than the subscription is unknown. */
64
+ export function claudeBilling(configPath: string): (at: string) => Billing {
65
+ const config = readJson(configPath);
66
+ const subscription = claudeSubscriptionOf(config);
67
+ const apiKeyLogin = "primaryApiKey" in config;
68
+ return (at) => {
69
+ if (!subscription) return apiKeyLogin ? API : UNKNOWN;
70
+ if (Date.parse(at) < subscription.since) return UNKNOWN;
71
+ return {
72
+ billing: "subscription",
73
+ plan: subscription.plan,
74
+ extraUsage: subscription.extraUsage,
75
+ };
76
+ };
77
+ }
78
+
79
+ const CODEX_AUTH_MODES: Record<string, Billing> = { apikey: API, chatgpt: SUBSCRIPTION };
80
+
81
+ export function codexBilling(authPath: string): Billing {
82
+ return CODEX_AUTH_MODES[stringField(readJson(authPath), "auth_mode") ?? ""] ?? UNKNOWN;
83
+ }
84
+
85
+ const OPENCODE_AUTH_TYPES: Record<string, Billing> = {
86
+ oauth: SUBSCRIPTION,
87
+ api: API,
88
+ wellknown: API,
89
+ };
90
+
91
+ /** Billing per provider, from the type of the login opencode holds for it. */
92
+ export function opencodeBilling(
93
+ authPath: string
94
+ ): (provider: string | undefined) => Billing {
95
+ const logins = readJson(authPath);
96
+ const billingOf = new Map(
97
+ Object.entries(logins).map(([provider, login]) => [
98
+ provider,
99
+ OPENCODE_AUTH_TYPES[stringField(login, "type") ?? ""] ?? UNKNOWN,
100
+ ])
101
+ );
102
+ return (provider) => (provider && billingOf.get(provider)) || UNKNOWN;
103
+ }
104
+
105
+ export const COPILOT_BILLING = SUBSCRIPTION;
@@ -12,6 +12,7 @@ import { relative, sep } from "node:path";
12
12
  import { appendEvent } from "../../../hooks/lib/events";
13
13
  import { cacheWritesOf, type TranscriptUsage } from "../transcript-usage";
14
14
  import { claudeTranscripts } from "../usage-buckets";
15
+ import { type Billing, UNKNOWN } from "./billing";
15
16
  import { readCursor, writeCursor } from "./cursors";
16
17
 
17
18
  const SETTLE_MS = 5 * 60_000;
@@ -34,12 +35,14 @@ interface ClaudeLine {
34
35
  };
35
36
  }
36
37
 
38
+ type ModelCallLine = ClaudeLine & { timestamp: string };
39
+
37
40
  interface SettledRead {
38
- messages: ClaudeLine[];
41
+ messages: ModelCallLine[];
39
42
  resumeAt: number;
40
43
  }
41
44
 
42
- function isModelCall(line: ClaudeLine | null): line is ClaudeLine {
45
+ function isModelCall(line: ClaudeLine | null): line is ModelCallLine {
43
46
  return (
44
47
  line?.type === "assistant" &&
45
48
  !!line.message?.usage &&
@@ -79,7 +82,7 @@ function settledMessages(
79
82
  settled: boolean
80
83
  ): SettledRead {
81
84
  const { lines, end } = completeLinesFrom(filepath, offset);
82
- const groups = new Map<string, { start: number; line: ClaudeLine }>();
85
+ const groups = new Map<string, { start: number; line: ModelCallLine }>();
83
86
  let newest: string | undefined;
84
87
  for (const { text, start } of lines) {
85
88
  if (!text.includes('"usage"')) continue;
@@ -97,11 +100,12 @@ function settledMessages(
97
100
  };
98
101
  }
99
102
 
100
- function emitModelCall(line: ClaudeLine): void {
103
+ function emitModelCall(line: ClaudeLine, billing: Billing): void {
101
104
  const usage = line.message?.usage ?? {};
102
105
  appendEvent(
103
106
  "model_call",
104
107
  {
108
+ ...billing,
105
109
  model: line.message?.model,
106
110
  requestedModel: line.requestedModel,
107
111
  inputTokens: usage.input_tokens,
@@ -132,7 +136,11 @@ function isSettled(filepath: string, now: Date): boolean {
132
136
  return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
133
137
  }
134
138
 
135
- export function collectClaude(projectsDir: string, now: Date = new Date()): number {
139
+ export function collectClaude(
140
+ projectsDir: string,
141
+ now: Date = new Date(),
142
+ billingAt: (at: string) => Billing = () => UNKNOWN
143
+ ): number {
136
144
  const cursor = readCursor("claude");
137
145
  let emitted = 0;
138
146
  for (const filepath of claudeTranscripts(projectsDir)) {
@@ -140,7 +148,7 @@ export function collectClaude(projectsDir: string, now: Date = new Date()): numb
140
148
  const offset = Number(cursor[key] ?? 0);
141
149
  const read = settledMessages(filepath, offset, isSettled(filepath, now));
142
150
  if (read.resumeAt === offset) continue;
143
- for (const line of read.messages) emitModelCall(line);
151
+ for (const line of read.messages) emitModelCall(line, billingAt(line.timestamp));
144
152
  emitted += read.messages.length;
145
153
  cursor[key] = read.resumeAt;
146
154
  writeCursor("claude", cursor);
@@ -5,9 +5,10 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, statSync } from "node:fs";
8
- import { relative, sep } from "node:path";
8
+ import { relative, resolve, sep } from "node:path";
9
9
  import { appendEvent } from "../../../hooks/lib/events";
10
10
  import { codexRollouts } from "../agent-usage";
11
+ import { type Billing, codexBilling } from "./billing";
11
12
  import { readCursor, writeCursor } from "./cursors";
12
13
 
13
14
  interface CodexUsage {
@@ -90,12 +91,13 @@ function callsOf(filepath: string): CodexCall[] {
90
91
  return calls;
91
92
  }
92
93
 
93
- function emitModelCall({ ts, usage, context }: CodexCall): void {
94
+ function emitModelCall({ ts, usage, context }: CodexCall, billing: Billing): void {
94
95
  const cacheRead = usage.cached_input_tokens ?? 0;
95
96
  const cacheWrite = usage.cache_write_input_tokens ?? 0;
96
97
  appendEvent(
97
98
  "model_call",
98
99
  {
100
+ ...billing,
99
101
  model: context.model,
100
102
  provider: context.provider,
101
103
  inputTokens: (usage.input_tokens ?? 0) - cacheRead - cacheWrite,
@@ -111,6 +113,7 @@ function emitModelCall({ ts, usage, context }: CodexCall): void {
111
113
 
112
114
  export function collectCodex(codexDir: string): number {
113
115
  const cursor = readCursor("codex");
116
+ const billing = codexBilling(resolve(codexDir, "auth.json"));
114
117
  let emitted = 0;
115
118
  for (const filepath of codexRollouts(codexDir)) {
116
119
  const key = relative(codexDir, filepath).split(sep).join("/");
@@ -119,7 +122,7 @@ export function collectCodex(codexDir: string): number {
119
122
  const collectedTotal = Number(cursor[key] ?? -1);
120
123
  for (const call of callsOf(filepath)) {
121
124
  if (call.total <= collectedTotal) continue;
122
- emitModelCall(call);
125
+ emitModelCall(call, billing);
123
126
  emitted++;
124
127
  cursor[key] = call.total;
125
128
  }
@@ -7,6 +7,7 @@
7
7
  import { existsSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
9
  import { appendEvent } from "../../../hooks/lib/events";
10
+ import { COPILOT_BILLING } from "./billing";
10
11
  import { readCursor, writeCursor } from "./cursors";
11
12
  import { type AllowList, selectAllowed } from "./sqlite";
12
13
 
@@ -66,6 +67,7 @@ function emitModelCall(row: UsageRow, session: SessionRow | undefined): void {
66
67
  appendEvent(
67
68
  "model_call",
68
69
  {
70
+ ...COPILOT_BILLING,
69
71
  model: row.model,
70
72
  inputTokens: num(row.input_tokens) - cacheRead,
71
73
  outputTokens: row.output_tokens,
@@ -12,6 +12,7 @@ import { paths, platform } from "../../../hooks/lib/paths";
12
12
  import { eventLogEnabled } from "../../../hooks/lib/settings";
13
13
  import { opencodeDatabase } from "../agent-usage";
14
14
  import { antigravityDatabase, collectAntigravity } from "./antigravity";
15
+ import { claudeBilling, claudeConfigFile } from "./billing";
15
16
  import { collectClaude } from "./claude";
16
17
  import { collectCodex } from "./codex";
17
18
  import { collectCopilot, copilotDatabase } from "./copilot";
@@ -20,7 +21,12 @@ import { collectOpencode } from "./opencode";
20
21
  const LOCK_STALE_MS = 10 * 60_000;
21
22
 
22
23
  const COLLECTORS: Record<string, () => number> = {
23
- claude: () => collectClaude(resolve(platform.claudeDir(), "projects")),
24
+ claude: () =>
25
+ collectClaude(
26
+ resolve(platform.claudeDir(), "projects"),
27
+ new Date(),
28
+ claudeBilling(claudeConfigFile())
29
+ ),
24
30
  opencode: () => collectOpencode(opencodeDatabase()),
25
31
  codex: () => collectCodex(platform.codexDir()),
26
32
  copilot: () => collectCopilot(copilotDatabase(platform.copilotDir())),
@@ -5,7 +5,9 @@
5
5
  */
6
6
 
7
7
  import { existsSync } from "node:fs";
8
+ import { dirname, resolve } from "node:path";
8
9
  import { appendEvent } from "../../../hooks/lib/events";
10
+ import { type Billing, opencodeBilling } from "./billing";
9
11
  import { readCursor, writeCursor } from "./cursors";
10
12
  import { type AllowList, selectAllowed } from "./sqlite";
11
13
 
@@ -57,11 +59,16 @@ function durationOf(message: AssistantMessage): number | undefined {
57
59
  return created && completed ? completed - created : undefined;
58
60
  }
59
61
 
60
- function emitModelCall(row: MessageRow, message: AssistantMessage): void {
62
+ function emitModelCall(
63
+ row: MessageRow,
64
+ message: AssistantMessage,
65
+ billing: Billing
66
+ ): void {
61
67
  const tokens = message.tokens ?? {};
62
68
  appendEvent(
63
69
  "model_call",
64
70
  {
71
+ ...billing,
65
72
  model: message.modelID,
66
73
  provider: message.providerID,
67
74
  inputTokens: tokens.input,
@@ -99,12 +106,13 @@ export function collectOpencode(dbPath: string, now: Date = new Date()): number
99
106
  id: String(cursor.id ?? ""),
100
107
  }
101
108
  );
109
+ const billingOf = opencodeBilling(resolve(dirname(dbPath), "auth.json"));
102
110
  let emitted = 0;
103
111
  for (const row of rows) {
104
112
  const message = assistantMessageOf(row);
105
113
  if (message && stillStreaming(message, row, now)) break;
106
114
  if (message) {
107
- emitModelCall(row, message);
115
+ emitModelCall(row, message, billingOf(message.providerID));
108
116
  emitted++;
109
117
  }
110
118
  cursor.order = row.time_created;