@paxnax/berliner 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,8 +9,7 @@ export async function readCodex(rolloutPath) {
9
9
  let taskUrl = null;
10
10
  let latest = zero;
11
11
  let latestTotal = 0;
12
- // A resumed session restarts the running total in the same rollout, so the totals before each restart are carried over.
13
- let carried = zero;
12
+ const byModel = {};
14
13
  for await (const e of jsonl(rolloutPath)) {
15
14
  span.add(e.timestamp);
16
15
  const p = e.payload;
@@ -29,18 +28,21 @@ export async function readCodex(rolloutPath) {
29
28
  if (!u)
30
29
  continue;
31
30
  const total = num(u.total_tokens);
32
- // shortcut: an overflow reset whose first total exceeds the running total is missed; compare every count instead of total_tokens if that shows up.
33
- if (total < latestTotal)
34
- carried = addTokens(carried, latest);
35
- latestTotal = total;
36
- latest = {
31
+ // A resumed session restarts the running total in the same rollout: it drops, or its first count is just its own last request.
32
+ const restart = total < latestTotal || (total > latestTotal && latestTotal > 0 && total === num(p?.info?.last_token_usage?.total_tokens));
33
+ const cur = {
37
34
  input: Math.max(0, num(u.input_tokens) - num(u.cached_input_tokens) - num(u.cache_write_input_tokens)),
38
35
  output: num(u.output_tokens),
39
36
  cacheRead: num(u.cached_input_tokens),
40
37
  cacheWrite: num(u.cache_write_input_tokens),
41
38
  cacheWrite1h: 0,
42
39
  };
40
+ // Each event's growth goes to the model it was made with, so a switch mid-session splits the usage.
41
+ const delta = Object.fromEntries(Object.entries(cur).map(([k, v]) => [k, restart ? v : Math.max(0, v - latest[k])]));
42
+ if (model)
43
+ byModel[model] = addTokens(byModel[model] ?? zero, delta);
44
+ latestTotal = total;
45
+ latest = cur;
43
46
  }
44
- const tokens = addTokens(carried, latest);
45
- return { model, tokensByModel: model ? { [model]: tokens } : {}, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
47
+ return { model, tokensByModel: byModel, costUsd: null, startedAt: span.first, endedAt: span.last, firstPrompt, taskUrl };
46
48
  }
@@ -1,18 +1,20 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { CliError, request } from "../client.js";
3
3
  export async function del(cfg, values, positionals) {
4
- if (positionals.length !== 1)
4
+ // A blank --evidence must not fall through to deleting the whole session.
5
+ const { evidence } = values;
6
+ if (positionals.length !== 1 || (evidence && (evidence.length !== 1 || !evidence[0].trim())))
5
7
  throw new CliError("usage: berliner delete <session-id> [--evidence <id>] [--yes]", "usage");
6
8
  const id = positionals[0];
7
- const evidenceId = values.evidence?.[0];
8
- if (evidenceId) {
9
+ const evidenceId = evidence?.[0];
10
+ if (evidenceId !== undefined) {
9
11
  // Evidence ids are global, so the session named on the command line must actually hold this one.
10
12
  const { json } = await request(cfg, "GET", `/v1/sessions/${encodeURIComponent(id)}`);
11
13
  if (!json.session.evidence.some((e) => e.id === evidenceId)) {
12
14
  throw new CliError(`evidence ${evidenceId} is not part of session ${id}`, "usage");
13
15
  }
14
16
  }
15
- const question = evidenceId ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
17
+ const question = evidenceId !== undefined ? `Delete evidence ${evidenceId} of session ${id}? [y/N] ` : `Delete session ${id} and its evidence? [y/N] `;
16
18
  if (!values.yes) {
17
19
  if (!process.stdin.isTTY)
18
20
  throw new CliError("refusing to delete without confirmation; pass --yes to delete without a prompt", "usage");
@@ -24,7 +26,7 @@ export async function del(cfg, values, positionals) {
24
26
  return;
25
27
  }
26
28
  }
27
- if (evidenceId) {
29
+ if (evidenceId !== undefined) {
28
30
  await request(cfg, "DELETE", `/v1/evidence/${encodeURIComponent(evidenceId)}`);
29
31
  console.log(`Deleted evidence ${evidenceId}.`);
30
32
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, linkSync, mkdirSync, renameSync, rmSync, statSync, 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";
@@ -152,15 +152,36 @@ async function child(tool, file, env) {
152
152
  clearTimeout(timer);
153
153
  }
154
154
  }
155
- // A lock younger than a minute belongs to a running child. An older one was left by a child that died: it is removed and
156
- // created again with wx, so of two children that find it stale only one gets it, and the other exits as for a held lock.
157
- function takeLock(lock) {
155
+ // 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
156
+ // removed only if it is the very lock that was checked, so of two children that find it stale only one gets it.
157
+ export function takeLock(lock) {
158
158
  if (createLock(lock))
159
159
  return true;
160
- if (Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? 0) < LOCK_MS)
160
+ const seen = statSync(lock, { throwIfNoEntry: false });
161
+ // Gone since: its child just unlocked, so it is free, not stale.
162
+ if (!seen)
163
+ return createLock(lock);
164
+ if (Date.now() - seen.mtimeMs < LOCK_MS)
161
165
  return false;
162
- // shortcut: a child that found the lock stale just before another one replaced it removes the new lock; this needs a child to have died.
163
- rmSync(lock, { force: true });
166
+ const aside = `${lock}.${process.pid}`;
167
+ try {
168
+ renameSync(lock, aside);
169
+ }
170
+ catch (err) {
171
+ if (err.code === "ENOENT")
172
+ return false;
173
+ throw err;
174
+ }
175
+ if (statSync(aside).ino !== seen.ino) {
176
+ // shortcut: a third child can lock in the moment before this lock is put back; that needs a dead child and three at once.
177
+ try {
178
+ linkSync(aside, lock);
179
+ }
180
+ catch { }
181
+ rmSync(aside, { force: true });
182
+ return false;
183
+ }
184
+ rmSync(aside, { force: true });
164
185
  return createLock(lock);
165
186
  }
166
187
  function createLock(lock) {
@@ -114,7 +114,8 @@ export async function setup(env, values) {
114
114
  }
115
115
  let installFailed = false;
116
116
  if (!values["no-install"]) {
117
- const run = spawnSync(install[0], install.slice(1), { encoding: "utf8" });
117
+ // From home, so the install never picks up the cwd's .npmrc.
118
+ const run = spawnSync(install[0], install.slice(1), { encoding: "utf8", cwd: homedir() });
118
119
  installFailed = run.status !== 0;
119
120
  if (!installFailed)
120
121
  console.log(`✓ Installed berliner ${version}`);
@@ -1,12 +1,15 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { dirname } from "node:path";
5
+ import { gunzipSync } from "node:zlib";
4
6
  import { CliError } from "../client.js";
5
7
  import { hookPath, updatedHooks } from "../setup/hooks.js";
6
8
  import { detectTools, skillTargets } from "../setup/targets.js";
7
9
  import { confirm, installCommand, showPlan, tilde } from "./setup.js";
10
+ const registryOf = (env) => (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
8
11
  async function latestVersion(env) {
9
- const registry = (env.npm_config_registry || "https://registry.npmjs.org").replace(/\/+$/, "");
12
+ const registry = registryOf(env);
10
13
  try {
11
14
  const res = await fetch(`${registry}/@paxnax/berliner/latest`);
12
15
  if (!res.ok)
@@ -21,15 +24,35 @@ async function latestVersion(env) {
21
24
  }
22
25
  }
23
26
  // The skill text comes from the release being installed, so the plan's "already up to date" and what is written match it.
24
- function skillOf(version, bundled) {
27
+ // It is read out of the release tarball as data: running the release (npx) would load the cwd's .npmrc and node_modules.
28
+ async function skillOf(version, bundled, env) {
25
29
  if (bundled)
26
30
  return readFileSync(new URL("../../skill/SKILL.md", import.meta.url), "utf8");
27
- // --bun keeps the CLI on Bun: its shebang prefers node when both exist.
28
- const [cmd, ...pre] = process.versions.bun ? ["bunx", "--bun"] : ["npx", "--yes"];
29
- const run = spawnSync(cmd, [...pre, `@paxnax/berliner@${version}`, "skill"], { encoding: "utf8" });
30
- if (run.status !== 0 || !run.stdout)
31
- throw new CliError(`Could not read the skill of berliner ${version} (${(run.error?.message ?? run.stderr).trim() || `exited with ${run.status}`}). Nothing was changed.`, "network");
32
- return run.stdout;
31
+ try {
32
+ const meta = await fetch(`${registryOf(env)}/@paxnax/berliner/${version}`);
33
+ if (!meta.ok)
34
+ throw new Error(`HTTP ${meta.status}`);
35
+ const { dist } = (await meta.json());
36
+ if (typeof dist?.tarball !== "string")
37
+ throw new Error("no tarball in the reply");
38
+ const res = await fetch(dist.tarball);
39
+ if (!res.ok)
40
+ throw new Error(`HTTP ${res.status}`);
41
+ const tar = gunzipSync(Buffer.from(await res.arrayBuffer()));
42
+ // shortcut: plain ustar names only (no pax or GNU long names), enough for npm's short paths; parse pax if a path passes 100 bytes.
43
+ const field = (at, end) => tar.toString("utf8", at, end).split("\0")[0];
44
+ for (let at = 0; at + 512 <= tar.length && tar[at];) {
45
+ const prefix = field(at + 345, at + 500);
46
+ const size = Number.parseInt(field(at + 124, at + 136).trim() || "0", 8);
47
+ if (`${prefix && `${prefix}/`}${field(at, at + 100)}` === "package/skill/SKILL.md")
48
+ return tar.toString("utf8", at + 512, at + 512 + size);
49
+ at += 512 + Math.ceil(size / 512) * 512;
50
+ }
51
+ throw new Error("no skill/SKILL.md in the package");
52
+ }
53
+ catch (err) {
54
+ throw new CliError(`Could not read the skill of berliner ${version} (${err.message}). Nothing was changed.`, "network");
55
+ }
33
56
  }
34
57
  // Release versions only (x.y.z): the registry's latest tag never points at a prerelease.
35
58
  const newer = (a, b) => {
@@ -49,7 +72,7 @@ const sameAs = (path, text) => {
49
72
  const HOOK_CHANGE = {
50
73
  "claude-code": "the reminder also runs on startup and resume",
51
74
  codex: "the reminder also runs on startup and resume",
52
- opencode: "the new reminder",
75
+ opencode: "the new reminder, and totals saved in order",
53
76
  };
54
77
  // The command, the skill, and Berliner hooks already installed: the token and config are setup's, as is adding a hook.
55
78
  export async function upgrade(env, values) {
@@ -61,7 +84,7 @@ export async function upgrade(env, values) {
61
84
  const install = installCommand(latest);
62
85
  const tools = detectTools(env);
63
86
  const targets = values["no-skill"] ? [] : skillTargets(tools, env);
64
- const skill = targets.length ? skillOf(target, !installing) : "";
87
+ const skill = targets.length ? await skillOf(target, !installing, env) : "";
65
88
  let stale = targets.filter((t) => !sameAs(t.path, skill));
66
89
  // Computed now, so what is written is what the plan showed.
67
90
  const hooks = tools.flatMap((tool) => {
@@ -107,7 +130,8 @@ export async function upgrade(env, values) {
107
130
  console.log();
108
131
  }
109
132
  if (installing) {
110
- const run = spawnSync(install[0], install.slice(1), { encoding: "utf8" });
133
+ // From home, so the install never picks up the cwd's .npmrc.
134
+ const run = spawnSync(install[0], install.slice(1), { encoding: "utf8", cwd: homedir() });
111
135
  if (run.status !== 0) {
112
136
  console.error(`✗ Could not install the berliner command:\n${(run.error?.message ?? run.stdout + run.stderr).trimEnd()}`);
113
137
  throw new CliError(`The skill was left as it is. Install the command yourself, then run upgrade again: ${install.join(" ")}`, "usage");
@@ -77,20 +77,33 @@ async function save(client, sessionID) {
77
77
  return session;
78
78
  }
79
79
 
80
- async function report(client, event, sessionID) {
81
- try {
82
- const session = await save(client, sessionID);
83
- if (!session) return;
84
- // Detached and unreferenced: OpenCode never waits on it.
85
- const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
86
- child.on("error", () => {});
87
- child.stdin.on("error", () => {});
88
- child.stdin.end(JSON.stringify({ event, session }));
89
- child.unref();
90
- } catch {
91
- // A report that can't be sent is skipped; the next idle sends the latest totals.
92
- }
93
- }
80
+ // One session's saves run one at a time, in the order they came, so an older total never overwrites a newer one.
81
+ // shortcut: only within this OpenCode process; two `berliner hook` processes can still write out of order, lock there if that shows up.
82
+ const queues = new Map();
83
+ const serial = (id, fn) => {
84
+ const p = (queues.get(id) ?? Promise.resolve()).then(fn, fn);
85
+ queues.set(
86
+ id,
87
+ p.catch(() => {}),
88
+ );
89
+ return p;
90
+ };
91
+
92
+ const report = (client, event, sessionID) =>
93
+ serial(sessionID, async () => {
94
+ try {
95
+ const session = await save(client, sessionID);
96
+ if (!session) return;
97
+ // Detached and unreferenced: OpenCode never waits on it.
98
+ const child = spawn("berliner", ["hook", "opencode"], { detached: true, stdio: ["pipe", "ignore", "ignore"] });
99
+ child.on("error", () => {});
100
+ child.stdin.on("error", () => {});
101
+ child.stdin.end(JSON.stringify({ event, session }));
102
+ child.unref();
103
+ } catch {
104
+ // A report that can't be sent is skipped; the next idle sends the latest totals.
105
+ }
106
+ });
94
107
 
95
108
  export const BerlinerPlugin = async ({ client }) => ({
96
109
  "shell.env": async (input, output) => {
@@ -102,7 +115,7 @@ export const BerlinerPlugin = async ({ client }) => ({
102
115
  if (!top) return;
103
116
  output.env.OPENCODE_SESSION_ID = top;
104
117
  // So that report finds the totals before the session's first idle. Not reported from here: that waits for idle.
105
- await save(client, top);
118
+ await serial(top, () => save(client, top));
106
119
  } catch {
107
120
  // Never fail the agent's shell.
108
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paxnax/berliner",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Report coding-agent sessions to Berliner",
5
5
  "type": "module",
6
6
  "engines": {