@paxnax/berliner 0.5.3 → 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,10 +1,12 @@
1
1
  import { CliError } from "../client.js";
2
2
  import { print, warn } from "../output.js";
3
3
  import { uploadEvidence } from "../upload.js";
4
- import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
4
+ import { announceRefusal, resolveTeam } from "../team.js";
5
+ import { fromTool, patchFirst, projectHint, saveSession, sessionBody, warnTeam } from "./body.js";
5
6
  import { inTool } from "./hook.js";
6
7
  const required = ["session-id", "title", "repo", "cost", "model", "plan"];
7
8
  export async function report(cfg, values, positionals, env) {
9
+ await announceRefusal(env);
8
10
  // Usually an unquoted value with spaces, e.g. --title Fix the bug.
9
11
  if (positionals.length)
10
12
  throw new CliError(`unexpected argument "${positionals[0]}"; quote values that contain spaces`, "usage");
@@ -19,16 +21,20 @@ export async function report(cfg, values, positionals, env) {
19
21
  throw new CliError(`missing required flag(s): ${missing.map((f) => `--${f}`).join(", ")}`, "usage");
20
22
  if (values.public && !values.evidence?.length)
21
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);
26
+ const team = await resolveTeam(values, process.cwd(), env);
22
27
  const body = { ...sessionBody(values), plan: cfg.plan ?? values.plan };
23
28
  const id = values["session-id"];
24
29
  const path = `/v1/sessions/${encodeURIComponent(id)}`;
25
30
  // Inside a tool a report without --title is usually a follow-up: the derived title is only for a new session. A report
26
31
  // without a cost must not replace the saved one either.
27
32
  const { json: saved, dropped } = await (tool && (!titled || body.cost_usd === undefined)
28
- ? patchFirst(cfg, path, body, titled, values.plan)
29
- : saveSession(cfg, "PUT", path, body, values.plan));
33
+ ? patchFirst(cfg, path, body, titled, values.plan, team)
34
+ : saveSession(cfg, "PUT", path, { ...body, team: team ?? undefined }, values.plan));
30
35
  const json = dropped.length ? { ...saved, dropped } : saved;
31
36
  const { created, session } = json;
37
+ warnTeam(session, team);
32
38
  const text = `Saved session ${session.id} (${created ? "created" : "updated"}). ${session.pr_count} PR(s) linked.${projectHint(session)}`;
33
39
  const json_ = values.json === true;
34
40
  // Human mode prints the session line before uploads start; --json waits so stdout is one document.
@@ -39,7 +45,7 @@ export async function report(cfg, values, positionals, env) {
39
45
  print({ data: json, text }, true);
40
46
  return;
41
47
  }
42
- 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);
43
49
  if (json_)
44
50
  print({ data: { ...json, evidence: result }, text }, true);
45
51
  else {
@@ -52,8 +58,17 @@ export async function report(cfg, values, positionals, env) {
52
58
  if (values.strict && result.skipped.length)
53
59
  process.exitCode = 1;
54
60
  }
55
- // <path>[::<caption>]
61
+ // <path>[::<caption>[::happy|unhappy|edge]]
56
62
  function parseEvidence(spec) {
57
63
  const i = spec.indexOf("::");
58
- 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 };
59
74
  }
