@paxnax/berliner 0.6.0 → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
3
+ import { jsonl, num, Span, sumByModel, ticketUrl } from "./read.js";
4
4
  // Subagent transcripts sit next to the session's: <project>/<session-id>/subagents/agent-*.jsonl.
5
5
  export async function subagentFiles(transcriptPath) {
6
6
  const dir = join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
@@ -26,7 +26,9 @@ export async function readClaudeCode(transcriptPath) {
26
26
  let firstPrompt = null;
27
27
  let taskUrl = null;
28
28
  for (const file of [transcriptPath, ...(await subagentFiles(transcriptPath))]) {
29
+ let line = -1;
29
30
  for await (const e of jsonl(file)) {
31
+ line++;
30
32
  span.add(e.timestamp);
31
33
  if (file === transcriptPath) {
32
34
  const prompt = promptText(e);
@@ -40,7 +42,10 @@ export async function readClaudeCode(transcriptPath) {
40
42
  const u = m.usage;
41
43
  // cache_creation_input_tokens counts both kinds of write; the 1-hour ones cost more.
42
44
  const writes1h = num(u.cache_creation?.ephemeral_1h_input_tokens);
43
- responses.set(m.id ?? Symbol(), {
45
+ // The line index counts parsed lines only, which is stable as the file is only appended to.
46
+ const id = typeof m.id === "string" ? m.id : typeof e.uuid === "string" ? e.uuid : `${file}:${line}`;
47
+ responses.set(id, {
48
+ id,
44
49
  model: m.model,
45
50
  tokens: {
46
51
  input: num(u.input_tokens),
@@ -52,14 +57,12 @@ export async function readClaudeCode(transcriptPath) {
52
57
  });
53
58
  }
54
59
  }
55
- const tokensByModel = {};
56
- for (const { model, tokens } of responses.values()) {
57
- tokensByModel[model] = tokensByModel[model] ? addTokens(tokensByModel[model], tokens) : tokens;
58
- }
60
+ const replies = [...responses.values()];
61
+ const tokensByModel = sumByModel(replies);
59
62
  // The session's model is the one that wrote the most.
60
63
  let model = null;
61
64
  for (const [id, t] of Object.entries(tokensByModel))
62
65
  if (model === null || t.output > tokensByModel[model].output)
63
66
  model = id;
64
- return { model, tokensByModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
67
+ return { model, tokensByModel, replies, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
65
68
  }
@@ -1,4 +1,4 @@
1
- import { addTokens, jsonl, num, Span, ticketUrl } from "./read.js";
1
+ import { jsonl, num, Span, sumByModel, ticketUrl } from "./read.js";
2
2
  const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 };
3
3
  const tokensOf = (u) => ({
4
4
  input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
@@ -17,23 +17,22 @@ export async function readCodex(rolloutPath) {
17
17
  let latest = zero;
18
18
  let latestTotal = 0;
19
19
  let latestLast = 0;
20
- const byModel = {};
21
20
  // Codex 0.148+ writes a token_usage_record per model response, before its token_count; where present they are the usage.
22
- let beforeRecords = null;
23
- const records = {};
24
- const responses = new Set();
21
+ // Replies from token_count growth come first; a rollout begun on an older Codex and resumed on a newer one keeps those before its first record.
22
+ const counted = [];
23
+ let countedBeforeRecords = null;
24
+ const records = new Map();
25
25
  for await (const e of jsonl(rolloutPath)) {
26
26
  span.add(e.timestamp);
27
27
  const p = e.payload;
28
28
  if (e.type === "turn_context" && typeof p?.model === "string")
29
29
  model = p.model;
30
30
  if (e.type === "token_usage_record" && p?.usage) {
31
- // A rollout begun on an older Codex and resumed on a newer one counts its earlier part from token_count.
32
- beforeRecords ??= { ...byModel };
33
- if (model && !(typeof p.response_id === "string" && responses.has(p.response_id))) {
34
- responses.add(p.response_id);
35
- records[model] = addTokens(records[model] ?? zero, tokensOf(p.usage));
36
- }
31
+ countedBeforeRecords ??= counted.length;
32
+ const id = typeof p.response_id === "string" ? p.response_id : `tr:${e.timestamp}:${num(p.usage.total_tokens)}`;
33
+ // A response id repeated in the rollout counts once, whatever model is in effect.
34
+ if (model && !(typeof p.response_id === "string" && records.has(id)))
35
+ records.set(id, { id, model, tokens: tokensOf(p.usage) });
37
36
  }
38
37
  if (e.type !== "event_msg")
39
38
  continue;
@@ -57,14 +56,12 @@ export async function readCodex(rolloutPath) {
57
56
  const cur = tokensOf(u);
58
57
  // Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
59
58
  const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
60
- if (model)
61
- byModel[model] = addTokens(byModel[model] ?? zero, delta);
59
+ if (model && Object.values(delta).some((v) => v > 0))
60
+ counted.push({ id: `tc:${e.timestamp}:${total}`, model, tokens: delta });
62
61
  latestTotal = total;
63
62
  latestLast = last;
64
63
  latest = cur;
65
64
  }
66
- if (beforeRecords)
67
- for (const [m, t] of Object.entries(records))
68
- beforeRecords[m] = addTokens(beforeRecords[m] ?? zero, t);
69
- return { model, tokensByModel: beforeRecords ?? byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
65
+ const replies = countedBeforeRecords === null ? counted : [...counted.slice(0, countedBeforeRecords), ...records.values()];
66
+ return { model, tokensByModel: sumByModel(replies), replies, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
70
67
  }
@@ -7,7 +7,9 @@ const iso = (ms) => {
7
7
  export function readOpenCode(json) {
8
8
  const s = (typeof json === "object" && json !== null ? json : {});
9
9
  const model = typeof s.model?.id === "string" ? s.model.id : null;
10
- const t = s.tokens;
10
+ const own = s.own && typeof s.own.cost === "number" && Number.isFinite(s.own.cost) && s.own.tokens ? s.own : null;
11
+ const t = own ? own.tokens : s.tokens;
12
+ const cost = own ? own.cost : s.cost;
11
13
  const tokens = {
12
14
  input: num(t?.input),
13
15
  // OpenCode counts reasoning apart from output and bills it at the output price.
@@ -19,7 +21,9 @@ export function readOpenCode(json) {
19
21
  return {
20
22
  model,
21
23
  tokensByModel: model && t ? { [model]: tokens } : {},
22
- costUsd: typeof s.cost === "number" && Number.isFinite(s.cost) ? s.cost : null,
24
+ // A session row has no per-reply records.
25
+ replies: [],
26
+ costUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
23
27
  startedAt: iso(s.time?.created),
24
28
  endedAt: iso(s.time?.updated),
25
29
  firstPrompt: null,
@@ -74,3 +74,9 @@ export class Span {
74
74
  this.last = ts;
75
75
  }
76
76
  }
77
+ export function sumByModel(replies) {
78
+ const sums = {};
79
+ for (const { model, tokens } of replies)
80
+ sums[model] = sums[model] ? addTokens(sums[model], tokens) : tokens;
81
+ return sums;
82
+ }
package/dist/args.js CHANGED
@@ -5,6 +5,8 @@ const config = {
5
5
  title: { type: "string" },
6
6
  repo: { type: "string" },
7
7
  cost: { type: "string" },
8
+ // Set only by fromTool, from the ledger; not a documented flag.
9
+ "cost-status": { type: "string" },
8
10
  model: { type: "string" },
9
11
  plan: { type: "string" },
10
12
  task: { type: "string" },
@@ -18,6 +18,7 @@ export function sessionBody(values) {
18
18
  title: values.title,
19
19
  repository: values.repo,
20
20
  cost_usd: number(values, "cost"),
21
+ cost_status: values["cost-status"],
21
22
  model: values.model,
22
23
  plan: values.plan,
23
24
  task_id: values.task,
@@ -42,6 +43,7 @@ export async function fromTool(tool, values, env, command) {
42
43
  if (facts) {
43
44
  // Unknown without prices: left out, so a cost already saved stays (report creates a new session at 0).
44
45
  filled.cost = facts.costUsd === null ? undefined : String(facts.costUsd);
46
+ filled["cost-status"] = facts.costStatus ?? undefined;
45
47
  filled.model = facts.model ?? values.model;
46
48
  }
47
49
  const ignored = ["session-id", "cost", "model"].filter((f) => values[f] !== undefined && values[f] !== filled[f]);
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { appendFileSync, existsSync, linkSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { basename, join } from "node:path";
5
5
  import { findRecord, readClaudeCode, readCodex, readOpenCode, SAFE_ID } from "../agents/index.js";
@@ -7,6 +7,8 @@ import { CliError, request } from "../client.js";
7
7
  import { readConfig, stateDir } from "../config.js";
8
8
  import { viewPr } from "../gh.js";
9
9
  import { gitFacts } from "../git.js";
10
+ import { priceSession, sessionTime } from "../ledger.js";
11
+ import { takeLock } from "../lock.js";
10
12
  import { estimateCost, loadPrices } from "../prices.js";
11
13
  import { noteRefusal, resolveTeam, teamWarning } from "../team.js";
12
14
  import { checkTests } from "../tests/check.js";
@@ -18,7 +20,6 @@ const REPORT_EVENTS = ["Stop", "PreCompact", "SessionEnd"];
18
20
  const OPENCODE_EVENTS = ["idle", "compacting"];
19
21
  const SESSION_STARTS = ["startup", "resume", "compact", "clear"];
20
22
  const MAX_PAYLOAD = 1024 * 1024;
21
- const LOCK_MS = 60_000;
22
23
  const DEADLINE_MS = 15_000;
23
24
  const MAX_GH = 5;
24
25
  export function inTool(env) {
@@ -30,8 +31,8 @@ export function inTool(env) {
30
31
  return { tool: "opencode", sessionId: env.OPENCODE_SESSION_ID };
31
32
  return null;
32
33
  }
33
- // The session's facts from the tool's own record, with a cost estimated wherever the tool stores none; the cost stays null when
34
- // no prices load. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
34
+ // The session's facts from the tool's own record, with a cost priced once per reply in the ledger wherever the tool stores none.
35
+ // The cost and its status stay null when no prices load, the ledger is busy or its backfill is unfinished. Claude Code and Codex name the record in the hook payload; otherwise it is looked up. OpenCode's plugin sends
35
36
  // the session itself too, used only without a saved record. Null without a record.
36
37
  export async function sessionFacts(tool, sessionId, env, payload = {}) {
37
38
  let facts;
@@ -53,10 +54,22 @@ export async function sessionFacts(tool, sessionId, env, payload = {}) {
53
54
  // Codex's payload names the model the session runs on now.
54
55
  if (tool === "codex" && typeof payload.model === "string" && payload.model)
55
56
  facts = { ...facts, model: payload.model };
56
- if (facts.costUsd !== null)
57
- return facts;
58
57
  const prices = await loadPrices(env);
59
- return prices ? { ...facts, costUsd: estimateCost(facts.tokensByModel, prices).costUsd } : facts;
58
+ // OpenCode stays out of the ledger: its own figure, else an estimate. A 0 is a session nobody priced.
59
+ if (tool === "opencode") {
60
+ if (facts.costUsd !== null)
61
+ return { ...facts, costStatus: facts.costUsd > 0 ? "exact" : "unknown" };
62
+ if (!prices)
63
+ return { ...facts, costStatus: null };
64
+ const { costUsd, unpriced } = estimateCost(facts.tokensByModel, prices);
65
+ // As in the ledger: partial while some model has no price.
66
+ return { ...facts, costUsd, costStatus: costUsd === 0 ? "unknown" : unpriced.length ? "partial" : "exact" };
67
+ }
68
+ if (!prices || !file)
69
+ return { ...facts, costStatus: null };
70
+ // The record file's creation time, not the first entry's: the backfill orders sessions by it too.
71
+ const priced = await priceSession(env, { id: sessionId, startedAt: await sessionTime(file, facts.startedAt) }, facts.replies, prices);
72
+ return { ...facts, costUsd: priced?.costUsd ?? null, costStatus: priced?.status ?? null };
60
73
  }
61
74
  // The first prompt's first line cut to 80 characters, else the branch. The repository is the last resort: a new session needs a title.
62
75
  export function derivedTitle(firstPrompt, git) {
@@ -169,49 +182,6 @@ async function child(tool, file, env) {
169
182
  clearTimeout(timer);
170
183
  }
171
184
  }
172
- // A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is moved aside and
173
- // removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
174
- export function takeLock(lock) {
175
- if (createLock(lock))
176
- return true;
177
- const seen = statSync(lock, { throwIfNoEntry: false });
178
- // Gone since: its child just unlocked, so it is free, not stale.
179
- if (!seen)
180
- return createLock(lock);
181
- if (Date.now() - seen.mtimeMs < LOCK_MS)
182
- return false;
183
- const aside = `${lock}.${process.pid}`;
184
- try {
185
- renameSync(lock, aside);
186
- }
187
- catch (err) {
188
- if (err.code === "ENOENT")
189
- return false;
190
- throw err;
191
- }
192
- if (statSync(aside).ino !== seen.ino) {
193
- // shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
194
- try {
195
- linkSync(aside, lock);
196
- }
197
- catch { }
198
- rmSync(aside, { force: true });
199
- return false;
200
- }
201
- rmSync(aside, { force: true });
202
- return createLock(lock);
203
- }
204
- function createLock(lock) {
205
- try {
206
- writeFileSync(lock, String(process.pid), { flag: "wx" });
207
- return true;
208
- }
209
- catch (err) {
210
- if (err.code === "EEXIST")
211
- return false;
212
- throw err;
213
- }
214
- }
215
185
  // Only the fields the machine knows. The task, PRs, coverage, evidence, project, an existing title and the plan are the agent's.
216
186
  async function send(tool, file, env) {
217
187
  const payload = JSON.parse(await readFile(file, "utf8"));
@@ -249,6 +219,7 @@ async function send(tool, file, env) {
249
219
  const body = {
250
220
  // Unknown without prices: left out, so a cost already saved stays.
251
221
  cost_usd: facts.costUsd ?? undefined,
222
+ cost_status: facts.costStatus ?? undefined,
252
223
  model: facts.model ?? undefined,
253
224
  commits: git.commits ?? undefined,
254
225
  started_at: facts.startedAt ?? undefined,
@@ -21,6 +21,8 @@ export async function report(cfg, values, positionals, env) {
21
21
  throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
22
22
  if (values.public && !values.evidence?.length)
23
23
  throw new CliError("--public needs --evidence", "usage");
24
+ // Parsed before anything is saved, so a bad label saves nothing.
25
+ const evidence = (values.evidence ?? []).map(parseEvidence);
24
26
  const team = await resolveTeam(values, process.cwd(), env);
25
27
  const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
26
28
  const id = values["session-id"];
@@ -43,7 +45,7 @@ export async function report(cfg, values, positionals, env) {
43
45
  print({ data: json, text }, true);
44
46
  return;
45
47
  }
46
- const result = await uploadEvidence(cfg, id, values.evidence.map(parseEvidence), undefined, values.public === true);
48
+ const result = await uploadEvidence(cfg, id, evidence, undefined, values.public === true);
47
49
  if (json_)
48
50
  print({ data: { ...json, evidence: result }, text }, true);
49
51
  else {
@@ -56,8 +58,17 @@ export async function report(cfg, values, positionals, env) {
56
58
  if (values.strict && result.skipped.length)
57
59
  process.exitCode = 1;
58
60
  }
59
- // <path>[::<caption>]
61
+ // <path>[::<caption>[::happy|unhappy|edge]]
60
62
  function parseEvidence(spec) {
61
63
  const i = spec.indexOf("::");
62
- return i < 0 ? { path: spec } : { path: spec.slice(0, i), caption: spec.slice(i + 2) || undefined };
64
+ if (i < 0)
65
+ return { path: spec };
66
+ const rest = spec.slice(i + 2);
67
+ const j = rest.lastIndexOf("::");
68
+ if (j < 0)
69
+ return { path: spec.slice(0, i), caption: rest || undefined };
70
+ const label = rest.slice(j + 2);
71
+ if (!["happy", "unhappy", "edge"].includes(label))
72
+ throw new CliError(`Evidence label must be happy, unhappy or edge: "${label}"`, "usage");
73
+ return { path: spec.slice(0, i), caption: rest.slice(0, j) || undefined, path_label: label };
63
74
  }
@@ -1,5 +1,5 @@
1
1
  import { absolute, CliError, request } from "../client.js";
2
- import { mergeLabel, print } from "../output.js";
2
+ import { costLabel, mergeLabel, print } from "../output.js";
3
3
  import { announceRefusal } from "../team.js";
4
4
  export async function session(cfg, values, positionals) {
5
5
  await announceRefusal(process.env);
@@ -13,7 +13,7 @@ export async function session(cfg, values, positionals) {
13
13
  ["Repository", s.repository],
14
14
  ["Task", [s.task_id, s.task_url].filter(Boolean).join(" ")],
15
15
  ["Project", s.project],
16
- ["Estimated cost", `$${s.cost_usd.toFixed(2)}`],
16
+ ["Estimated cost", costLabel(s.cost_usd, s.cost_status)],
17
17
  ["Model", s.model],
18
18
  ["Plan", s.plan],
19
19
  ["Started", s.started_at],
@@ -1,14 +1,13 @@
1
1
  import { request } from "../client.js";
2
- import { mergeLabel, print } from "../output.js";
2
+ import { costLabel, mergeLabel, print } from "../output.js";
3
3
  import { announceRefusal } from "../team.js";
4
4
  import { filterQuery } from "./query.js";
5
- const usd = (n) => `$${n.toFixed(2)}`;
6
5
  export async function sessions(cfg, values) {
7
6
  await announceRefusal(process.env);
8
7
  const { json } = await request(cfg, "GET", `/v1/sessions${filterQuery(values, { limit: values.limit })}`);
9
8
  const rows = [["TASK", "TITLE", "REPO", "COST (est.)", "PRS", "CI", "MERGE"]];
10
9
  for (const s of json.sessions)
11
- rows.push([s.task_id ?? s.id, s.title, s.repository, usd(s.cost_usd), String(s.pr_count), s.ci, mergeLabel(s)]);
10
+ rows.push([s.task_id ?? s.id, s.title, s.repository, costLabel(s.cost_usd, s.cost_status), String(s.pr_count), s.ci, mergeLabel(s)]);
12
11
  const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
13
12
  const lines = rows.map((r) => r
14
13
  .map((c, i) => c.padEnd(widths[i]))
@@ -1,14 +1,15 @@
1
1
  import { request } from "../client.js";
2
- import { print, subscriptionLabel } from "../output.js";
2
+ import { print, subscriptionLabel, totalLabel, unpricedNote } from "../output.js";
3
3
  import { filterQuery } from "./query.js";
4
4
  export async function summary(cfg, values) {
5
5
  const { json } = await request(cfg, "GET", `/v1/summary${filterQuery(values)}`);
6
6
  const s = json.summary;
7
+ const unpriced = s.unpriced_sessions ?? 0;
7
8
  const text = [
8
- `Estimated spend: $${s.spend_usd.toFixed(2)}`,
9
+ `Spend: ${totalLabel(s.spend_usd, unpriced)}${unpricedNote(unpriced)}`,
9
10
  `Sessions: ${s.sessions}`,
10
11
  `PRs merged: ${s.prs_merged} of ${s.prs_opened} opened`,
11
- `Cost per merged PR: ${s.cost_per_merged_pr === null ? "n/a" : `$${s.cost_per_merged_pr.toFixed(2)}`}`,
12
+ `Cost per merged PR: ${s.cost_per_merged_pr === null ? "n/a" : totalLabel(s.cost_per_merged_pr, unpriced)}`,
12
13
  `Commits: ${s.commits}`,
13
14
  ...subscriptionLines(json.subscriptions),
14
15
  ].join("\n");
@@ -18,8 +19,8 @@ export async function summary(cfg, values) {
18
19
  function subscriptionLines(c) {
19
20
  if (!c)
20
21
  return [];
21
- const lines = c.rows.map((r) => `${subscriptionLabel(r)}: $${r.spend_usd.toFixed(2)} used of $${r.prorated_usd.toFixed(2)} paid (${r.multiple.toFixed(1)}×), ${r.sessions} session(s)`);
22
+ const lines = c.rows.map((r) => `${subscriptionLabel(r)}: ${totalLabel(r.spend_usd, r.unpriced_sessions ?? 0)} used of $${r.prorated_usd.toFixed(2)} paid (${r.multiple.toFixed(1)}×), ${r.sessions} session(s)`);
22
23
  if (c.unmatched.sessions > 0)
23
- lines.push(`On no listed plan: $${c.unmatched.spend_usd.toFixed(2)}, ${c.unmatched.sessions} session(s)`);
24
+ lines.push(`On no listed plan: ${totalLabel(c.unmatched.spend_usd, c.unmatched.unpriced_sessions ?? 0)}, ${c.unmatched.sessions} session(s)`);
24
25
  return lines;
25
26
  }
@@ -81,7 +81,7 @@ const sameAs = (path, text) => {
81
81
  const HOOK_CHANGE = {
82
82
  "claude-code": "Stop hook no longer async, so Berliner can ask the agent to run the tests",
83
83
  codex: "the reminder also runs on startup and resume",
84
- opencode: "the new reminder, and totals saved in order",
84
+ opencode: "the new reminder, totals saved in order, and a fork counts only its own messages",
85
85
  };
86
86
  // The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
87
87
  export async function upgrade(env, values) {
package/dist/ledger.js ADDED
@@ -0,0 +1,202 @@
1
+ import { readFileSync, rmSync } from "node:fs";
2
+ import { appendFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { readClaudeCode, readCodex } from "./agents/index.js";
5
+ import { stateDir } from "./config.js";
6
+ import { takeLock } from "./lock.js";
7
+ import { estimateCost } from "./prices.js";
8
+ import { claudeDir, codexDir } from "./setup/targets.js";
9
+ // For the lock wait and the backfill together.
10
+ const BUDGET_MS = 3_000;
11
+ const PRUNE_BYTES = 20 * 1024 * 1024;
12
+ const KEEP_MS = 60 * 24 * 60 * 60 * 1000;
13
+ const ROLLOUT = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/;
14
+ // In stateDir, not cacheDir: a cache is safe to delete, and losing this reprices every reply.
15
+ const paths = (env) => {
16
+ const dir = stateDir(env);
17
+ return { dir, ledger: join(dir, "replies.jsonl"), lock: join(dir, "replies.lock"), progress: join(dir, "replies-backfill.json") };
18
+ };
19
+ const entries = (dir, recursive = false) => readdir(dir, { recursive }).catch(() => []);
20
+ const toText = (lines) => lines.map((l) => `${JSON.stringify(l)}\n`).join("");
21
+ // Each batch starts on a line of its own, so a line cut off by a crash can't swallow the batch's first line; empty lines are skipped.
22
+ const append = (file, lines) => appendFile(file, `\n${toText(lines)}`);
23
+ // The earliest-started session owns a reply; a null start sorts last and a tie goes to the smaller id, so the owner is the
24
+ // same whatever order sessions report in. Starts are UTC ISO strings, which sort as text.
25
+ const before = (a, b) => (a.t === b.t ? a.s < b.s : b.t === null || (a.t !== null && a.t < b.t));
26
+ async function withLock(env, deadline, run) {
27
+ const { dir, lock } = paths(env);
28
+ await mkdir(dir, { recursive: true });
29
+ while (!takeLock(lock)) {
30
+ if (Date.now() >= deadline)
31
+ return null;
32
+ await new Promise((resolve) => setTimeout(resolve, 50));
33
+ }
34
+ // The hook child's deadline ends it with process.exit, which skips the finally below; only a lock this process wrote is removed.
35
+ const onExit = () => {
36
+ try {
37
+ if (readFileSync(lock, "utf8") === String(process.pid))
38
+ rmSync(lock, { force: true });
39
+ }
40
+ catch { }
41
+ };
42
+ process.on("exit", onExit);
43
+ try {
44
+ return await run();
45
+ }
46
+ finally {
47
+ process.off("exit", onExit);
48
+ rmSync(lock, { force: true });
49
+ }
50
+ }
51
+ // A missing ledger is an empty one; any other read error is thrown, since treating it as empty would reprice every reply.
52
+ async function readText(file) {
53
+ return readFile(file, "utf8").catch((err) => {
54
+ if (err.code === "ENOENT")
55
+ return "";
56
+ throw err;
57
+ });
58
+ }
59
+ // A line cut off by a crash, or otherwise unparseable, is skipped.
60
+ function parse(text) {
61
+ const lines = [];
62
+ for (const raw of text.split("\n")) {
63
+ try {
64
+ const l = JSON.parse(raw);
65
+ if (typeof l?.r !== "string" || typeof l.s !== "string")
66
+ continue;
67
+ lines.push({ r: l.r, s: l.s, t: typeof l.t === "string" ? l.t : null, c: typeof l.c === "number" ? l.c : null });
68
+ }
69
+ catch { }
70
+ }
71
+ return lines;
72
+ }
73
+ function priceOf(reply, prices) {
74
+ if (!prices)
75
+ return null;
76
+ const { costUsd, unpriced } = estimateCost({ [reply.model]: reply.tokens }, prices);
77
+ return unpriced.length ? null : Math.round(costUsd * 1e6);
78
+ }
79
+ // A session's place in the owner order is when its record file was made: a fork's copied entries keep their parent's timestamps,
80
+ // its file does not. startedAt stands in where the file system keeps no creation time.
81
+ export async function sessionTime(file, startedAt) {
82
+ const birth = (await stat(file).catch(() => null))?.birthtimeMs ?? 0;
83
+ return birth > 0 ? new Date(birth).toISOString() : startedAt;
84
+ }
85
+ // Every local Claude Code and Codex transcript, with the session id its file name gives.
86
+ async function transcripts(env) {
87
+ const found = [];
88
+ const projects = join(claudeDir(env), "projects");
89
+ for (const project of await entries(projects)) {
90
+ for (const name of await entries(join(projects, project))) {
91
+ if (name.endsWith(".jsonl"))
92
+ found.push({ file: join(projects, project, name), id: name.slice(0, -".jsonl".length), read: readClaudeCode });
93
+ }
94
+ }
95
+ const sessions = join(codexDir(env), "sessions");
96
+ for (const f of await entries(sessions, true)) {
97
+ const id = ROLLOUT.exec(basename(f))?.[1];
98
+ if (id)
99
+ found.push({ file: join(sessions, f), id, read: readCodex });
100
+ }
101
+ return found;
102
+ }
103
+ // Run under the lock. Claims are written unpriced, so a reply's price is still set by the first report that owns it.
104
+ async function claimTranscripts(env, deadline) {
105
+ const { ledger, progress } = paths(env);
106
+ let saved = {};
107
+ try {
108
+ saved = JSON.parse(await readFile(progress, "utf8")) ?? {};
109
+ }
110
+ catch { }
111
+ if (saved.complete === true)
112
+ return true;
113
+ const done = new Set(Array.isArray(saved.done) ? saved.done.filter((f) => typeof f === "string") : []);
114
+ const lines = [];
115
+ let complete = true;
116
+ for (const { file, id, read } of await transcripts(env)) {
117
+ if (done.has(file))
118
+ continue;
119
+ if (Date.now() >= deadline) {
120
+ complete = false;
121
+ break;
122
+ }
123
+ // A file removed or unreadable since the listing has nothing left to claim.
124
+ const facts = await read(file).catch(() => null);
125
+ const t = facts && (await sessionTime(file, facts.startedAt));
126
+ for (const r of facts?.replies ?? [])
127
+ lines.push({ r: r.id, s: id, t, c: null });
128
+ done.add(file);
129
+ }
130
+ if (lines.length)
131
+ await append(ledger, lines);
132
+ await writeFile(progress, JSON.stringify({ done: [...done], complete }));
133
+ return complete;
134
+ }
135
+ // Returns true once every local transcript has been claimed, false when the deadline or the lock stops it first.
136
+ export async function backfill(env, deadline) {
137
+ return (await withLock(env, deadline, () => claimTranscripts(env, deadline))) ?? false;
138
+ }
139
+ // The cost of the replies this session owns. Null when the lock and the backfill don't both finish within 3 seconds.
140
+ export async function priceSession(env, session, replies, prices) {
141
+ const deadline = Date.now() + BUDGET_MS;
142
+ return withLock(env, deadline, async () => {
143
+ // Until every transcript is claimed, a fork reported first would own the replies it copied from a parent not yet claimed.
144
+ if (!(await claimTranscripts(env, deadline)))
145
+ return null;
146
+ const { ledger } = paths(env);
147
+ // shortcut: the whole ledger is read on each report, not only the new replies; upgrade to an index if it slows the hook.
148
+ const text = await readText(ledger);
149
+ let lines = parse(text);
150
+ // shortcut: past 20 MB, sessions created over 60 days ago are dropped, as Claude Code deletes transcripts after 30; one still
151
+ // resumed after that has its replies priced again. Upgrade to a last-seen time per session if that happens in practice.
152
+ if (Buffer.byteLength(text) > PRUNE_BYTES) {
153
+ const cutoff = new Date(Date.now() - KEEP_MS).toISOString();
154
+ const kept = lines.filter((l) => l.t === null || l.t >= cutoff);
155
+ // Nothing old to drop: a rewrite would cost every report a 20 MB write for nothing.
156
+ if (kept.length < lines.length) {
157
+ lines = kept;
158
+ await writeFile(`${ledger}.${process.pid}`, toText(lines));
159
+ await rename(`${ledger}.${process.pid}`, ledger);
160
+ }
161
+ }
162
+ const wanted = new Map(replies.map((r) => [r.id, r]));
163
+ const known = new Map();
164
+ for (const l of lines) {
165
+ if (!wanted.has(l.r))
166
+ continue;
167
+ const k = known.get(l.r);
168
+ if (!k)
169
+ known.set(l.r, { owner: l, price: l.c, mine: l.s === session.id });
170
+ else {
171
+ if (before(l, k.owner))
172
+ k.owner = l;
173
+ k.price ??= l.c;
174
+ k.mine ||= l.s === session.id;
175
+ }
176
+ }
177
+ const add = [];
178
+ let micros = 0;
179
+ let owned = 0;
180
+ let priced = 0;
181
+ for (const reply of wanted.values()) {
182
+ const k = known.get(reply.id);
183
+ const claim = { r: reply.id, s: session.id, t: session.startedAt, c: null };
184
+ const owner = k && !before(claim, k.owner) ? k.owner : claim;
185
+ let price = k?.price ?? null;
186
+ if (owner.s === session.id && price === null)
187
+ price = claim.c = priceOf(reply, prices);
188
+ if (!k?.mine || claim.c !== null)
189
+ add.push(claim);
190
+ if (owner.s !== session.id)
191
+ continue;
192
+ owned++;
193
+ if (price !== null) {
194
+ priced++;
195
+ micros += price;
196
+ }
197
+ }
198
+ if (add.length)
199
+ await append(ledger, add);
200
+ return { costUsd: micros / 1e6, status: owned && priced === owned ? "exact" : priced ? "partial" : "unknown" };
201
+ });
202
+ }
package/dist/lock.js ADDED
@@ -0,0 +1,45 @@
1
+ import { linkSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ const LOCK_MS = 60_000;
3
+ // A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is moved aside and
4
+ // removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
5
+ export function takeLock(lock) {
6
+ if (createLock(lock))
7
+ return true;
8
+ const seen = statSync(lock, { throwIfNoEntry: false });
9
+ // Gone since: its child just unlocked, so it is free, not stale.
10
+ if (!seen)
11
+ return createLock(lock);
12
+ if (Date.now() - seen.mtimeMs < LOCK_MS)
13
+ return false;
14
+ const aside = `${lock}.${process.pid}`;
15
+ try {
16
+ renameSync(lock, aside);
17
+ }
18
+ catch (err) {
19
+ if (err.code === "ENOENT")
20
+ return false;
21
+ throw err;
22
+ }
23
+ if (statSync(aside).ino !== seen.ino) {
24
+ // shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
25
+ try {
26
+ linkSync(aside, lock);
27
+ }
28
+ catch { }
29
+ rmSync(aside, { force: true });
30
+ return false;
31
+ }
32
+ rmSync(aside, { force: true });
33
+ return createLock(lock);
34
+ }
35
+ function createLock(lock) {
36
+ try {
37
+ writeFileSync(lock, String(process.pid), { flag: "wx" });
38
+ return true;
39
+ }
40
+ catch (err) {
41
+ if (err.code === "EEXIST")
42
+ return false;
43
+ throw err;
44
+ }
45
+ }
package/dist/output.js CHANGED
@@ -30,3 +30,8 @@ const PLAN = {
30
30
  api: "API",
31
31
  };
32
32
  export const subscriptionLabel = (s) => `${VENDOR[s.vendor] ?? s.vendor} ${PLAN[s.plan] ?? s.plan}`;
33
+ // A session's cost in its three forms: every model priced, some priced (a floor), none priced.
34
+ export const costLabel = (usd, status) => status === "unknown" ? "Unknown" : `${status === "partial" ? "≥ " : ""}$${usd.toFixed(2)}`;
35
+ // A total of sessions, a floor whenever some of them are not fully priced. The count is for the line that names it.
36
+ export const totalLabel = (usd, unpriced) => `${unpriced > 0 ? "≥ " : ""}$${usd.toFixed(2)} est.`;
37
+ export const unpricedNote = (unpriced) => (unpriced > 0 ? ` · ${unpriced} ${unpriced === 1 ? "session" : "sessions"} not fully priced` : "");
package/dist/upload.js CHANGED
@@ -74,6 +74,7 @@ async function uploadOne(cfg, sessionId, file, budget) {
74
74
  size_bytes: size,
75
75
  sha256: hash.digest("hex"),
76
76
  caption: file.caption,
77
+ path_label: file.path_label,
77
78
  }, budget);
78
79
  const id = json.evidence.id;
79
80
  if (json.status === "exists")
@@ -10,7 +10,10 @@
10
10
  // - client.session.get({ path: { id } }) returns { data } with the server's Session, which holds cost, tokens and model,
11
11
  // so the messages are not needed;
12
12
  // - subagents run in child sessions (parentID set), each with its own cost and tokens, and
13
- // client.session.children({ path: { id } }) returns { data } with a session's direct children.
13
+ // client.session.children({ path: { id } }) returns { data } with a session's direct children;
14
+ // - client.session.messages({ path: { id } }) returns { data }, an array of { info, parts } (sdk/js/src/gen/types.gen.ts at
15
+ // v1.18.30: SessionMessagesData; an assistant info has role, time.created, cost and tokens). A fork's session row counts
16
+ // its parent's usage too, and its copied messages keep the parent's time.created, so `own` counts only newer ones.
14
17
  import { spawn } from "node:child_process";
15
18
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
16
19
  import { homedir } from "node:os";
@@ -37,13 +40,50 @@ async function topLevelId(client, id) {
37
40
  return session && !session.parentID ? session.id : null;
38
41
  }
39
42
 
43
+ const sum = (a, b) => ({
44
+ tokens: {
45
+ input: a.tokens.input + b.tokens.input,
46
+ output: a.tokens.output + b.tokens.output,
47
+ reasoning: a.tokens.reasoning + b.tokens.reasoning,
48
+ cache: { read: a.tokens.cache.read + b.tokens.cache.read, write: a.tokens.cache.write + b.tokens.cache.write },
49
+ },
50
+ cost: a.cost + b.cost,
51
+ });
52
+ const zero = () => ({ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 });
53
+
54
+ // What the session itself used: its assistant messages created at or after the session was. null when they can't be listed.
55
+ async function ownUsage(client, session) {
56
+ try {
57
+ const messages = (await within(client.session.messages({ path: { id: session.id } }), 5000))?.data;
58
+ if (!Array.isArray(messages)) return null;
59
+ let own = zero();
60
+ for (const { info: m } of messages) {
61
+ if (m?.role !== "assistant" || num(m.time?.created) < num(session.time?.created)) continue;
62
+ const t = m.tokens;
63
+ own = sum(own, {
64
+ tokens: {
65
+ input: num(t?.input),
66
+ output: num(t?.output),
67
+ reasoning: num(t?.reasoning),
68
+ cache: { read: num(t?.cache?.read), write: num(t?.cache?.write) },
69
+ },
70
+ cost: num(m.cost),
71
+ });
72
+ }
73
+ return own;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
40
79
  // The session with its subagents' cost and tokens added in, in the shape the CLI reads. OpenCode 1.18.30 keeps them apart:
41
80
  // a session's cost and tokens grow only from its own step-finish parts (packages/core/src/session/projector.ts:310-326,
42
81
  // applyUsage on the part's own sessionID, lines 89-109), and a live run with one task-tool subagent gave the parent 0.215
43
82
  // and the child 1.05.
44
83
  async function withChildren(client, session, depth = 0) {
45
84
  const children = depth < MAX_DEPTH ? (await client.session.children({ path: { id: session.id } }))?.data : null;
46
- if (!Array.isArray(children) || children.length === 0) return session;
85
+ const mine = await ownUsage(client, session);
86
+ if (!Array.isArray(children) || children.length === 0) return mine ? { ...session, own: mine } : session;
47
87
  const t = session.tokens;
48
88
  let cost = num(session.cost);
49
89
  const tokens = {
@@ -52,7 +92,9 @@ async function withChildren(client, session, depth = 0) {
52
92
  reasoning: num(t?.reasoning),
53
93
  cache: { read: num(t?.cache?.read), write: num(t?.cache?.write) },
54
94
  };
95
+ let own = mine;
55
96
  for (const c of await Promise.all(children.map((child) => withChildren(client, child, depth + 1)))) {
97
+ own = own && c.own ? sum(own, c.own) : null;
56
98
  cost += num(c.cost);
57
99
  tokens.input += num(c.tokens?.input);
58
100
  tokens.output += num(c.tokens?.output);
@@ -60,7 +102,7 @@ async function withChildren(client, session, depth = 0) {
60
102
  tokens.cache.read += num(c.tokens?.cache?.read);
61
103
  tokens.cache.write += num(c.tokens?.cache?.write);
62
104
  }
63
- return { ...session, cost, tokens };
105
+ return { ...session, cost, tokens, ...(own && { own }) };
64
106
  }
65
107
 
66
108
  // Each save's place in the order saves started, and per session the latest one written: a save that finishes after a later
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "keywords": [
6
6
  "claude-code",
package/skill/SKILL.md CHANGED
@@ -146,7 +146,7 @@ Report again with the same `--session-id` whenever something changes.
146
146
  - `--commits <n>` (outside the tools; the hooks count them): `git rev-list --count <base>..HEAD`
147
147
  - `--coverage <percent>`: from the test run's coverage summary.
148
148
  - `--pr <url>[,merge=<open|merged|closed>][,ci=<pending|running|passed|failed|none>]`: repeat per PR. URL from `gh pr view --json url`.
149
- - `--evidence <path>[::caption]`: repeat per screenshot or video.
149
+ - `--evidence <path>[::caption[::happy|unhappy|edge]]`: repeat per screenshot or video; the optional last part says whether it shows the happy path, an unhappy path or an edge case. A caption can't contain `::`.
150
150
  - `--public`: with `--evidence`, also make those files viewable without sign-in. See below.
151
151
  - `--task <id>`, `--task-url <url>`: the ticket. See [Tickets](#tickets).
152
152
  - `--project <name>`: only the ticket's tracker project or a project the user named. See [Projects](#projects).