@@ -1,6 +1,8 @@
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
+ import { announceRefusal } from "../team.js";
3
4
  export async function session(cfg, values, positionals) {
5
+ await announceRefusal(process.env);
4
6
  if (positionals.length !== 1)
5
7
  throw new CliError("usage: berliner session <session-id> [--json]", "usage");
6
8
  const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(positionals[0])}`);
@@ -11,7 +13,7 @@ export async function session(cfg, values, positionals) {
11
13
  ["Repository", s.repository],
12
14
  ["Task", [s.task_id, s.task_url].filter(Boolean).join(" ")],
13
15
  ["Project", s.project],
14
- ["Estimated cost", `$${s.cost_usd.toFixed(2)}`],
16
+ ["Estimated cost", costLabel(s.cost_usd, s.cost_status)],
15
17
  ["Model", s.model],
16
18
  ["Plan", s.plan],
17
19
  ["Started", s.started_at],
@@ -19,6 +21,7 @@ export async function session(cfg, values, positionals) {
19
21
  ["Duration (s)", s.duration_seconds],
20
22
  ["Commits", s.commits],
21
23
  ["Coverage", s.coverage],
24
+ ["Tests", s.tests && `${s.tests.check}${s.tests.command ? ` (${s.tests.command})` : ""}${s.tests.by_ci ? " via CI" : ""}`],
22
25
  ["CI", s.ci],
23
26
  ["Merge", mergeLabel(s)],
24
27
  ];
@@ -1,12 +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
+ import { announceRefusal } from "../team.js";
3
4
  import { filterQuery } from "./query.js";
4
- const usd = (n) => `$${n.toFixed(2)}`;
5
5
  export async function sessions(cfg, values) {
6
+ await announceRefusal(process.env);
6
7
  const { json } = await request(cfg, "GET", `/v1/sessions${filterQuery(values, { limit: values.limit })}`);
7
8
  const rows = [["TASK", "TITLE", "REPO", "COST (est.)", "PRS", "CI", "MERGE"]];
8
9
  for (const s of json.sessions)
9
- 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)]);
10
11
  const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
11
12
  const lines = rows.map((r) => r
12
13
  .map((c, i) => c.padEnd(widths[i]))
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { CliError, request } from "../client.js";
7
+ import { askNudge } from "./nudge.js";
7
8
  import { configPath, DEFAULT_URL, readConfig, readConfigFile, saveConfig } from "../config.js";
8
9
  import { machineName, openBrowser, startConnect, waitForApproval } from "../setup/connect.js";
9
10
  import { describeClaudeHooks, describeCodexHooks, describeOpenCodePlugin, hookPath, installClaudeHooks, installCodexHooks, installOpenCodePlugin, } from "../setup/hooks.js";
@@ -90,6 +91,7 @@ export async function setup(env, values) {
90
91
  throw new CliError("There is no terminal to ask on. Run setup again with --yes to continue without asking.", "usage");
91
92
  if (!(await confirm(process.stdin, process.stdout)))
92
93
  throw new CliError("Cancelled. Nothing was changed.", "usage");
94
+ await askNudge(env);
93
95
  console.log();
94
96
  }
95
97
  if (!login) {
@@ -109,7 +111,8 @@ export async function setup(env, values) {
109
111
  throw new CliError("The request expired. Run setup again.", "http");
110
112
  const me = await request({ url, token: req.token }, "GET", "/v1/me");
111
113
  console.log(`✓ Approved by ${me.json.github_login}`);
112
- const file = saveConfig(env, url === DEFAULT_URL ? { token: req.token } : { token: req.token, url });
114
+ const { team, nudge } = readConfigFile(env) ?? {};
115
+ const file = saveConfig(env, { token: req.token, url: url === DEFAULT_URL ? undefined : url, team, nudge });
113
116
  console.log(`✓ Saved token "${name}" to ${tilde(file)}`);
114
117
  }
115
118
  let installFailed = false;
@@ -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
  }
@@ -0,0 +1,21 @@
1
+ import { CliError } from "../client.js";
2
+ import { readConfigFile, saveConfig } from "../config.js";
3
+ import { print } from "../output.js";
4
+ import { resolveTeamSource, UUID } from "../team.js";
5
+ const FROM = { flag: "--team", repo: ".berliner.json", env: "BERLINER_TEAM", config: "the global config", none: "" };
6
+ // The team for this directory: shown without an argument, saved in the global config with a uuid, removed with --none.
7
+ export async function team(env, values, positionals) {
8
+ if (positionals.length > 1 || (positionals.length && values.none))
9
+ throw new CliError("usage: berliner team [<uuid> | --none] [--json]", "usage");
10
+ if (positionals.length || values.none) {
11
+ const id = positionals[0];
12
+ if (id !== undefined && !UUID.test(id))
13
+ throw new CliError(`"${id}" is not a team id (a uuid)`, "usage");
14
+ const file = readConfigFile(env) ?? {};
15
+ saveConfig(env, { ...file, team: id });
16
+ return print({ data: { team: id ?? null }, text: id ? `Saved team ${id} in your global config.` : "Removed the team from your global config." }, values.json === true);
17
+ }
18
+ const resolved = await resolveTeamSource(values, process.cwd(), env);
19
+ const text = resolved.team ? `Team ${resolved.team} (from ${FROM[resolved.source]})` : "No team: sessions go on your own plan.";
20
+ print({ data: resolved, text }, values.json === true);
21
+ }
@@ -1,8 +1,10 @@
1
1
  import { CliError } from "../client.js";
2
2
  import { print } from "../output.js";
3
- import { fromTool, patchFirst, projectHint, saveSession, sessionBody } from "./body.js";
3
+ import { announceRefusal, resolveTeam } from "../team.js";
4
+ import { fromTool, patchFirst, projectHint, saveSession, sessionBody, warnTeam } from "./body.js";
4
5
  import { inTool } from "./hook.js";
5
6
  export async function update(cfg, values, positionals, env) {
7
+ await announceRefusal(env);
6
8
  const tool = inTool(env);
7
9
  // Inside a tool the session id is the tool's, so a positional one is ignored like --session-id.
8
10
  if (positionals.length !== 1 && !(tool && positionals.length === 0)) {
@@ -18,8 +20,10 @@ export async function update(cfg, values, positionals, env) {
18
20
  const id = tool ? tool.sessionId : positionals[0];
19
21
  // A plan is only sent when --plan is passed, so an update never changes it unasked; BERLINER_PLAN then replaces it.
20
22
  const body = { ...sessionBody(values), plan: values.plan === undefined ? undefined : (cfg.plan ?? values.plan) };
23
+ const team = await resolveTeam(values, process.cwd(), env);
21
24
  const path = `/v1/sessions/${encodeURIComponent(id)}`;
22
- const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, values.plan) : saveSession(cfg, "PATCH", path, body, values.plan));
25
+ const { json: saved, dropped } = await (tool ? patchFirst(cfg, path, body, titled, values.plan, team) : saveSession(cfg, "PATCH", path, body, values.plan));
23
26
  const json = dropped.length ? { ...saved, dropped } : saved;
27
+ warnTeam(json.session, team);
24
28
  print({ data: json, text: `Updated session ${json.session.id}. ${json.session.pr_count} PR(s) linked.${projectHint(json.session)}` }, values.json === true);
25
29
  }
@@ -6,6 +6,7 @@ import { gunzipSync } from "node:zlib";
6
6
  import { CliError } from "../client.js";
7
7
  import { hookPath, updatedHooks } from "../setup/hooks.js";
8
8
  import { detectTools, skillTargets } from "../setup/targets.js";
9
+ import { askNudge } from "./nudge.js";
9
10
  import { confirm, installCommand, showPlan, tilde } from "./setup.js";
10
11
  const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
11
12
  async function latestVersion(env) {
@@ -78,9 +79,9 @@ const sameAs = (path, text) => {
78
79
  };
79
80
  // What a hook gains from this release, for the plan.
80
81
  const HOOK_CHANGE = {
81
- "claude-code": "the reminder also runs on startup and resume",
82
+ "claude-code": "Stop hook no longer async, so Berliner can ask the agent to run the tests",
82
83
  codex: "the reminder also runs on startup and resume",
83
- 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",
84
85
  };
85
86
  // The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
86
87
  export async function upgrade(env, values) {
@@ -137,6 +138,7 @@ export async function upgrade(env, values) {
137
138
  // Without an install, the skill is all Continue covered.
138
139
  if (installing && stale.length && !(await confirm(process.stdin, process.stdout, "Upgrade the skill too? [Y/n] ")))
139
140
  stale = [];
141
+ await askNudge(env);
140
142
  console.log();
141
143
  }
142
144
  if (installing) {
package/dist/config.js CHANGED
@@ -7,14 +7,29 @@ export function configPath(env) {
7
7
  }
8
8
  export const stateDir = (env) => join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "berliner");
9
9
  export const cacheDir = (env) => join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "berliner");
10
+ function readNudge(raw) {
11
+ if (typeof raw !== "object" || raw === null)
12
+ return undefined;
13
+ const { off_all, off, asked } = raw;
14
+ return {
15
+ ...(asked === true && { asked: true }),
16
+ ...(off_all === true && { off_all: true }),
17
+ ...(Array.isArray(off) && { off: off.filter((r) => typeof r === "string") }),
18
+ };
19
+ }
10
20
  // A missing or unreadable file is the same as no file: the environment may still hold the token.
11
21
  export function readConfigFile(env) {
12
22
  try {
13
23
  const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
14
24
  if (typeof parsed !== "object" || parsed === null)
15
25
  return null;
16
- const { token, url } = parsed;
17
- return { token: typeof token === "string" ? token : undefined, url: typeof url === "string" ? url : undefined };
26
+ const { token, url, team, nudge } = parsed;
27
+ return {
28
+ token: typeof token === "string" ? token : undefined,
29
+ url: typeof url === "string" ? url : undefined,
30
+ team: typeof team === "string" ? team : undefined,
31
+ nudge: readNudge(nudge),
32
+ };
18
33
  }
19
34
  catch {
20
35
  return null;
package/dist/gh.js CHANGED
@@ -5,8 +5,8 @@ const MERGE = { OPEN: "open", MERGED: "merged", CLOSED: "closed" };
5
5
  // CheckRun conclusions and StatusContext states.
6
6
  const FAILED = ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"];
7
7
  const PENDING = ["PENDING", "EXPECTED"];
8
- // `gh pr view --json state,statusCheckRollup` as Berliner's merge and CI states. statusCheckRollup mixes CheckRuns (status,
9
- // conclusion; conclusion is "" until completed) and commit StatusContexts (state).
8
+ // `gh pr view --json state,statusCheckRollup,headRefOid` as Berliner's merge and CI states and the head commit they belong to.
9
+ // statusCheckRollup mixes CheckRuns (status, conclusion; conclusion is "" until completed) and commit StatusContexts (state).
10
10
  export function prState(view) {
11
11
  const merge_state = MERGE[String(view.state)];
12
12
  if (!merge_state)
@@ -19,10 +19,12 @@ export function prState(view) {
19
19
  : checks.length
20
20
  ? "passed"
21
21
  : "none";
22
- return { merge_state, ci_state };
22
+ // Anything else the server would refuse, losing the states with it.
23
+ const head_sha = typeof view.headRefOid === "string" && /^[0-9a-f]{40}$/.test(view.headRefOid) ? view.headRefOid : null;
24
+ return { merge_state, ci_state, head_sha };
23
25
  }
24
26
  // The machine's gh, found on PATH; it rejects when gh is missing (code ENOENT), fails or takes over 5 seconds.
25
27
  export async function viewPr(url) {
26
- const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup"], { timeout: 5000 });
28
+ const { stdout } = await run("gh", ["pr", "view", url, "--json", "state,statusCheckRollup,headRefOid"], { timeout: 5000 });
27
29
  return prState(JSON.parse(stdout));
28
30
  }
package/dist/index.js CHANGED
@@ -6,12 +6,14 @@ import { CliError } from "./client.js";
6
6
  import { del } from "./commands/delete.js";
7
7
  import { evidence } from "./commands/evidence.js";
8
8
  import { hook } from "./commands/hook.js";
9
+ import { nudge } from "./commands/nudge.js";
9
10
  import { project, projects } from "./commands/projects.js";
10
11
  import { report } from "./commands/report.js";
11
12
  import { session } from "./commands/session.js";
12
13
  import { sessions } from "./commands/sessions.js";
13
14
  import { setup } from "./commands/setup.js";
14
15
  import { skill } from "./commands/skill.js";
16
+ import { team } from "./commands/team.js";
15
17
  import { summary } from "./commands/summary.js";
16
18
  import { update } from "./commands/update.js";
17
19
  import { upgrade } from "./commands/upgrade.js";
@@ -33,6 +35,11 @@ Commands:
33
35
  projects the project names in use, then each repository's default [--json]
34
36
  project [<name>] show or save this repository's default project; --none saves "no project"
35
37
  [--none --repo <owner/name> --json]
38
+ team [<uuid>] show or save the team new sessions are reported to; --none clears it
39
+ a team can also come from --team <uuid> / --no-team, .berliner.json {"team": "<uuid>"}, BERLINER_TEAM
40
+ [--none --json]
41
+ nudge [on|off] show or set whether Berliner asks agents to run the tests before they hand work back, for this repository
42
+ [--all for every repository]
36
43
  delete <session-id> [--evidence <id>] [--yes]
37
44
  evidence public <id> let anyone with the link view one file; prints its URL and Markdown [--json]
38
45
  evidence private <id> make it viewable only by you again [--json]
@@ -40,7 +47,7 @@ Commands:
40
47
  skill print the bundled SKILL.md
41
48
  hook <tool> run by the hooks of claude-code, codex or opencode; reads the payload on stdin
42
49
 
43
- Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan)`;
50
+ Environment: BERLINER_TOKEN, BERLINER_URL, BERLINER_PLAN (replaces --plan), BERLINER_TEAM`;
44
51
  const args = parse(process.argv.slice(2));
45
52
  // Reporting must never fail the agent's own pipeline unless it asks to (anti-goal 1).
46
53
  const softFail = (args.command === "report" || args.command === "update") && !args.values.strict;
@@ -53,6 +60,8 @@ const handled = new Set([
53
60
  "summary",
54
61
  "projects",
55
62
  "project",
63
+ "team",
64
+ "nudge",
56
65
  "delete",
57
66
  "evidence",
58
67
  "skill",
@@ -84,6 +93,10 @@ try {
84
93
  await projects(cfg(), args.values);
85
94
  else if (args.command === "project")
86
95
  await project(cfg(), args.values, args.positionals);
96
+ else if (args.command === "team")
97
+ await team(process.env, args.values, args.positionals);
98
+ else if (args.command === "nudge")
99
+ await nudge(process.env, args.values, args.positionals);
87
100
  else if (args.command === "delete")
88
101
  await del(cfg(), args.values, args.positionals);
89
102
  else if (args.command === "evidence")
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` : "");